o
    .j}                    @   s\  d 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mZmZmZmZmZ ddlZddlZddlZddlmZ eejZedZeeZejdd Zejd	d
 Zeddd Z eddd Z!eddd Z"eddd Z#dZ$edZ%dd Z&eddd Z'ejddgd d!d" Z(ejdd#gd d$d% Z)ejd&d#gd d'd( Z*ejd&dgd d)d* Z+ed+ed,dwd.d/Z,ed0 Z-ed1ed2d3d4 Z.ed5d6d7 Z/ed8d9d: Z0d;Z1ed<Z2ed=d>d? Z3ejd=d#gd d@dA Z4edBedCedDdwdEdFZ5e6dG7dHZ8dIdJ Z9edKdLdM Z:ejdNdgd dOdP Z;ejdNd#gd dQdR Z<edSdTdU Z=ddl>Z?edVedWedXdwdYdZZ@ed[ed\ed]dwd^d_ZAed`dadb ZBedcddde ZCedfdgdh ZDediedjedkdxdldmZEedndodp ZFedqdrds ZGedtdudv ZHedwedxdydzd{ZIed|ed}dwd~dZJededdwddZKeddd ZLeddd ZMejdd#gd dd ZNdZOdd ZPededdxddZQeddd ZReddd ZSejdd#gd dd ZTejdd#gd dd ZUdd ZVdd ZWejdd#gd dd ZXejdd#gd dd ZYdd ZZejdd#gd dd Z[ejdd#gd dd Z\eddd Z]dZ^dZ_ejdd#gd dd Z`dd ZadZbdacdd Zdejdd#gd dd Zeejdd#gd ddÄ Zfejdd#gd ddƄ ZgdZhedȡedɡdd˄ Zied̡dd΄ ZjedϡedСdd҄ ZkedӡddՄ ZledփZmedסedءddڄ Zned Zoed܃Zpedݡedޡdd Zqeddd Zreddd Zseddd Ztejdd#gd dd Zuededdd Zveddd Zweddd Zxeddd ZydZzh dZ{h dZ|ddl}Z~ed Zejdd d ZdededefddZdedefddZdedefd	d
ZedZejdd dedefddZdedefddZdededefddZdededefddZdedefddZdedefddZed ed ededgZdd Zeded d!d" Zed#ed$dzd&d'Zd{d)ed*efd+d,Zed-Zed. Zejdd ed/ed0ed1dwd2d3Zed4d5d6 Zejd7d#gd d8d9 Zejd:d#gd d;d< Zd=d> Zed-ed?d@ZejdAd#gd dBdC ZdadDdE ZejdFd#gd dGdH ZedIdJdK ZejdLd#gd dMdN ZdOZdPZdQZdRefdSdTZejdUdVd%ig dWdXejdYdVd%ig dWdXejdZg dWd d[d\ Zejd]dVd%ig dWdXejd^dVd%ig dWdXejd_g dWd d`da Zejdbg dWd ejdcg dWd ejddg dWd dzdedfZedgkr,e Zejdhedidj ejdkdldmdn ejdod(d(dpgdq e ZejrejeejejfddrZe  edsej  ejdtejdudv dS dS (|  a8  
Simple Flask server for the PARA experiment dashboard.
Serves static HTML reports + media with directory listing fallback.

Usage:
    python serve.py                  # localhost:8080
    python serve.py --public         # + cloudflared tunnel for public URL
    python serve.py --port 9090      # custom port
    N)Path)	Flasksend_from_directory	send_fileredirectabortrequestResponsemake_responsejsonify)datetime/data/cameronc                 C   s<   | j dd | j dd | j dd | j dd | S )	zIAdd Accept-Ranges to all responses so browsers know they can seek videos.Accept-RangesbyteszAccess-Control-Allow-Origin*zAccess-Control-Allow-MethodszGET, POST, OPTIONSzAccess-Control-Allow-HeaderszContent-Type, X-Agents-Password)headers
setdefault)response r   +/data/cameron/para/.agents/reports/serve.pyadd_range_header   s
   r   c                   C   s   t jdkrdS dS )z=Short-circuit CORS preflight requests with the headers above.OPTIONS)    N)r   methodr   r   r   r   _cors_preflight)   s   
r   /c                  C   s   t d } |  rtt dS dS )N
index.htmlzC<h1>No reports yet</h1><p>Agents haven't generated any reports.</p>)REPORTS_DIRexistsr   )Z
index_filer   r   r   index0      
r    z
/style.cssc                   C   
   t tdS )Nz	style.cssr   r   r   r   r   r   style8   s   
r$   z/lifec                  C   s"   t d} | d  rt| dS dS )N/data/cameron/lifezdashboard.html)zLife dashboard not found.  )r   r   r   )Zlife_dirr   r   r   life_dashboardA   s   
r'   z/life/vision/<path:filename>c                 C   s   t td| S )Nz/data/cameron/life/visionr   r   filenamer   r   r   life_visionI      r+   Zfabregasz/data/cameron/life/notes.mdc                  C   s0   ddl m} m} | jdtkr|d d S d S )Nr   r   r   zX-Notes-Auth  )flaskr   r   r   get_NOTES_PASSWORDr-   r   r   r   _check_notes_authV   s   r2   z/notesc                   C   s   t tddS )Nr%   z
notes.htmlr(   r   r   r   r   
notes_page\   r,   r3   z
/api/notesGET)methodsc                   C   s$   t   t rt dddifS dS )N   zContent-Typeztext/plain; charset=utf-8)r   r6   )r2   _NOTES_FILEr   	read_textr   r   r   r   	get_notesa   s   r9   POSTc                  C   s(   t   ddlm}  t| jdd dS )Nr   r   TZas_text)okr6   )r2   r/   r   r7   
write_textget_datar;   r   r   r   
save_notesi   s   r@   z/api/annotatec            	   
   C   s>  ddl } tjdddpi }|dd}|dd}|di p i }|r%|s.td	d
ddfS t|}|du s:| sFtd	d| ddfS |d }i }| rez	| |	 }W n t
yd   i }Y nw |||< z|| j|ddd W n ty } ztd	t|ddfW  Y d}~S d}~ww tdt|ddfS )aY  Save one rollout's annotation to `<eval_dir>/_annotations.json`.

    Body: {"eval_dir": "<browse-relative path>",
           "rollout_id": "rollout_NNN",
           "fields": {"success": "yes|partial|no",
                       "touched_obj": bool,
                       "failure_mode": "..."|null,
                       "notes": "..."}}
    r   NT)Zforcesilenteval_dirr   Z
rollout_idfieldsFzmissing eval_dir / rollout_id)r=   error  zeval_dir not allowed/found:   _annotations.json   )indentZ	sort_keys  )r=   savedr6   )jsonr   get_jsonr0   r   _check_browse_pathis_dirr   loadsr8   	Exceptionr>   dumpsOSErrorstr)	rL   bodyeval_relZrollrC   	eval_pathann_pathstateer   r   r   annotate_rolloutq   s4   "r[   c                  C   s   ddl } tjdd}|sti dfS t|}|du s| s%ti dfS |d }| s3ti dfS zt| |	 dfW S  t
yN   ti df Y S w )zJReturn the existing `_annotations.json` for a given eval_dir (browse-rel).r   NrB   r   r6   rG   )rL   r   argsr0   r   rN   rO   r   rP   r8   rQ   )rL   rV   rW   rX   r   r   r   annotate_get   s   r]   z/567_projectz/567_project/<path:filename>r   c                 C      t d}| rt|| S dS )Nz8/data/cameron/567_augmentation_viewpoint_project/websitez
Not found.r&   r   rO   r   r*   Zsite_dirr   r   r   school_project      
rb   Zmeeting_notesz/meeting_notesz/meeting_notes/c                  C   s   g } t  rYtt  ddD ]J}| rX|d  rX|d  }|jdddd}|dD ]}|	drA|d	d
 
 } nq0t|d  jd}| |j||f qddd | D }d|pgd dS )z%List all meeting notes, newest first.Treversenotes.md- _
z# rH   N%Y-%m-%d %H:%Mr   c                 s   s.    | ]\}}}d | d| d| dV  qdS )z<li><a href="/meeting_notes/">z</a><span class="meta"></span></li>Nr   ).0Zslugtitlemtimer   r   r   	<genexpr>   s    
z&meeting_notes_index.<locals>.<genexpr>a0  <!DOCTYPE html><html><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meeting Notes</title>
<style>
body { font-family: -apple-system, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; background: #fff; color: #1f2328; }
a { color: #0969da; text-decoration: none; } a:hover { text-decoration: underline; }
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
ul { list-style: none; padding: 0; }
li { padding: 0.7rem 0; border-bottom: 1px solid #d8dee4; display: flex; justify-content: space-between; align-items: center; }
li a { font-size: 1rem; font-weight: 500; }
.meta { color: #656d76; font-size: 0.8rem; }
nav { margin-bottom: 1rem; font-size: 0.85rem; }
</style></head><body>
<nav><a href="/">Dashboard</a></nav>
<h1>Meeting Notes</h1>
<ul>z5<li style="color:#656d76;">No meeting notes yet.</li></ul>
</body></html>)	NOTES_DIRrO   sortediterdirr   r8   namereplacesplit
startswithstripr   fromtimestampstatst_mtimestrftimeappendjoin)notesfZmdro   linerp   itemsr   r   r   meeting_notes_index   s(   

r   z/meeting_notes/<note_id>c                 C   sH   t |  }|d }| sdS | }td| d|ddddS )	z8Render a meeting note as HTML with collapsible sections.rf   z	Not foundr&   {{NOTE_ID}}{{MD_CONTENT}}`\`${\${)rs   r   r8   MEETING_NOTE_HTMLrw   )note_idZnote_dirmd_file
md_contentr   r   r   meeting_notes_view   s   $r   z./meeting_notes/<note_id>/media/<path:filename>c                 C   s   t t|  d |S )Nmedia)r   rs   )r   r*   r   r   r   meeting_notes_media      r   aP  <!DOCTYPE html>
<html><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meeting Notes</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
       max-width: 860px; margin: 0 auto; padding: 1.5rem 1rem; background: #fff; color: #1f2328; line-height: 1.65; }
nav { margin-bottom: 1.5rem; font-size: 0.85rem; }
nav a { color: #0969da; text-decoration: none; }
nav a:hover { text-decoration: underline; }

/* Rendered markdown */
.note h1 { font-size: 1.7rem; margin: 0.5rem 0 1rem; color: #0d1117; border-bottom: 1px solid #d8dee4; padding-bottom: 0.5rem; }
.note h2 { font-size: 1.25rem; margin: 1.5rem 0 0.5rem; color: #1f2328; cursor: pointer; user-select: none;
            display: flex; align-items: center; gap: 0.5rem; }
.note h2::before { content: "\25BE"; font-size: 0.8rem; color: #656d76; transition: transform 0.15s; display: inline-block; }
.note h2.collapsed::before { transform: rotate(-90deg); }
.note h3 { font-size: 1.05rem; margin: 1rem 0 0.4rem; color: #1f2328; }
.note p { margin: 0.4rem 0; }
.note ul, .note ol { margin: 0.4rem 0 0.4rem 1.5rem; }
.note li { margin: 0.25rem 0; }
.note li::marker { color: #656d76; }
.note strong { color: #0d1117; }
.note code { background: #f6f8fa; padding: 0.15em 0.35em; border-radius: 4px; font-size: 0.88em; color: #cf222e; }
.note pre { background: #f6f8fa; padding: 1rem; border-radius: 8px; overflow-x: auto; margin: 0.8rem 0; border: 1px solid #d8dee4; }
.note pre code { background: none; color: #1f2328; padding: 0; }
.note blockquote { border-left: 3px solid #0969da; padding: 0.3rem 0 0.3rem 1rem; margin: 0.8rem 0; color: #656d76; }
.note img { max-width: 100%; border-radius: 8px; margin: 0.8rem 0; border: 1px solid #d8dee4; }
.note table { border-collapse: collapse; margin: 0.8rem 0; font-size: 0.9rem; width: 100%; }
.note th, .note td { border: 1px solid #d8dee4; padding: 0.5rem 0.75rem; text-align: left; }
.note th { background: #f6f8fa; font-weight: 600; }
.note tr:hover td { background: #f6f8fa; }
.note hr { border: none; border-top: 1px solid #d8dee4; margin: 1.5rem 0; }

/* Collapsible sections */
.section-content { overflow: hidden; transition: max-height 0.25s ease; }
.section-content.collapsed { max-height: 0 !important; }
</style>
</head><body>
<nav><a href="/meeting_notes/">&larr; All Notes</a> &middot; <a href="/">Dashboard</a></nav>
<div class="note" id="note"></div>
<script>
const md = `{{MD_CONTENT}}`;

// Render markdown
document.getElementById('note').innerHTML = marked.parse(md);

// Make h2 sections collapsible
document.querySelectorAll('.note h2').forEach(h2 => {
    // Wrap everything between this h2 and the next h2/h1 in a div
    const wrapper = document.createElement('div');
    wrapper.className = 'section-content';
    let sibling = h2.nextElementSibling;
    const toWrap = [];
    while (sibling && !sibling.matches('h1, h2')) {
        toWrap.push(sibling);
        sibling = sibling.nextElementSibling;
    }
    if (toWrap.length === 0) return;
    h2.after(wrapper);
    toWrap.forEach(el => wrapper.appendChild(el));

    // Set initial max-height
    wrapper.style.maxHeight = wrapper.scrollHeight + 'px';

    h2.addEventListener('click', () => {
        h2.classList.toggle('collapsed');
        wrapper.classList.toggle('collapsed');
        if (!wrapper.classList.contains('collapsed')) {
            wrapper.style.maxHeight = wrapper.scrollHeight + 'px';
        }
    });
});
</script>
</body></html>z/data/cameron/scratch_filesz/uploadc                   C      dS )NuF  <!DOCTYPE html><html><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Upload</title>
<style>
body { font-family: -apple-system, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; background: #fff; color: #1f2328; }
a { color: #0969da; text-decoration: none; } a:hover { text-decoration: underline; }
nav { margin-bottom: 1.5rem; font-size: 0.85rem; }
h1 { font-size: 1.4rem; margin-bottom: 1rem; }
.drop-zone {
    border: 2px dashed #d8dee4; border-radius: 12px; padding: 3rem 1rem; text-align: center;
    color: #656d76; font-size: 0.95rem; cursor: pointer; transition: all 0.15s; margin-bottom: 1rem;
}
.drop-zone.dragover { border-color: #0969da; background: #f0f7ff; color: #0969da; }
.drop-zone.uploading { border-color: #9a6700; color: #9a6700; }
.drop-zone.done { border-color: #1a7f37; color: #1a7f37; }
input[type=text] {
    width: 100%; padding: 0.6rem 0.8rem; border: 1px solid #d8dee4; border-radius: 8px;
    font-size: 0.9rem; margin-bottom: 0.75rem; outline: none; box-sizing: border-box;
}
input[type=text]:focus { border-color: #0969da; }
input[type=file] { display: none; }
.preview { margin: 1rem 0; text-align: center; }
.preview img, .preview video { max-width: 100%; max-height: 300px; border-radius: 8px; border: 1px solid #d8dee4; }
.file-name { font-size: 0.8rem; color: #656d76; margin-top: 0.3rem; }
button {
    background: #0969da; color: #fff; border: none; border-radius: 8px; padding: 0.6rem 1.5rem;
    font-size: 0.9rem; cursor: pointer; width: 100%;
}
button:hover { background: #0550ae; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.result { margin-top: 1rem; padding: 0.8rem; background: #dafbe1; border-radius: 8px; font-size: 0.85rem; color: #1a7f37; }
.result a { color: #1a7f37; font-weight: 500; }
</style></head><body>
<nav><a href="/">Dashboard</a></nav>
<h1>Upload File</h1>
<input type="text" id="fileName" placeholder="File name (optional — uses original name if empty)">
<div class="drop-zone" id="dropZone" onclick="document.getElementById('fileInput').click()">
    Drag & drop a file here, or click to select
</div>
<input type="file" id="fileInput" accept="image/*,video/*,*/*">
<div class="preview" id="preview"></div>
<button id="uploadBtn" onclick="doUpload()" disabled>Upload</button>
<div id="result"></div>
<script>
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const preview = document.getElementById('preview');
const uploadBtn = document.getElementById('uploadBtn');
let selectedFile = null;

dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', e => {
    e.preventDefault(); dropZone.classList.remove('dragover');
    if (e.dataTransfer.files.length) selectFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', () => { if (fileInput.files.length) selectFile(fileInput.files[0]); });

function selectFile(file) {
    selectedFile = file;
    dropZone.textContent = file.name + ' (' + (file.size / 1024).toFixed(0) + ' KB)';
    uploadBtn.disabled = false;

    // Preview
    preview.innerHTML = '';
    if (file.type.startsWith('image/')) {
        const img = document.createElement('img');
        img.src = URL.createObjectURL(file);
        preview.appendChild(img);
    } else if (file.type.startsWith('video/')) {
        const vid = document.createElement('video');
        vid.src = URL.createObjectURL(file);
        vid.controls = true;
        vid.style.maxWidth = '100%';
        preview.appendChild(vid);
    }

    // Pre-fill name if empty
    const nameInput = document.getElementById('fileName');
    if (!nameInput.value) nameInput.value = file.name;
}

async function doUpload() {
    if (!selectedFile) return;
    const name = document.getElementById('fileName').value.trim() || selectedFile.name;
    uploadBtn.disabled = true;
    dropZone.className = 'drop-zone uploading';
    dropZone.textContent = 'Uploading...';

    const form = new FormData();
    form.append('file', selectedFile);
    form.append('name', name);

    try {
        const res = await fetch('/upload', { method: 'POST', body: form });
        const data = await res.json();
        if (data.error) throw new Error(data.error);
        dropZone.className = 'drop-zone done';
        dropZone.textContent = 'Uploaded!';
        document.getElementById('result').innerHTML =
            '<div class="result">Saved to: <code>' + data.path + '</code><br>' +
            '<a href="/browse/' + data.browse_path + '">View in file browser</a></div>';
    } catch(e) {
        dropZone.className = 'drop-zone';
        dropZone.textContent = 'Upload failed: ' + e.message;
        uploadBtn.disabled = false;
    }
}
</script>
</body></html>r   r   r   r   r   upload_pageG     r   c                  C   s   dt jvrtddidfS t jd } t jdd p| j}dd l}|dd	|}t	| }| 
t| t|d
d}tt||dS )NfilerD   zNo file providedrE   rv   r   r   z
[^\w\-\. ]ri   z/data/cameron/)pathZbrowse_path)r   filesr   Zformr0   rz   r*   resub
UPLOAD_DIRsaverT   rw   )r   rv   r   Z	safe_namedestrelr   r   r   upload_file  s   

r   z/data_viewerz/data_viewer/z/data_viewer/<path:filename>c                 C      t d }| rt|| S dS )Ndata_viewer)zData viewer not built yet.r&   r   rO   r   r*   Z
viewer_dirr   r   r   r        
r   r   z(\d+)c                 C   s   dd t | D S )Nc                 S   s$   g | ]}|  rt|n| qS r   )isdigitintlower)rn   tr   r   r   
<listcomp>  s   $ z _natural_key.<locals>.<listcomp>)_NATURAL_RErx   sr   r   r   _natural_key  s   r   z/api/data_viewer/datasetsc                  C   s:   t d d } |  stdg iS dd l}t||  S )Nr   zdatasets.jsonZdatasetsr   )r   r   r   rL   rP   r8   )Zcfg_jsonr   r   r   data_viewer_datasets  s
   r   z/api/data_viewer/annotationsc               
      s   ddl } tjddd}t|}|du s| s"tddidfS |d	 }| rRz
t| 	|
 W S  tyQ } ztdd
| idfW  Y d}~S d}~ww h d z fdd| D }W n tyn   g }Y nw td|t|dg dS )zhLoad episodes.json (if present) from a frames directory, else return an empty skeleton with frame count.r   Nr   r   r   rD   path not a directoryrE   episodes.jsonzinvalid episodes.json: rJ   >   .jpg.jpeg.png.webpc                    s&   g | ]}|  r|j  v r|qS r   )is_filesuffixr   rn   pZIMG_EXTSr   r   r     s   & z/data_viewer_get_annotations.<locals>.<listcomp>      )versionsource_pathtotal_framesfpsepisodes)rL   r   r\   r0   lstriprN   rO   r   r   rP   r8   rQ   ru   rS   len)r   r   fullZ	meta_filerZ   Zframesr   r   r   data_viewer_get_annotations  s4   "r   c                  C   s:  ddl } ddlm} tjddpi }|dpdd}t|}|du s(| s0t	d	d
idfS |dg }t
|tsCt	d	didfS t|dpJd}g }t|D ]d\}}	t
|	tsjt	d	d| didf  S zt|	d }
t|	d }W n tttfy   t	d	d| didf Y   S w |
dk s||
k rt	d	d| d|
 d| idf  S |r||krt	d	d| d| d| idf  S |	dpg }t
|tst	d	d| didf  S g }t }t|D ]\}}t
|tst	d	d| d| didf    S zt|d }W n  tttfy/   t	d	d| d| didf Y     S w ||
k s:||krUt	d	d| d| d| d|
 d| 
idf    S t|dpcd | d!| }||v rq| d!| }|| |||t|d"pdd# q|jd$d% d& |t|	dpd'| |
|t|	d"pdt|	d(pd|d) qRt|jjd*d+}d,||t|d-pd.||d/}|d0 }|d1t   }z|| j|d2d3 || W n t y } zt	d	d4| id5fW  Y d}~S d}~ww t	d6t|t!||d7S )8z[Persist episodes.json into the frames directory. Body: {path, total_frames, fps, episodes}.r   N)timezoneT)rA   r   r   r   rD   r   rE   r   zepisodes must be a listr   zepisode z must be an objectstartendz missing/invalid start/endz invalid range rg   z end z >= total_frames 	keyframesz keyframes must be a listz
 keyframe framez missing/invalid framez frame z outside ep range idZkf_ri   label)r   r   r   c                 S   s   | d S )Nr   r   )kr   r   r   <lambda>7  s    z-data_viewer_put_annotations.<locals>.<lambda>keyZep_r   )r   r   r   r   r   r   Zseconds)Ztimespecr   r   r   )r   r   r   r   r   
updated_atr   z.episodes.json.tmp.rH   )rI   zwrite failed: rJ   r=   )statusr   countr   )"rL   r   r   r   rM   r0   r   rN   rO   r   
isinstancelistr   	enumeratedictKeyError	TypeError
ValueErrorsetrT   addr   sortnowZutcZ	isoformatfloatosgetpidr>   rR   rw   rS   r   )r   Z_tzdatar   r   Zepsr   Z
normalizediepr   r   Zkfs_inZkfs_outZseen_idsjZkfr   Zkf_idr   Zpayloadr   tmprZ   r   r   r   data_viewer_put_annotations  s   

 &&
&*6 



		"r   z/api/data_viewer/listc               	   C   s  t jddd} tt jdd}tt jdd}t jdd}| r)t| nt}|d	u r7td
didfS | r?|	 sJtdt
|ddfS g g }}zD| D ]=}|j}|dr_qTz)|	 rq|dkrp||dd n|dkr|j }	||d|	| jd W qT ty   Y qTw W n ty   td
didf Y S w |jdd d |jdd d t|}
t|}|| }||||  }t| |
|||t||dS )zDList directory contents under BROWSE_ROOT as JSON with natural sort.r   r   r   limitZ2000offset0onlyNrD   Z	forbiddenrF   znot a directory)rD   r   r&   .r   dir)rv   typedirsr   )rv   r   extsizezpermission deniedc                 S      t | d S Nrv   r   rZ   r   r   r   r   |      z"data_viewer_list.<locals>.<lambda>r   c                 S   r   r   r   r   r   r   r   r   }  r   )r   
total_dirstotal_filesr   r   Zreturnedentries)r   r\   r0   r   r   rN   BROWSE_ROOTr   r   rO   rT   ru   rv   ry   r   r   r   r|   st_sizerS   PermissionErrorr   r   )r   r   r   r   r   r   r   itemrv   r   r   r   ZcombinedZpagedr   r   r   data_viewer_listV  sf   


r   z/runsz/runs/z/runs/<path:filename>c                 C   r   )Nruns)zRuns viewer not built yet.r&   r   r   r   r   r   wandb_viewer  r   r  z/glassesz	/glasses/z/glasses/<path:filename>c                 C   r   )Nglasses_app)zGlasses app not built yet.r&   r   )r*   Zapp_dirr   r   r   r    s   
r  z/api/runs/listc                   C   s   t t S N)r   _wandb_runsZ	list_runsr   r   r   r   api_runs_list  s   r  z/api/runs/<run_id>c                 C   s*   t | }|d u rtddidfS t|S )NrD   zrun not foundr&   )r  Z
run_detailr   )run_idZdetailr   r   r   api_runs_detail  s   
r  z(/api/runs/<run_id>/media/<path:filename>c                    s`   t | }|d u rtd |  dd t  D }t fdd|D s)td t|d |S )Nr&   c                 S   s   g | ]}t | qS r   )r   resolvern   rr   r   r   r     s    z"api_runs_media.<locals>.<listcomp>c                 3   s&    | ]}t  t |d  V  qdS )r   N)rT   ry   r
  resolvedr   r   rq        $ z!api_runs_media.<locals>.<genexpr>rF   r   )r  Zfind_runr   r	  Z	get_rootsanyr   )r  r*   Zrun_dirrootsr   r  r   api_runs_media  s   
r  z/figmaz/figma/z/figma/<path:filename>c                 C   sR   t d}| s
dS | rt|| S t| }ddd |D }d|p%d dS )	Nz#/data/cameron/para/paper/figs/figmaz<h1>No Figma exports yet</h1>r   c                 s   s>    | ]}|  rd |j d|j d| jd  dV  qdS )zN<li style="padding:0.5rem 0;border-bottom:1px solid #d8dee4;"><a href="/figma/z" style="color:#0969da;">z4</a> <span style="color:#656d76;font-size:0.8rem;">(   zKB)</span></li>N)r   rv   r|   r   rn   r   r   r   r   rq     s    
z figma_exports.<locals>.<genexpr>a6  <!DOCTYPE html><html><head><meta charset="UTF-8"><title>Figma Exports</title>
<style>body{font-family:-apple-system,sans-serif;max-width:800px;margin:2rem auto;padding:0 1rem;}
a{color:#0969da;}</style></head>
<body><h1>Figma Exports</h1><p><a href="/">Dashboard</a></p>
<ul style="list-style:none;padding:0;">zI<li>No exports yet. Set up figma_config.yaml and run figma_export.py</li>z</ul></body></html>)r   rO   r   rt   ru   r   )r*   Z	figma_dirr   r   r   r   r   figma_exports  s   

r  z/paperc                   C   r   )Na!  <!DOCTYPE html>
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PARA Paper</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #f6f8fa; font-family: -apple-system, sans-serif; height: 100vh; display: flex; flex-direction: column; }
.bar { padding: 0.5rem 1rem; background: #fff; border-bottom: 1px solid #d8dee4; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
.bar h1 { font-size: 0.9rem; color: #0d1117; }
.bar .links { display: flex; gap: 1rem; font-size: 0.8rem; }
.bar a { color: #0969da; text-decoration: none; }
.status { font-size: 0.75rem; padding: 0.2rem 0.6rem; border-radius: 4px; }
.status.ok { background: #dafbe1; color: #1a7f37; }
.status.err { background: #ffebe9; color: #cf222e; }
.status.loading { background: #fff8c5; color: #9a6700; }
.err-detail { padding: 0.5rem 1rem; background: #ffebe9; border-bottom: 1px solid #cf222e; font-family: monospace; font-size: 0.75rem; color: #cf222e; max-height: 150px; overflow-y: auto; white-space: pre-wrap; display: none; }
iframe { flex: 1; border: none; width: 100%; }
</style></head><body>
<div class="bar">
    <h1>PARA Paper</h1>
    <span id="status" class="status loading">checking...</span>
    <div class="links"><a href="/">Dashboard</a><a href="/paper/main.pdf" target="_blank">PDF</a><a href="/agents">Agents</a></div>
</div>
<div id="errDetail" class="err-detail"></div>
<iframe id="pdf" src="/paper/main.pdf"></iframe>
<script>
let lastCompileTimestamp = '';
async function checkStatus() {
    try {
        const res = await fetch('/paper/compile_status.json?' + Date.now());
        const data = await res.json();
        const el = document.getElementById('status');
        const err = document.getElementById('errDetail');
        if (data.ok) {
            el.className = 'status ok';
            el.textContent = data.message + ' (' + data.timestamp.split(' ')[1] + ')';
            err.style.display = 'none';
            // Only reload PDF when timestamp changes (new compile)
            if (data.message === 'Compiled successfully' && data.timestamp !== lastCompileTimestamp) {
                lastCompileTimestamp = data.timestamp;
                document.getElementById('pdf').src = '/paper/main.pdf?' + Date.now();
            }
        } else {
            el.className = 'status err';
            el.textContent = 'Compile error';
            err.style.display = 'block';
            err.textContent = data.message;
        }
    } catch(e) {}
}
// Set initial timestamp so first load doesn't trigger a reload
fetch('/paper/compile_status.json?' + Date.now()).then(r => r.json()).then(d => { lastCompileTimestamp = d.timestamp; }).catch(() => {});
checkStatus();
setInterval(checkStatus, 5000);
</script>
</body></html>r   r   r   r   r   paper_viewer  r   r  z/paper/<path:filename>c                 C   r^   )Nz/data/cameron/para/paperr_   r`   )r*   Z	paper_dirr   r   r   
paper_file  r!   r  z/old_para_websitec                   C   s   t dS )N/old_para_website/)r   r   r   r   r   old_para_website_redirect$  s   r  r  z!/old_para_website/<path:filename>index_goal.htmlc                 C   r^   )Nz#/data/cameron/para/old_para_websiter_   r`   ra   r   r   r   old_para_website(  rc   r  z/para_websitez/para_website/<path:filename>c                 C   r   )NZproject_site)zProject website not built yet.r&   r   ra   r   r   r   para_website1  rc   r  z/para_website_v1z /para_website_v1/<path:filename>c                 C   r   )NZproject_site_v1)zArchived v1 site not found.r&   r   ra   r   r   r   para_website_v1:  rc   r  z/chatc                   C      t S r  )MOBILE_CHATTER_HTMLr   r   r   r   	chat_pageC  r   r  z/api/mobile_chatter/iframe_urlc                  C   s    t d} |  r|   S dS )Nz8/data/cameron/para/.agents/mobile_chatter/iframe_url.txtr   )r   r   r8   rz   )Zurl_filer   r   r   mobile_chatter_iframe_urlH  s   r   z	/api/chatc               
   C   s   ddl m }  t }|dd}|dtt }|s$tddidfS |d	d
}z| |||d}t||dW S  tyU } ztdt|idfW  Y d }~S d }~ww )Nr   )chatmessager   
session_idrD   zNo message providedrE   modelzclaude-sonnet-4-20250514)r$  )r   r#  rJ   )	r!  r   rM   r0   rT   uuidZuuid4r   rQ   )Zdo_chatr   Zuser_msgr#  r$  Zresponse_textrZ   r   r   r   api_chatP  s    r&  c                  C   s    t jdpt jd} | tkS )z*Check password from header or query param.zX-Agents-Passwordpw)r   r   r0   r\   AGENTS_PASSWORD)r'  r   r   r   _check_agents_authg  s   r)  z/agentsz/agents/<agent_name>c                 C   r  r  )AGENTS_HTML)
agent_namer   r   r   agents_pagem  s   r,  z/api/agents/listc            	      C   s   t  stddidfS ddl} tjd }| stg S t|}| |}W d   n1 s0w   Y  g }|di 	 D ]$\}}tj| d }| rT|
  nd	}|||d
d|d q?t|S )z.List all agents with their tmux window status.rD   unauthorizedr.   r   Nzconfig.yamlagentsz	status.mdunknowndescriptionr   )rv   r0  r   )r)  r   yamlr   parentr   openZ	safe_loadr0   r   r8   rz   r   )	r1  Zconfig_pathr   Zconfigr.  rv   infoZstatus_filer   r   r   r   api_agents_lists  s   

r5  z/api/agents/<agent_name>/panec              	   C   s   t  stddidfS tjjddtd}t| }|s%td|  dd	d
S tjddd|ddd| gddd}ddl	}|
dd	|j}|
dd	|}td|iS )z&Capture tmux pane output for an agent.rD   r-  r.   linesr6   r   Window '' not foundr   )rD   outputtmuxzcapture-pane-tz-pz-Srg   Tcapture_outputtextr   Nz\x1b\[[0-9;]*[a-zA-Z]z\x1b\].*?\x07r:  )r)  r   r   r\   r0   r   _find_tmux_window
subprocessrunr   r   stdout)r+  r6  targetresultr   Zcleanr   r   r   api_agent_pane  s   rF  z"/api/agents/<agent_name>/interruptc                 C   s   t  stddidfS t| }|stdd|  didfS tddd	|d
g ddl}|d tddd	|dg tddiS )z)Send Escape key to interrupt Claude Code.rD   r-  r.   r8  r9  r&   r;  	send-keysr<  ZEscaper   N333333?r   r   Zinterrupted)r)  r   r@  rA  rB  timesleep)r+  rD  _tr   r   r   api_agent_interrupt  s   rL  z/api/agents/<agent_name>/sendc                 C   s   t  stddidfS t }|dd}|stddidfS t| }|s1tdd|  d	id
fS tddd||dg ddl}|	d tddd|dg tddiS )z(Send keystrokes to an agent's tmux pane.rD   r-  r.   r?  r   No text providedrE   r8  r9  r&   r;  rG  r<  Enterr   Nr   r   sent)
r)  r   r   rM   r0   r@  rA  rB  rI  rJ  )r+  r   r?  rD  Z_timer   r   r   api_agent_send  s   rP  c                  C   s0   dd l } d}|| jvr| jd| dd l}|S )Nr   z)/data/cameron/agents_stuff/agents/glasses)sysr   insertspotify_lib)_sysglrS  r   r   r   _spotify_lib  s   
rV  c                  C   sD   zddl } ddl}| dt|  W dS  ty!   Y dS w )zRSignal the renderer to refresh now-playing immediately (glasses-initiated change).r   Nz/tmp/glasses_spotify_force)pathlibrI  r   r>   rT   rQ   rW  rK  r   r   r   _sp_force_refresh  s   rY  z/api/glasses/spotify/playpausec                  C   2   t  stddidfS t  } t  td| iS NrD   r-  r.   code)r)  r   rV  Ztoggle_playbackrY  r\  r   r   r   api_glasses_spotify_playpause  
   
r^  z/api/glasses/spotify/nextc                  C   rZ  r[  )r)  r   rV  Z
next_trackrY  r]  r   r   r   api_glasses_spotify_next  r_  r`  c              
   C   s   dd l }z6| dkr|||ddW S | dkr'|||ddW S | dkr8|||ddW S W dS  tyV } zd	t|d d
  W  Y d }~S d }~ww )Nr   search_spotifyqueryr   artist_albumsartistalbum_tracksalbum_idztool error: x   zunknown tool)rL   rR   Zsearch_tracksr0   rc  re  rQ   rT   )rv   inplibr   rZ   r   r   r   _sp_find_tool  s    rj  z/api/glasses/spotify/findc               
      s&  t  stddidfS t pi dd } | s!tddidfS t  dd	l}dd	l}d
dddddiidgddddddddiidgddddddddiidgddg}d}d| dg}z}dd	l	}|
 }tdD ]n}|jjdd|||d}	|	jdkr|d |	jd |d fd!d"|	jD d qmdd#d$ |	jD }
|d%|
|j}|r||dni }|d&rtd'|d& |d(d|ddd)  W S td*d+i  W S W n/ ty } z" | }|rtd*d'i|ntd+t|d	d, d-W  Y d	}~S d	}~ww td*d+iS ).u   Pick the track to play for a spoken request, grounded in Spotify's LIVE
    catalog via tools (so 'newest album' is current, not guessed). Returns the
    candidate WITHOUT playing it — the glasses confirm first.rD   r-  r.   r?  r   rM  rE   r   Nra  zCSearch Spotify for tracks. Returns up to 5 {name,artist,album,uri}.objectrb  r   string)r   Z
propertiesrequired)rv   r0  Zinput_schemarc  u   List an artist's albums/singles NEWEST-FIRST with release_date. Use this for 'newest/latest album' requests — it reflects Spotify's live catalog.rd  re  zGList an album's tracks {name,uri} given an album id from artist_albums.rf  u  Pick the single Spotify track to play for the spoken request. ALWAYS ground your answer in the tool results (Spotify's live catalog) — never rely on your own memory of what albums/songs exist or are newest. For 'newest/latest album by X' call artist_albums (newest-first) and use the top album, then album_tracks. For a named song use search_spotify. When you've decided, reply with ONLY JSON: {"uri":"spotify:track:..","name":"..","artist":".."}  — or {"found":false} if nothing fits.user)Zrolecontent   zclaude-sonnet-4-6i   )r$  Z
max_tokenssystemtoolsmessagestool_useZ	assistantc                    s0   g | ]}|j d krd|jt|j|j dqS )rt  Ztool_result)r   Ztool_use_idro  )r   r   rj  rv   inputrn   bri  r   r   r     s    z,api_glasses_spotify_find.<locals>.<listcomp>c                 s   s     | ]}|j d kr|jV  qdS )r?  N)r   r?  rv  r   r   r   rq     s    z+api_glasses_spotify_find.<locals>.<genexpr>z\{.*\}uriTrv   )foundry  rv   rd  rz  Frg  )rz  rD   )r)  r   r   rM   r0   rz   rV  rL   r   	anthropicZ	Anthropicrangers  ZcreateZstop_reasonr   ro  r   searchSrP   grouprQ   rT   )r?  r   _rerr  rq  rs  r{  clientri   r  ZtxtmdrZ   r   r   rx  r   api_glasses_spotify_find  sZ   	

,
:r  z/api/glasses/spotify/play_uric                  C   s`   t  stddidfS t pi dd } | s!tddidfS t | }t  td|iS )	NrD   r-  r.   ry  r   zNo uri providedrE   r\  )	r)  r   r   rM   r0   rz   rV  Zplay_urirY  )ry  r\  r   r   r   api_glasses_spotify_play_uri!  s   r  zA/browse/para/.agents/reports/glasses_app/feed/module_spotify.jsonc                  C   sJ   zddl } ddl}| dt|  W n	 ty   Y nw tddS )zThe client fetching this feed == it's on the Spotify tab. Touch a marker
    so the renderer only polls Spotify while it's actually being viewed (avoids
    24/7 polling -> rate-limit bans). Then serve the renderer-written file.r   Nz/tmp/glasses_spotify_viewedz3/data/cameron/para/.agents/reports/glasses_app/feedzmodule_spotify.json)rW  rI  r   r>   rT   rQ   r   rX  r   r   r   glasses_spotify_feed-  s   
r  z5/data/cameron/agents_stuff/agents/glasses/chat_log.mdZvoice_interfacez/api/glasses/chat/sendc                  C   s.  t  stddidfS t pi dd } | s!tddidfS ttd}|d	|  d
  W d   n1 s:w   Y  t	t
}|skttd}|dt
 d W d   n1 s^w   Y  tddidfS |  dt d}tddd||dg ddl}|d tddd|dg tddiS )zGlasses Chat -> the persistent voice_interface agent. It observes/commands
    other agents per its own ROLE, then appends its reply to the chat log, which
    the glasses poll (so the reply shows up whenever it's done).rD   r-  r.   r?  r   rM  rE   az> you: rj   Nzvoice: (agent 'z' window not found)
zvoice agent not foundr&   za  [glasses chat from Cameron -- when done, append ONE line 'voice: <concise 1-3 line answer>' to zU; observe agents by reading their panes, only message one if Cameron explicitly asks]r;  rG  r<  rN  r   r   r   Z
dispatched)r)  r   r   rM   r0   rz   r3  GLASSES_CHAT_LOGwriter@  VOICE_AGENTrA  rB  rI  rJ  )r?  r   rD  wrappedrK  r   r   r   api_glasses_chat_send@  s*   r  c                 C   s^   t jg dddd}|j dD ]}|dd}t|dkr,|d | kr,|d   S qd	S )
z Find tmux window target by name.)r;  zlist-windowsz-az-Fz.#{session_name}:#{window_index} #{window_name}Tr=  rj   rh   r   rH   r   N)rA  rB  rC  rz   rx   r   )rv   rE  r   partsr   r   r   r@  [  s   r@  u[E  <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>PARA Agents</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Inter:wght@400;500;600&display=swap');

* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
    --bg: #ffffff; --bg-card: #f6f8fa; --bg-input: #f0f2f5;
    --border: #d8dee4; --border-focus: #0969da;
    --text: #1f2328; --text-bright: #0d1117; --text-dim: #656d76;
    --accent: #0969da; --accent-hover: #0550ae;
    --green: #1a7f37; --yellow: #9a6700; --red: #cf222e;
    --radius: 10px;
}

body {
    font-family: 'Inter', -apple-system, sans-serif;
    background: var(--bg); color: var(--text);
    height: 100vh; height: 100dvh;
    display: flex; flex-direction: column;
    overflow: hidden;
    -webkit-font-smoothing: antialiased;
}

/* ── Header ── */
.header {
    padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border);
    display: flex; align-items: center; justify-content: space-between;
    background: var(--bg-card); flex-shrink: 0;
}
.header h1 {
    font-size: 0.85rem; color: var(--text-bright); font-weight: 600;
    letter-spacing: 0.04em;
}
.header .links { display: flex; gap: 0.75rem; font-size: 0.75rem; }
.header a { color: var(--text-dim); text-decoration: none; font-weight: 500; }
.header a:hover { color: var(--accent); }

/* ── Agent Tabs ── */
.agent-tabs {
    display: flex; gap: 0.25rem; padding: 0.4rem 0.5rem;
    overflow-x: auto; flex-shrink: 0;
    background: var(--bg-card); border-bottom: 1px solid var(--border);
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
}
.agent-tabs::-webkit-scrollbar { display: none; }
.agent-tab {
    padding: 0.35rem 0.7rem; cursor: pointer; color: var(--text-dim);
    font-size: 0.72rem; font-weight: 500; font-family: 'Inter', sans-serif;
    white-space: nowrap; border-radius: 6px; border: 1px solid transparent;
    background: transparent; transition: all 0.15s;
    display: flex; align-items: center; gap: 0.35rem;
}
.agent-tab:hover { background: var(--bg-input); color: var(--text); }
.agent-tab.active {
    background: var(--accent); color: #fff; border-color: var(--accent);
}
.agent-tab .status-dot {
    width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0;
}
.status-dot.idle { background: var(--text-dim); }
.status-dot.working { background: var(--yellow); }
.status-dot.done { background: var(--green); }
.status-dot.blocked { background: var(--red); }
.status-dot.unknown { background: var(--text-dim); }
.status-dot.task_pending { background: var(--yellow); }

/* ── Terminal ── */
.terminal-wrap { flex: 1; overflow: hidden; position: relative; }
.terminal {
    height: 100%; overflow-y: auto; padding: 0.6rem 0.75rem;
    background: #0d1117;
    font-family: 'JetBrains Mono', 'SF Mono', monospace;
    font-size: 0.7rem; line-height: 1.5;
    white-space: pre-wrap; word-wrap: break-word;
    color: #c9d1d9;
    -webkit-overflow-scrolling: touch;
}
.terminal .prompt { color: var(--accent); font-weight: 500; }
.terminal .tool { color: var(--text-dim); }
.refresh-indicator {
    position: absolute; top: 0.4rem; right: 0.6rem;
    width: 6px; height: 6px; border-radius: 50%; background: var(--green);
    opacity: 0.5; animation: pulse 3s infinite;
}
@keyframes pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 0.15; } }

/* ── Input Area ── */
.input-area {
    flex-shrink: 0; border-top: 1px solid var(--border);
    background: var(--bg-card); padding: 0.5rem 0.6rem;
    display: flex; flex-direction: column; gap: 0.4rem;
}
.input-area textarea {
    width: 100%; background: var(--bg-input); border: 1px solid var(--border);
    border-radius: var(--radius); color: var(--text-bright);
    padding: 0.55rem 0.75rem; font-size: 0.85rem;
    font-family: 'Inter', sans-serif; resize: none; outline: none;
    min-height: 42px; max-height: 120px; line-height: 1.4;
    transition: border-color 0.15s;
}
.input-area textarea:focus { border-color: var(--border-focus); }
.input-area textarea::placeholder { color: var(--text-dim); }
.btn-row {
    display: flex; gap: 0.35rem; justify-content: flex-end;
}
.btn-row button {
    font-family: 'Inter', sans-serif;
    font-size: 0.75rem; font-weight: 500;
    padding: 0.4rem 0.7rem; border-radius: 7px; cursor: pointer;
    border: 1px solid var(--border); background: var(--bg-input);
    color: var(--text); transition: all 0.15s;
    touch-action: manipulation;
}
.btn-row button:active { transform: scale(0.96); }
.btn-row button:hover { background: var(--border); }
.btn-row .send-btn {
    background: var(--accent); border-color: var(--accent); color: #fff;
    padding: 0.4rem 1rem;
}
.btn-row .send-btn:hover { background: var(--accent-hover); }
.btn-row .mic-active {
    background: var(--red) !important; border-color: var(--red) !important;
    color: #fff !important; animation: mic-pulse 1s infinite;
}
@keyframes mic-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }

/* ── Mobile tweaks ── */
@media (max-width: 640px) {
    .header h1 { font-size: 0.8rem; }
    .header .links { gap: 0.5rem; font-size: 0.7rem; }
    .agent-tab { font-size: 0.68rem; padding: 0.3rem 0.55rem; }
    .terminal { font-size: 0.65rem; padding: 0.5rem; }
    .input-area textarea { font-size: 0.85rem; }
    .btn-row button { font-size: 0.72rem; padding: 0.35rem 0.6rem; }
}
</style>
</head>
<body>

<div class="header">
    <h1>AGENTS</h1>
    <div class="links">
        <a href="/">Dashboard</a>
        <a href="/chat">Chat</a>
        <a href="/browse/">Files</a>
    </div>
</div>

<div class="agent-tabs" id="agentTabs"></div>

<div class="terminal-wrap">
    <div class="refresh-indicator"></div>
    <div class="terminal" id="terminal"></div>
</div>

<div class="input-area">
    <textarea id="msgInput" placeholder="Message agent..." rows="1"
        onkeydown="if(event.key==='Enter' && !event.shiftKey){event.preventDefault();sendToAgent();}"
        oninput="autoResize(this)"></textarea>
    <div class="btn-row">
        <button onclick="interruptAgent()" style="color:#cf222e;">Esc</button>
        <button onclick="refreshPane()">Refresh</button>
        <button id="micBtn" onclick="toggleMic()">Mic</button>
        <button id="ttsBtn" onclick="readLatestResponse()">Read</button>
        <button class="send-btn" onclick="sendToAgent()">Send</button>
    </div>
</div>
<script>
let currentAgent = null;
let agents = [];
let refreshInterval = null;
let autoScroll = true;
let agentsPassword = null;

// Prompt for password on load
function checkPassword() {
    agentsPassword = prompt('Enter password to access agents:');
    if (!agentsPassword) {
        document.body.innerHTML = '<div style="text-align:center;padding:4rem;color:#8b949e;">Access denied.</div>';
        return false;
    }
    return true;
}
if (!checkPassword()) throw new Error('no auth');

const terminal = document.getElementById('terminal');
const tabsEl = document.getElementById('agentTabs');
const inputEl = document.getElementById('msgInput');

function autoResize(el) {
    el.style.height = 'auto';
    el.style.height = Math.min(el.scrollHeight, 120) + 'px';
}

// Track scroll position to auto-scroll only if at bottom
terminal.addEventListener('scroll', () => {
    autoScroll = terminal.scrollTop + terminal.clientHeight >= terminal.scrollHeight - 30;
});

async function loadAgents() {
    const res = await fetch('/api/agents/list', {headers:{'X-Agents-Password':agentsPassword}});
    if (res.status === 401) {
        agentsPassword = null;
        document.body.innerHTML = '<div style="text-align:center;padding:4rem;color:#f85149;">Wrong password. <a href="/agents" style="color:#58a6ff;">Try again</a></div>';
        return;
    }
    agents = await res.json();
    renderTabs();
    // Select agent from URL or first
    const path = window.location.pathname.split('/agents/')[1];
    const target = path ? decodeURIComponent(path) : (agents[0] && agents[0].name);
    if (target) selectAgent(target);
}

function renderTabs() {
    tabsEl.innerHTML = agents.map(a => {
        const active = a.name === currentAgent ? 'active' : '';
        return `<button class="agent-tab ${active}" onclick="selectAgent('${a.name}')">
            ${a.name}<span class="status-dot ${a.status}" title="${a.status}"></span>
        </button>`;
    }).join('');
}

function selectAgent(name) {
    currentAgent = name;
    renderTabs();
    history.replaceState(null, '', '/agents/' + name);
    inputEl.placeholder = 'Send message to ' + name + '...';
    refreshPane();
    if (refreshInterval) clearInterval(refreshInterval);
    refreshInterval = setInterval(refreshPane, 3000);
}

async function refreshPane() {
    if (!currentAgent) return;
    // Don't replace innerHTML while the user is selecting text in the pane —
    // wholesale DOM replacement destroys the selection anchor and makes the
    // selection jump across lines.
    const sel = window.getSelection();
    if (sel && sel.toString().length > 0 && terminal.contains(sel.anchorNode)) return;
    const res = await fetch(`/api/agents/${currentAgent}/pane?lines=300`, {headers:{'X-Agents-Password':agentsPassword}});
    const data = await res.json();
    if (data.error) {
        terminal.textContent = data.error;
        return;
    }
    // Highlight prompts and structure
    let html = data.output
        .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
        .replace(/^(❯ .*)$/gm, '<span class="prompt">$1</span>')
        .replace(/^(● .*)$/gm, '<span class="tool">$1</span>');
    terminal.innerHTML = html;
    if (autoScroll) terminal.scrollTop = terminal.scrollHeight;
}

async function interruptAgent() {
    if (!currentAgent || !agentsPassword) return;
    await fetch(`/api/agents/${currentAgent}/interrupt`, {
        method: 'POST', headers: {'X-Agents-Password': agentsPassword},
    });
    setTimeout(refreshPane, 500);
}

async function sendToAgent() {
    let text = inputEl.value.trim();
    if (!text || !currentAgent) return;
    if (isListening || inputEl.dataset.fromMic) {
        text = '(note this text is coming from dictation so interpret likely typos as you best see fit) ' + text;
    }
    inputEl.dataset.fromMic = '';
    inputEl.value = '';
    inputEl.style.height = 'auto';
    stopMic();
    await fetch(`/api/agents/${currentAgent}/send`, {
        method: 'POST',
        headers: {'Content-Type': 'application/json', 'X-Agents-Password': agentsPassword},
        body: JSON.stringify({text: text}),
    });
    // Quick refresh after sending
    setTimeout(refreshPane, 500);
    setTimeout(refreshPane, 2000);
}

// Speech-to-text via Whisper
let mediaRecorder = null;
let audioChunks = [];
let isListening = false;

async function toggleMic() {
    if (isListening) { stopMic(); return; }
    try {
        const stream = await navigator.mediaDevices.getUserMedia({audio: true});
        mediaRecorder = new MediaRecorder(stream, {mimeType: 'audio/webm;codecs=opus'});
        audioChunks = [];
        mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunks.push(e.data); };
        mediaRecorder.onstop = async () => {
            stream.getTracks().forEach(t => t.stop());
            if (audioChunks.length === 0) return;
            const btn = document.getElementById('micBtn');
            btn.textContent = '...';
            const blob = new Blob(audioChunks, {type: 'audio/webm'});
            const form = new FormData();
            form.append('audio', blob, 'recording.webm');
            try {
                const res = await fetch('/api/transcribe', {method: 'POST', body: form});
                const data = await res.json();
                if (data.text) {
                    inputEl.value = (inputEl.value ? inputEl.value + ' ' : '') + data.text;
                    inputEl.dataset.fromMic = '1';
                    autoResize(inputEl);
                } else if (data.error) {
                    alert('Transcription error: ' + data.error);
                }
            } catch(e) { alert('Transcription failed: ' + e.message); }
            btn.textContent = 'Mic'; btn.classList.remove('mic-active');
        };
        mediaRecorder.start(250);
        isListening = true;
        document.getElementById('micBtn').classList.add('mic-active');
        document.getElementById('micBtn').textContent = 'Stop';
    } catch(e) { alert('Microphone access denied: ' + e.message); }
}

function stopMic() {
    if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop();
    isListening = false;
    // Button update happens in onstop handler
}

// Text-to-speech with pre-generation
let ttsAudio = null;
let ttsPlaying = false;
let ttsCachedBlob = null;
let ttsCachedText = '';
let ttsGenerating = false;
let lastTerminalContent = '';

// Pre-generate TTS when terminal content changes
function checkAndPregenerate() {
    const text = terminal.textContent || '';
    if (text === lastTerminalContent) return;
    lastTerminalContent = text;

    // Extract latest response
    const responseText = extractLatestResponse(text);
    if (!responseText || responseText === ttsCachedText || responseText.length < 20) return;

    // Pre-generate in background
    ttsCachedText = responseText;
    ttsCachedBlob = null;
    ttsGenerating = true;
    const btn = document.getElementById('ttsBtn');
    btn.textContent = 'Read';

    fetch('/api/tts', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({text: responseText}),
    }).then(res => {
        if (!res.ok) throw new Error('pre-gen failed');
        return res.blob();
    }).then(blob => {
        ttsCachedBlob = blob;
        ttsGenerating = false;
        btn.textContent = 'Read \u25B6';
    }).catch(e => {
        ttsGenerating = false;
    });
}

// Run pre-generation check every 5 seconds
setInterval(checkAndPregenerate, 5000);

function extractLatestResponse(text) {
    const lines = text.split('\n');
    let lastResponseStart = -1;
    for (let i = 0; i < lines.length; i++) {
        const line = lines[i].trim();
        if (line.startsWith('\u25cf') && !line.match(/^\u25cf (Bash|Read|Write|Edit|Glob|Grep|Search|Agent)\(/)) {
            lastResponseStart = i;
        }
    }
    if (lastResponseStart === -1) return '';
    let responseLines = [];
    for (let i = lastResponseStart; i < lines.length; i++) {
        const line = lines[i].trim();
        if (i > lastResponseStart && (line.startsWith('\u2771') || line.startsWith('\u2500\u2500'))) break;
        if (line.startsWith('\u25cf Bash(') || line.startsWith('\u25cf Read(') || line.startsWith('\u25cf Write(') ||
            line.startsWith('\u25cf Edit(') || line.startsWith('\u23bf') || line.startsWith('\u273b') ||
            line.startsWith('\u2722') || line.match(/^\s*$/) || line.includes('ctrl+o to expand') ||
            line.includes('auto-compact') || line.includes('bypass permissions')) continue;
        let clean = line.replace(/^\u25cf /, '');
        if (clean) responseLines.push(clean);
    }
    return responseLines.join(' ');
}

function readLatestResponse() {
    const btn = document.getElementById('ttsBtn');

    // Stop if already playing
    if (ttsPlaying) {
        if (ttsAudio) { ttsAudio.pause(); ttsAudio = null; }
        ttsPlaying = false;
        btn.textContent = ttsCachedBlob ? 'Read \u25B6' : 'Read';
        btn.style.background = ''; btn.style.borderColor = ''; btn.style.color = '';
        return;
    }

    // Use pre-cached audio if available
    if (ttsCachedBlob) {
        const url = URL.createObjectURL(ttsCachedBlob);
        ttsAudio = new Audio(url);
        ttsPlaying = true;
        btn.textContent = 'Stop'; btn.style.background = '#cf222e'; btn.style.borderColor = '#cf222e'; btn.style.color = '#fff';
        ttsAudio.onended = () => {
            ttsPlaying = false;
            btn.textContent = 'Read \u25B6'; btn.style.background = ''; btn.style.borderColor = ''; btn.style.color = '';
        };
        ttsAudio.play();
        return;
    }

    // If still generating, wait
    if (ttsGenerating) {
        btn.textContent = '...';
        return;
    }

    // Fallback: generate on demand
    const responseText = extractLatestResponse(terminal.textContent || '');
    if (!responseText.trim()) { alert('No readable response.'); return; }

    btn.textContent = '...'; btn.style.background = '#9a6700'; btn.style.borderColor = '#9a6700'; btn.style.color = '#fff';
    ttsPlaying = true;

    fetch('/api/tts', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({text: responseText}),
    }).then(res => {
        if (!res.ok) throw new Error('TTS failed: ' + res.status);
        return res.blob();
    }).then(blob => {
        ttsCachedBlob = blob;
        ttsCachedText = responseText;
        const url = URL.createObjectURL(blob);
        ttsAudio = new Audio(url);
        btn.textContent = 'Stop'; btn.style.background = '#cf222e'; btn.style.borderColor = '#cf222e'; btn.style.color = '#fff';
        ttsAudio.onended = () => {
            ttsPlaying = false;
            btn.textContent = 'Read \u25B6'; btn.style.background = ''; btn.style.borderColor = ''; btn.style.color = '';
        };
        ttsAudio.play();
    }).catch(e => {
        ttsPlaying = false;
        btn.textContent = 'Read'; btn.style.background = ''; btn.style.borderColor = ''; btn.style.color = '';
        alert('TTS failed: ' + e.message);
    });
}

loadAgents();
</script>
</body>
</html>c                  C   s&   t d u rddlm}  | dddda t S )Nr   WhisperModelZsmallZcpuZint8)ZdeviceZcompute_type)_whisper_modelZfaster_whisperr  r  r   r   r   _get_whisperD  s   r  z/api/transcribec               
   C   s   ddl } dtjvrtddidfS tjd }| jddd	}||j |  zaz=tj	
|jd
k rAtdddW W t|j S t }|j|jdd\}}ddd |D }td|iW W t|j S  ty } ztdt|idfW  Y d}~W t|j S d}~ww t|j w )zTranscribe audio using Whisper.r   NaudiorD   zNo audio filerE   .webmFr   deleter  r   zrecording too short or empty)r?  warningen)languagerh   c                 s   s    | ]}|j  V  qd S r  )r?  rz   )rn   segr   r   r   rq   [  s    z!api_transcribe.<locals>.<genexpr>r?  rJ   )tempfiler   r   r   NamedTemporaryFiler   rv   closer   r   getsizeunlinkr  Z
transcriber   rQ   rT   )r  r  r   r$  segmentsri   r?  rZ   r   r   r   api_transcribeL  s,   

 r  z/api/ttsc               
   C   s  ddl } t }|dd}|stddidfS t|dkr&|dd d	 }z4| jd
dd}|  tj	dddddd|j
g|dddd}|jdkrStd|jidfW S t|j
ddW S  tjyl   tddidf Y S  ty } ztdt|idfW  Y d}~S d}~ww )z.Convert text to speech using Piper neural TTS.r   Nr?  r   rD   rM  rE   i  z... text truncated for speech.z.wavFr  Zpythonz-mZpiperz--modelz0/data/cameron/.piper_voices/en_US-ryan-high.onnxz--output_fileTrg  )ru  r>  r?  timeoutrJ   z	audio/wavmimetypeu   TTS timed out — text too long)r  r   rM   r0   r   r   r  r  rA  rB  rv   
returncodestderrr   ZTimeoutExpiredrQ   rT   )r  r   r?  r   rE  rZ   r   r   r   api_ttsc  s4   
 r  z/api/chat/clearc                  C   s4   ddl m}  t }|dd}| | tddiS )Nr   )clear_conversationr#  r   r   Zcleared)r!  r  r   rM   r0   r   )r  r   r#  r   r   r   api_chat_clear  s
   r  a3  <!DOCTYPE html>
<html><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>PARA Mobile</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', sans-serif; height: 100vh; height: 100dvh;
       display: flex; flex-direction: column; background: #fff; overflow: hidden; }
.header { padding: 0.4rem 0.6rem; border-bottom: 1px solid #d8dee4; background: #f6f8fa;
           display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
.header h1 { font-size: 0.8rem; font-weight: 600; color: #1f2328; }
.header .links { display: flex; gap: 0.6rem; font-size: 0.7rem; }
.header a { color: #656d76; text-decoration: none; }
.iframe-wrap { height: 35%; flex-shrink: 0; border-bottom: 1px solid #d8dee4; overflow: hidden; position: relative; }
.iframe-wrap iframe { width: 100%; height: 100%; border: none; }
.iframe-label { position: absolute; bottom: 0.3rem; right: 0.5rem; font-size: 0.6rem; color: #656d76;
                background: rgba(255,255,255,0.85); padding: 0.1rem 0.4rem; border-radius: 4px; }
.resize-handle { height: 6px; background: #f6f8fa; cursor: ns-resize; border-bottom: 1px solid #d8dee4;
                 flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
.resize-handle::after { content: ''; width: 30px; height: 2px; background: #d8dee4; border-radius: 1px; }
.terminal-wrap { flex: 1; overflow: hidden; position: relative; }
.terminal { height: 100%; overflow-y: auto; padding: 0.5rem; background: #0d1117;
            font-family: 'JetBrains Mono', monospace; font-size: 0.65rem; line-height: 1.45;
            white-space: pre-wrap; word-wrap: break-word; color: #c9d1d9; -webkit-overflow-scrolling: touch; }
.terminal .prompt { color: #58a6ff; font-weight: 500; }
.terminal .tool { color: #8b949e; }
.refresh-dot { position: absolute; top: 0.3rem; right: 0.4rem; width: 5px; height: 5px;
               border-radius: 50%; background: #3fb950; opacity: 0.4; animation: pulse 3s infinite; }
@keyframes pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 0.1; } }
.input-area { flex-shrink: 0; border-top: 1px solid #d8dee4; background: #f6f8fa; padding: 0.4rem 0.5rem;
              display: flex; flex-direction: column; gap: 0.3rem; }
.input-area textarea { width: 100%; background: #f0f2f5; border: 1px solid #d8dee4; border-radius: 8px;
                       color: #1f2328; padding: 0.5rem 0.65rem; font-size: 0.85rem; font-family: 'Inter', sans-serif;
                       resize: none; outline: none; min-height: 38px; max-height: 100px; line-height: 1.35; }
.input-area textarea:focus { border-color: #0969da; }
.btn-row { display: flex; gap: 0.3rem; justify-content: flex-end; }
.btn-row button { font-family: 'Inter', sans-serif; font-size: 0.72rem; font-weight: 500;
                  padding: 0.35rem 0.6rem; border-radius: 6px; cursor: pointer;
                  border: 1px solid #d8dee4; background: #f0f2f5; color: #1f2328; touch-action: manipulation; }
.btn-row button:active { transform: scale(0.96); }
.btn-row .send-btn { background: #0969da; border-color: #0969da; color: #fff; padding: 0.35rem 0.9rem; }
.btn-row .mic-active { background: #cf222e !important; border-color: #cf222e !important; color: #fff !important; }
.auth-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: #fff;
                display: flex; align-items: center; justify-content: center; z-index: 100; }
.auth-overlay.hidden { display: none; }
.auth-box { text-align: center; }
.auth-box input { padding: 0.6rem; border: 1px solid #d8dee4; border-radius: 8px; font-size: 1rem; width: 200px; }
.auth-box button { margin-top: 0.5rem; padding: 0.5rem 1.5rem; background: #0969da; color: #fff;
                   border: none; border-radius: 8px; font-size: 0.9rem; cursor: pointer; }
</style></head><body>
<div class="auth-overlay" id="authOverlay">
    <div class="auth-box">
        <h2 style="margin-bottom:1rem;font-size:1.1rem;">PARA Mobile</h2>
        <input type="password" id="authInput" placeholder="Password" onkeydown="if(event.key==='Enter')doAuth()">
        <br><button onclick="doAuth()">Enter</button>
    </div>
</div>
<div class="header">
    <h1>PARA Mobile</h1>
    <div class="links"><a href="/">Dashboard</a><a href="/agents">Agents</a></div>
</div>
<div class="iframe-wrap" id="iframeWrap">
    <iframe id="viewerIframe" src="/"></iframe>
    <span class="iframe-label" id="iframeLabel">/</span>
</div>
<div class="resize-handle" id="resizeHandle"></div>
<div class="terminal-wrap">
    <div class="refresh-dot"></div>
    <div class="terminal" id="terminal"></div>
</div>
<div class="input-area">
    <textarea id="msgInput" placeholder="Message..." rows="1"
        onkeydown="if(event.key==='Enter' && !event.shiftKey){event.preventDefault();sendMsg();}"
        oninput="this.style.height='auto';this.style.height=Math.min(this.scrollHeight,100)+'px';"></textarea>
    <div class="btn-row">
        <button onclick="interruptAgent()" style="color:#cf222e;">Esc</button>
        <button onclick="refreshPane()">Refresh</button>
        <button id="micBtn" onclick="toggleMic()">Mic</button>
        <button id="ttsBtn" onclick="readResponse()">Read</button>
        <button class="send-btn" onclick="sendMsg()">Send</button>
    </div>
</div>
<script>
let pw = null;
const AGENT = 'mobile_chat';
const terminal = document.getElementById('terminal');
const inputEl = document.getElementById('msgInput');
let autoScroll = true;
let lastIframeUrl = '/';

document.getElementById('authInput').focus();
terminal.addEventListener('scroll', () => {
    autoScroll = terminal.scrollTop + terminal.clientHeight >= terminal.scrollHeight - 30;
});

function doAuth() {
    pw = document.getElementById('authInput').value;
    fetch('/api/agents/list', {headers:{'X-Agents-Password': pw}}).then(r => {
        if (r.ok) { document.getElementById('authOverlay').classList.add('hidden'); init(); }
        else { alert('Wrong password'); }
    });
}

function init() { refreshPane(); setInterval(refreshPane, 3000); setInterval(checkIframeUrl, 3000); }

async function refreshPane() {
    if (!pw) return;
    // Skip refresh while user has an active selection inside the pane.
    const sel = window.getSelection();
    if (sel && sel.toString().length > 0 && terminal.contains(sel.anchorNode)) return;
    const res = await fetch('/api/agents/' + AGENT + '/pane?lines=200', {headers:{'X-Agents-Password': pw}});
    const data = await res.json();
    if (data.error) return;
    terminal.innerHTML = data.output
        .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
        .replace(/^(\u2771 .*)$/gm, '<span class="prompt">$1</span>')
        .replace(/^(\u25cf .*)$/gm, '<span class="tool">$1</span>');
    if (autoScroll) terminal.scrollTop = terminal.scrollHeight;
}

async function checkIframeUrl() {
    try {
        const res = await fetch('/api/mobile_chatter/iframe_url?' + Date.now());
        const url = await res.text();
        if (url && url !== lastIframeUrl) {
            lastIframeUrl = url;
            document.getElementById('viewerIframe').src = url;
            document.getElementById('iframeLabel').textContent = url;
        }
    } catch(e) {}
}

async function interruptAgent() {
    if (!pw) return;
    await fetch('/api/agents/' + AGENT + '/interrupt', {
        method: 'POST', headers: {'X-Agents-Password': pw},
    });
    setTimeout(refreshPane, 500);
}

async function sendMsg() {
    let text = inputEl.value.trim();
    if (!text || !pw) return;
    if (inputEl.dataset.fromMic) text = '(note this text is coming from dictation so interpret likely typos as you best see fit) ' + text;
    inputEl.dataset.fromMic = '';
    inputEl.value = '';
    inputEl.style.height = 'auto';
    stopMic();
    await fetch('/api/agents/' + AGENT + '/send', {
        method: 'POST', headers: {'Content-Type': 'application/json', 'X-Agents-Password': pw},
        body: JSON.stringify({text: text}),
    });
    setTimeout(refreshPane, 500);
    setTimeout(refreshPane, 2000);
}

// Mic (Whisper)
let mediaRecorder = null, audioChunks = [], isListening = false;
async function toggleMic() {
    if (isListening) { stopMic(); return; }
    try {
        const stream = await navigator.mediaDevices.getUserMedia({audio: true});
        mediaRecorder = new MediaRecorder(stream, {mimeType: 'audio/webm;codecs=opus'});
        audioChunks = [];
        mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
        mediaRecorder.onstop = async () => {
            stream.getTracks().forEach(t => t.stop());
            if (!audioChunks.length) return;
            document.getElementById('micBtn').textContent = '...';
            const form = new FormData();
            form.append('audio', new Blob(audioChunks, {type: 'audio/webm'}), 'rec.webm');
            try {
                const res = await fetch('/api/transcribe', {method: 'POST', body: form});
                const data = await res.json();
                if (data.text) { inputEl.value = (inputEl.value ? inputEl.value+' ':'')+data.text; inputEl.dataset.fromMic='1'; }
            } catch(e) { alert('Transcription failed'); }
            document.getElementById('micBtn').textContent = 'Mic';
            document.getElementById('micBtn').classList.remove('mic-active');
        };
        mediaRecorder.start(250); isListening = true;
        document.getElementById('micBtn').classList.add('mic-active');
        document.getElementById('micBtn').textContent = 'Stop';
    } catch(e) { alert('Mic denied'); }
}
function stopMic() { if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop(); isListening = false; }

// TTS
let ttsAudio = null, ttsPlaying = false;
function extractResponse(text) {
    const lines = text.split('\n'); let start = -1;
    for (let i = 0; i < lines.length; i++) { const l = lines[i].trim();
        if (l.startsWith('\u25cf') && !l.match(/^\u25cf (Bash|Read|Write|Edit|Glob|Grep|Search|Agent)\(/)) start = i; }
    if (start === -1) return '';
    let out = [];
    for (let i = start; i < lines.length; i++) { const l = lines[i].trim();
        if (i > start && (l.startsWith('\u2771') || l.startsWith('\u2500\u2500'))) break;
        if (l.startsWith('\u25cf Bash(') || l.startsWith('\u25cf Read(') || l.startsWith('\u25cf Write(') ||
            l.startsWith('\u25cf Edit(') || l.startsWith('\u23bf') || l.startsWith('\u273b') || l.startsWith('\u2722') ||
            l.match(/^\s*$/) || l.includes('ctrl+o') || l.includes('auto-compact') || l.includes('bypass')) continue;
        let c = l.replace(/^\u25cf /, ''); if (c) out.push(c); }
    return out.join(' ');
}
function readResponse() {
    const btn = document.getElementById('ttsBtn');
    if (ttsPlaying) { if (ttsAudio) { ttsAudio.pause(); ttsAudio = null; } ttsPlaying = false;
        btn.textContent = 'Read'; btn.style.background = ''; btn.style.borderColor = ''; btn.style.color = ''; return; }
    const text = extractResponse(terminal.textContent || '');
    if (!text.trim()) { alert('No response'); return; }
    btn.textContent = '...'; btn.style.background = '#9a6700'; btn.style.borderColor = '#9a6700'; btn.style.color = '#fff';
    ttsPlaying = true;
    fetch('/api/tts', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({text})})
    .then(r => { if(!r.ok) throw new Error(r.status); return r.blob(); })
    .then(blob => { ttsAudio = new Audio(URL.createObjectURL(blob));
        btn.textContent='Stop'; btn.style.background='#cf222e'; btn.style.borderColor='#cf222e';
        ttsAudio.onended = () => { ttsPlaying=false; btn.textContent='Read'; btn.style.background=''; btn.style.borderColor=''; btn.style.color=''; };
        ttsAudio.play(); })
    .catch(e => { ttsPlaying=false; btn.textContent='Read'; btn.style.background=''; btn.style.borderColor=''; btn.style.color=''; alert('TTS failed'); });
}

// Resize handle
const handle = document.getElementById('resizeHandle'), iframeWrap = document.getElementById('iframeWrap');
let startY, startH;
handle.addEventListener('mousedown', e => { startY=e.clientY; startH=iframeWrap.offsetHeight;
    document.addEventListener('mousemove', onDrag); document.addEventListener('mouseup', stopDrag); });
handle.addEventListener('touchstart', e => { startY=e.touches[0].clientY; startH=iframeWrap.offsetHeight;
    document.addEventListener('touchmove', onDragT); document.addEventListener('touchend', stopDrag); }, {passive:true});
function onDrag(e) { iframeWrap.style.height = Math.max(50, startH+e.clientY-startY)+'px'; }
function onDragT(e) { iframeWrap.style.height = Math.max(50, startH+e.touches[0].clientY-startY)+'px'; }
function stopDrag() { document.removeEventListener('mousemove',onDrag); document.removeEventListener('mouseup',stopDrag);
    document.removeEventListener('touchmove',onDragT); document.removeEventListener('touchend',stopDrag); }
</script>
</body>
</html>z/paper_figuresz/paper_figures/c                  C   D   t d d } |  sdS |  }tddd|dddd	S )
z!Render paper figures review page.paper_figuresrf   r   r   r   r   r   r   r   r   r   r8   r   rw   r   r   r   r   r   r  x  
   $r  z$/paper_figures/media/<path:filename>c                 C      t td d | S )Nr  r   r#   r)   r   r   r   paper_figures_media  r   r  z/para_presentationz/para_presentation/c                  C   r  )
z2Render lab presentation deck (markdown-as-slides).para_presentationrf   r   r   r   r   r   r   r   r  r  r   r   r   r    r  r  z(/para_presentation/media/<path:filename>c                 C   r  )Nr  r   r#   r)   r   r   r   para_presentation_media  r   r  z/data/cameron/tri_diaryz/tri_diariesz/tri_diaries/c                  C   s   t d } |  s
dS tt dS )Nr   )u@   TRI diary not built yet — run /data/cameron/tri_diary/build.pyr&   )TRI_DIARY_DIRr   r   )idxr   r   r   tri_diaries  s   
r  Z
svg_editorz!/data/cameron/para/paper/figs/svgz/svg_editorz/svg_editor/c                   C   r"   )Nzeditor.htmlr   SVG_EDITOR_DIRr   r   r   r   svg_editor_index  s   
r  z/svg_editor/static/<path:sub>c                 C      t td | S )NZsvgeditr  )r   r   r   r   svg_editor_static  r,   r  z/svg_editor/filesc                  C   sB   t dd tdD } dd | D }dd | D }t||dS )Nc                 s   s    | ]}|j V  qd S r  )rv   r   r   r   r   rq     s    z#svg_editor_files.<locals>.<genexpr>z*.svgc                 S   s   g | ]	}| d s|qS _edited.svgendswithr  r   r   r   r         z$svg_editor_files.<locals>.<listcomp>c                 S   s   g | ]	}| d r|qS r  r  r  r   r   r   r     r  )	originalsedited)rt   SVG_SOURCE_DIRglobr   )Zall_svgsr  r  r   r   r   svg_editor_files  s   r  z/svg_editor/load/<fname>c                 C   sB   d| v sd| v s|  dsdS t|  }| sdS t| ddS )N..r   .svg)bad namerE   )z	not foundr&   zimage/svg+xmlr  )r  r  r   r	   r8   )fnamer   r   r   r   svg_editor_load  s   r  z/svg_editor/save/<fname>c                 C   s   d| v sd| v s|  dstddidfS | d d }| dr%t|  }nt| d	 }tjd
d}|| tt|| jdS )Nr  r   r  rD   r  rE   Z_editedr  Tr<   )rK   r   )	r  r   r  r   r?   r>   rT   r|   r   )r  stemoutrU   r   r   r   svg_editor_save  s   


r  z/paper_as_markdownz/paper_as_markdown/c                  C   r  )
z8Render the paper outline as a collapsible markdown page.paper_as_markdownrf   r   r   r   r   r   r   r   r  r  r   r   r   r    r  r  z(/paper_as_markdown/media/<path:filename>c                 C   r  )Nr  r   r#   r)   r   r   r   paper_as_markdown_media  r   r  z/<agent>/<path:filename>c                 C   s   t |  }| rt||S dS )Nr   r   )agentr*   	agent_dirr   r   r   report_file  r!   r  z	/<agent>/c                 C   sP   t |  }| s
dS t|ddd}|r"td|  d|d j S d|  dS )	Nr   z*.htmlTrd   r   r   z<h1>z</h1><p>No reports yet.</p>)r   rO   rt   r  r   rv   )r  r  Zreportsr   r   r   agent_index  s   r  a  
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
       background: #ffffff; color: #1f2328; padding: 1.5rem; line-height: 1.6; }
nav.breadcrumb { margin-bottom: 1rem; font-size: 0.9rem; }
nav.breadcrumb a { color: #0969da; text-decoration: none; }
nav.breadcrumb a:hover { text-decoration: underline; }
h1 { color: #0d1117; font-size: 1.4rem; margin-bottom: 1rem; }
.listing { list-style: none; }
.listing li { display: flex; align-items: center; gap: 0.75rem;
              padding: 0.4rem 0.6rem; border-bottom: 1px solid #d8dee4; }
.listing li:hover { background: #f6f8fa; border-radius: 4px; }
.listing a { color: #0969da; text-decoration: none; flex: 1; }
.listing a:hover { text-decoration: underline; }
.listing .icon { width: 1.2rem; text-align: center; flex-shrink: 0; }
.listing .size, .listing .date { color: #656d76; font-size: 0.8rem; white-space: nowrap; }
.media-preview { margin: 1.5rem 0; text-align: center; }
.media-preview video, .media-preview img { max-width: 100%; max-height: 80vh; border-radius: 6px; }
.media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; margin-top: 1rem; }
.media-grid .card { background: #f6f8fa; border: 1px solid #d8dee4; border-radius: 8px;
                    overflow: hidden; }
.media-grid .card video, .media-grid .card img { width: 100%; display: block; }
.media-grid .card .label { padding: 0.4rem 0.6rem; font-size: 0.8rem; color: #656d76;
                           white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.media-grid .card .label a { color: #0969da; text-decoration: none; }
.back-link { display: inline-block; margin-bottom: 0.5rem; color: #0969da; text-decoration: none; }
.toggle-btn { background: #f6f8fa; color: #1f2328; border: 1px solid #d8dee4; padding: 0.3rem 0.8rem;
              border-radius: 4px; cursor: pointer; font-size: 0.85rem; margin-bottom: 1rem; }
.toggle-btn:hover { background: #eef1f5; }
</style>
>   .mp4z.aviz.movr  z.mkv>   z.bmpr   r   r   z.gifr   Z_thumb_cacheT)exist_ok)@   `            i@  i  i     i   i  i   i   	full_pathr   returnc                 C   sF   |   }|  d|j d| }t|  d d }t| d S )N|   r   )r|   st_mtime_ns_hashlibZsha1encode	hexdigest_THUMB_CACHE_DIR)r  r   str   hr   r   r   _thumb_cache_path+  s   r  c                 C   s  t | |}| r| jdkr|S z:ddlm} || $}|d}|||f|j	 |j
t|ddddd W d	   |W S 1 sEw   Y  |W S  ty } z*z
| r]|  W n	 tyg   Y nw tjd
|  d| d|  W Y d	}~d	S d	}~ww )zUReturn a Path to a cached JPEG thumbnail (longest-edge = `size`), or None on failure.r   )ImageZRGBZJPEGR   TF)ZqualityZprogressiveoptimizeNzthumb failed for z @ z: )r  r   r|   r   ZPILr  r3  ZconvertZ	thumbnailZLANCZOSr   rT   rQ   r  rS   apploggerr  )r  r   tpr  ZimgrZ   r   r   r   _ensure_thumb2  s2   


r  	file_pathc                 C   sj   | j  tvr	dS z!tjdddddddd	d
t| g
dddd}|j  }|dvW S  ty4   Y dS w )zFCheck if a video uses a codec browsers can't play (e.g. mpeg4 part 2).FZffprobez-vquietz-select_streamszv:0z-show_entrieszstream=codec_namez-ofzcsv=p=0T   )r>  r?  r  )Zh264ZhevcZh265Zvp8Zvp9Zav1)	r   r   
VIDEO_EXTSrA  rB  rT   rC  rz   rQ   )r  r  codecr   r   r   _needs_transcodeJ  s   

r  z/tmp/para_transcode_cachec                 C   s~   ddl }|  d|  j }||  d }t| }| s=tj	ddt
| ddd	d
dddddddt
|dgddd |S )z;Return path to an H.264 transcoded version, cached on disk.r   N:r  Zffmpegz-iz-c:vZlibx264z-presetZ	ultrafastz-crfZ23z	-movflagsz
+faststartz-anz-fZmp4-yTrg  )r>  r  )hashlibr|   r}   Zmd5r  r  TRANSCODE_CACHEr   rA  rB  rT   )r  r  r   Z
cache_namecachedr   r   r   _get_transcoded`  s   r  c                 C   sX  t | rt| } |  j}tt| d pd}tj	d}|r|
dd }|d}|d r7t|d nd}|d rCt|d n|d }t||d }|| d }t| d}	|	| |	|}
W d	   n1 snw   Y  t|
d
|d}d| d| d| |jd< t||jd< d|jd< |S tt| |d}d|jd< t||jd< |S )zOServe a file with manual Range request support (works through tunnels/proxies).r   zapplication/octet-streamZRangezbytes=r   rg   r   rbN   r   r  zbytes r   zContent-RangezContent-Lengthr   r   r  )r  r  r|   r   	mimetypesZ
guess_typerT   r   r   r0   rw   rz   rx   r   minr3  seekreadr	   r
   r   )r  	file_sizeZ	mime_typeZrange_headerZ
byte_ranger  r   r   lengthr   r   respr   r   r   _serve_fileq  s2   




r  rel_pathc                 C   s   | j }t|  j}d|dddd }|r!d| dnd}d| d}d| d| d| d	| d
| d| d| d| d| d| dS )zFServe an HTML page with an embedded video player instead of raw bytes.r   N/browse/?raw<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>a  </title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
       background: #0d1117; color: #c9d1d9; padding: 1.5rem; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.player { max-width: 960px; margin: 1rem auto; text-align: center; }
video { max-width: 100%; max-height: 80vh; border-radius: 6px; background: #000; }
.info { margin-top: 0.75rem; font-size: 0.85rem; color: #8b949e; }
.nav { margin-bottom: 1rem; }
.download { display: inline-block; margin-top: 0.75rem; padding: 0.4rem 1rem;
             background: #21262d; border: 1px solid #30363d; border-radius: 4px; color: #c9d1d9; }
</style></head><body>
<div class="nav"><a href="z">&larr; Back</a></div>
<div class="player">
<video id="vid" controls autoplay preload="auto"></video>
<div class="info" id="status">Loading  (z%)...</div>
<a class="download" href="z" download="zc">Download</a>
</div>
<script>
// Fetch video as blob to bypass tunnel range-request issues
fetch("a  ").then(r => {
    if (!r.ok) throw new Error("Fetch failed: " + r.status);
    const total = +r.headers.get("content-length") || 0;
    const reader = r.body.getReader();
    const chunks = [];
    let loaded = 0;
    function pump() {
        return reader.read().then(({ done, value }) => {
            if (done) {
                const blob = new Blob(chunks, { type: "video/mp4" });
                document.getElementById("vid").src = URL.createObjectURL(blob);
                document.getElementById("status").textContent = "u    · a  ";
                return;
            }
            chunks.push(value);
            loaded += value.length;
            if (total) {
                const pct = Math.round(loaded / total * 100);
                document.getElementById("status").textContent = "Loading... " + pct + "%";
            }
            return pump();
        });
    }
    return pump();
}).catch(e => {
    document.getElementById("status").textContent = "Error: " + e.message;
    // Fallback: try direct src
    document.getElementById("vid").src = "z";
});
</script>
</body></html>)rv   _human_sizer|   r   r   rstriprx   )r  r  rv   r   r2  
parent_urlraw_urlr   r   r   _video_player_page  s4   $$4r  c                 C   s\   | j }d|dddd }|rd| dnd}d| d}d| d| d| d	S )
z1Serve an HTML page with an embedded image viewer.r   Nr  r  r  r	  a  </title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
       background: #0d1117; color: #c9d1d9; padding: 1.5rem; }
a { color: #58a6ff; text-decoration: none; }
a:hover { text-decoration: underline; }
.viewer { max-width: 960px; margin: 1rem auto; text-align: center; }
img { max-width: 100%; max-height: 85vh; border-radius: 6px; }
.nav { margin-bottom: 1rem; }
</style></head><body>
<div class="nav"><a href="z7">&larr; Back</a></div>
<div class="viewer">
<img src="z">
</div>
</body></html>)rv   r   r  rx   )r  r  rv   r2  r  r  r   r   r   _image_viewer_page  s   r  nbytesc                 C   sP   dD ]}| dk r|dkr| dd|   S |  d  S | d } q| ddS )N)BZKBZMBZGBr  r  z.1frh   z Bz TBr   )r  Zunitr   r   r   r    s
   *
r  c                 C   s~   |  dr|  ddng }dg}t|D ]\}}dd|d |d   d }|d| d| d qdd	| d
 S )Nr   z$<a href="/browse/">/data/cameron</a>r  r   z	<a href="rl   z</a>z<nav class="breadcrumb">z / z</nav>)rz   rx   r   r   r   )r  r  Zcrumbsr   partlinkr   r   r   _breadcrumb  s   r  z/data/libero /home/cameronsmith/mnt/robot-labz/home/cameronsmith/mnt/yukonc                 C   sx   dt | jv r	dS t|  }z| }W n
 ty   Y dS w tD ]}t|t|ks5t|t|d r9|  S q dS )zResolve `subpath` under BROWSE_ROOT, rejecting `..` traversal.
    Symlinks that resolve into any allowlisted root (e.g. /data/cameron/libero -> /data/libero) are accepted.r  Nr   )r   r  r   r	  rQ   _BROWSE_ALLOWED_ROOTSrT   ry   )subpath	candidater  rootr   r   r   rN   	  s   &rN   z/browse_yamz/browse_yam/c               
   C   sT   t d} zt|   W tdS  ty) } zd| ddfW  Y d}~S d}~ww )u   Land on the YAM calibration QA folder served through the SSHFS mount.

    The mount is school-server → dev (Tailscale ProxyJump) → robot-lab. If puget
    is offline, the mount stalls. Triggers a stat() to surface that quickly.
    r  zP<h1>YAM mount unavailable</h1><p>SSHFS to robot-lab (via puget) returned: <code>z</code></p><p>On the school server run:<br><code>fusermount -u /home/cameronsmith/mnt/robot-lab && sshfs robot-lab:/home/robot-lab /home/cameronsmith/mnt/robot-lab -o reconnect,ServerAliveInterval=30</code></p>  Nz6/browse/yam_remote/cameron/yam_overlay/calibration_qa/)r   r   ru   rS   r   )ZmountrZ   r   r   r   
browse_yam	  s   r  r  z/browse/<path:subpath>r   c                    sP  t | }|d u rtd | std | ro|j }tjdd urYtjjdt	d  rU|t
v rU tvrAtt fddd t| }|d urUt|}d	|jd
< |S t|S |tv rbt|| S |t
v rkt|| S t|S g }g }zt| dd dD ]~}|j}|drq~| r| dd | n|}	| }
t|
jd}| r|d|	 d| d| d q~t|
j  |j }|tv rd}|d|d|	 f n|t
v rd}|d|d|	 f nd}|d| d|	 d| d  d| d q~W n t!y   td Y nw d}|rg }t"|D ]2\}\}}}| d}|dkr:|d | d!| d| d" q|d#| d$| d| d" qd%d& t"|D }d}|rpd'#d(d) d*d) |$ D D }d+| d,}d-t%| d.d#| d/| }| rd| d nd}d0| d1t& d2t'|  d3| d4| d5d#| d6}|S )7NrF   r&   rawr   r7  c                    s   t |   S r  )absr   r   r   r   r   K	  r   zbrowse.<locals>.<lambda>r   zpublic, max-age=86400zCache-Controlc                 S   s   |   | j fS r  )r   rv   r   )r   r   r   r   r   ]	  s    r   r   rk   z8<li><span class="icon">&#128193;</span><a href="/browse/z/">z/</a><span class="date">rm   z	&#127910;videor  z	&#128444;Zimagez	&#128196;z<li><span class="icon">z</span><a href="/browse/rl   z</a><span class="size">z</span><span class="date">r   r  z<div class="card"><video id="vz>" controls preload="none"></video><div class="label"><a href="z</a></div></div>z<div class="card"><img src="z-" loading="lazy"><div class="label"><a href="c                 S   s*   i | ]\}\}}}|d kr|| dqS )r   r  r   )rn   r  mtypemnamemurlr   r   r   
<dictcomp>	  s   * zbrowse.<locals>.<dictcomp>,c                 s   s&    | ]\}}d | d| dV  qdS )z["z","z"]Nr   )rn   Zvid_idurlr   r   r   rq   	  r  zbrowse.<locals>.<genexpr>c                 s   s"    | ]\}}d | |fV  qdS )vNr   )rn   r  r&  r   r   r   rq   	  s     z<script>
const vids = [a[  ];
const obs = new IntersectionObserver((entries) => {
    entries.forEach(e => {
        if (e.isIntersecting) {
            const el = e.target;
            if (!el.dataset.loaded) {
                el.dataset.loaded = "1";
                fetch(el.dataset.src).then(r => r.blob()).then(b => {
                    el.src = URL.createObjectURL(b);
                });
            }
            obs.unobserve(el);
        }
    });
}, { rootMargin: "200px" });
vids.forEach(([id, url]) => {
    const el = document.getElementById(id);
    if (el) { el.dataset.src = url; obs.observe(el); }
});
</script>z<button class="toggle-btn" onclick="document.getElementById('media-grid').style.display=document.getElementById('media-grid').style.display==='none'?'grid':'none'">Toggle media grid (z8 items)</button><div id="media-grid" class="media-grid">z</div>z<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Browse z</title>z</head><body>
z
<h1>z</h1>
z
<ul class="listing">rr   )(rN   r   r   r   r   r   r   r\   r0   r   
IMAGE_EXTS_ALLOWED_THUMB_SIZESr  r  r  r   r  r  r  rt   ru   rv   ry   r  r|   r   r{   r}   r~   rO   r   r  r   r   r   r   r   r   BROWSE_STYLEr  )r  r  r   r  r  r   Zmedia_filesr   rv   r   r|   dateZiconZ
media_htmlZcardsr  r!  r"  r#  r  Z	video_mapZload_scriptZ
entries_jsZdir_nameZhtmlr   r  r   browse4	  s   











r,  localtunnelportr   c              
   C   s  ddl }	 z|dkrZtd tjddddt| gtjtjdd	}|jD ]1}| }d
|v s3d|	 v rTd|v r=|
 d n|}tdd  td|  td d q#|  nZ|dkrtd tjdddd|  gtjtjdd	}|jD ]8}| }d|v r|
 D ])}d|v r|dr|nd| }tdd  td|  td d  nqqw|  W n ty } ztd|  W Y d}~nd}~ww td |d q)z@Start a tunnel in background with auto-retry. Prints public URL.r   NTr-  z
Starting localtunnel...Znpxr  --port)rC  r  r?  zloca.ltzyour urlhttpr  rj   z<============================================================z  PUBLIC URL: cloudflaredz
Starting cloudflared tunnel...tunnelz--urlzhttp://localhost:z.trycloudflare.comztrycloudflare.comzhttps://zTunnel error: z&Tunnel disconnected, retrying in 5s...r  )rI  printrA  PopenrT   PIPEZSTDOUTrC  rz   r   rx   waitry   rQ   rJ  )r.  r   rI  procr   r&  ZwordrZ   r   r   r   start_tunnel	  sb   



r8  z+/data/cameron/panda_data/single_demo_sanityik_cachez
/inferencez/inference/z/inference/<path:filename>c                 C   r  )NZ	inferencer#   r)   r   r   r   inference_page	  s   r:  z/api/inference/episodesc                  C   sb   t d } |  stddidfS dd l}t| }t||W  d    S 1 s*w   Y  d S )Nr   rD   no episodes.jsonr&   r   )PANDA_DATA_DIRr   r   rL   r3  load)ep_filer   r   r   r   r   inference_episodes 
  s   
$r?  z/api/inference/run_ikc                  C   sD   t  } | dd}t|}|d u rtdd| didfS t|S )N	frame_idxr   rD   zframe z
 not foundr&   )r   rM   r0   _run_ik_for_framer   )r   r@  rE  r   r   r   inference_run_ik	
  s   rB  z/api/inference/run_ik_episodec            	      C   s   t  } | dd}dd l}ttd }||d }W d    n1 s&w   Y  |t|kr9tddidfS || }g }t	|d |d	 d
 dD ]}t
|}|rX|| qKtd|iS )NZepisode_idxr   r   r   rD   invalid episoderE   r   r   r   rH   results)r   rM   r0   rL   r3  r<  r=  r   r   r|  rA  r   )	r   ep_idxr   r   r   r   rD  r  r  r   r   r   inference_run_ik_episode
  s    
rF  c           5   
   C   s  ddl }ddl}| d}t| d }t| d }| r"| s$dS t| d }t| d }t| d }| r| r| rddl}	t|}
|	|
}W d   n1 s]w   Y  tt	d	}| d
| d|d< | d| d|d< | d| d|d< |S z,ddl
}ddlm} ddl}|jdd td ddlm} ddlm}m}m} W n ty } zdt|iW  Y d}~S d}~ww |g dg dg dg dg}|jg dg dg dg|jd}|g d}|j|j}||}| ||j!j"d}|t|#|j}|$|%t||j&}t'|dkr.|d nd}|dd |j(dd< |d   |j(d< |j(d!< |)|| |j*| + }|g d"}||j(dd< |d   |j(d< |j(d!< |)|| t,d#D ]} |j*| + }!|j-| .d$d$+ }"||! }#||"j/ }$|0|1|2|$d% d& d'd%}%|%d(k r|3d$n$|%d&|4|%d)   ||$d* |$d+  |$d, |$d-  |$d. |$d/  g }&|j56|#d0k r|j56|&d1k r nj|3d$|j7f|3d$|j7f}'}(|8|||'|(| |9|#d2|& g})|:|'ddddf d2|(ddddf  g}*|j5;|*j/|* d3|<d  |*j/|) }+|1|+d4d5}+|j(dd  |+7  < |)|| qx|j(dd + },|j*| + }-t=|j56|-| d6 }.|dd |j(dd< |d   |j(d< |j(d!< |)|| |||||||| |||||d7d8}/|j>|/d9kd&d:}0|+ }1||0 d; |/|0 d<  #|j?|1|0< |,|j(dd< |d   |j(d< |j(d!< |)|| |||||||| |||||d7d8}2|j>|2d9kd&d:}3|+ }4||3 d; |2|3 d<  #|j?|4|3< |@t||$|A|1d=|jB|jCd>g |@t||$|A|4d=|jB|jCd>g ddl}	| |.|D |-D t=||dd D |,D d?}t|d@}
|	E||
 W d   n	1 shw   Y  dA| d|d< dB| d|d< dB| d|d< |S )Cz4Run IK for a single frame, cache the overlay images.r   N06d.npyr   z_gt.jpgz_ik.jpgz
_meta.jsonr   r   Zrgb_pathz
/ik_cache/Zgt_overlay_pathZik_overlay_path)Rotation"/data/cameron/para/panda_streamingPANDA_HANDEYE_4X2_CONFIGrender_from_camera_poseget_link_poses_from_robotposition_exoskeleton_meshesrD   gS?gCH?g8$K?gp^ڿg̺=r?g,2ۿg gvMiyu?ghݫɿg*i?g
lPk޿gtUE}?r   r   r   r   r@r   33333@r   4@     X@r   r   r   Zdtype      ?      r_  hand   r^  {Gz?rp          gQrd  g+rd  gA`"?gQ?r6      r   rH   r  ư>绽|=rH   r   r   rH   r   rH   rH   r   r   r   r   r   Mb@?MbP?rH  -C6?皙皙?i  8    
   Zaxis皙?333333?)r  ih  U   )r@  Zpos_error_mmZ
gt_eef_posZ
ik_eef_posZgripperZ	gt_joints	ik_jointswz+panda_data/data_20260420_115853_632_frames/z4panda_data/data_20260420_115853_632_frames/ik_cache/)Fnumpycv2r<  r   _ik_cache_dirrL   r3  r=  rT   relative_tomujocoZscipy.spatial.transformrI  rQ  r   rR  r   chdir ExoConfigs.panda_exo_handeye_4x2rL  	exo_utilsrN  rO  rP  rQ   arrayfloat64diagMjModelfrom_xml_stringxmlMjData
mj_name2idmjtObj
mjOBJ_BODYastypecvtColorimreadCOLOR_BGR2RGBr   qpos
mj_forwardxposcopyr|  xmatreshapeTarccoscliptracezerossinlinalgnormnv
mj_jacBodyconcatenatevstacksolveeyer   r  uint8ZimwriteZresizeCOLOR_RGB2BGRIMWRITE_JPEG_QUALITYtolistdump)5r@  npr}  tsnpy_pathimg_pathZgt_cacher9  Z
meta_cacher   r   metaZ_relr  ZRotrQ  rL  rN  rO  rP  rZ   ZT_CAM_WORLDZCAM_K	FIXED_ROTr$  mj_datahand_idjsrgbgwZgt_posHOME_Qri   Zcur_poscur_rotZpos_errZR_errZangleZrot_errZjacpZjacrerrJdqZik_qZik_posZ
pos_err_mmZ	gt_renderZgt_maskZ
gt_overlayZ	ik_renderZik_maskZ
ik_overlayr   r   r   rA  #
  s   

"

"^$"2($$**	rA  z,/data/cameron/panda_data/fewdemo_bowl_pickup)single_demo_sanityZfewdemo_bowl_pickupz/api/inference/trajectory/loadc            M         s  ddl } ddl}ddl}ddl}t }|dd}|dd}|dd}|dd	}t|}	|	s>td
d| idfS |	d }
|
	 sNtd
didfS t
|
}||d }W d   n1 sdw   Y  |t|krwtd
didfS || }ddl}ddl}|jdd td ddlm} ddlm}m}m} | g dg dg dg dg | jg dg dg dg| jd|j|j}||}|||j j!d} fdd}t"t#|d  |d! d" |}g }t$|D ]E\}}g }g }g }t#|D ]}}|||  } | |d! kr|d! } |	| d#d$ }!|!	 s" n\| t%|!&| j}"t|"d%kr:t'|"d% nd&}#|"dd% |j(dd%< |#d'  |j(d%< |j(d	< |)|| |j*| + }$||$}%|,|%rk|%nddg |,|$-  |,|# q |	|d#d( }&|&	 rb|.t%|&}'g d)}(t$t/||D ]\})\}*}+t0|*d t0|*d" },}-|(|)t|(  }.|)dkr|1|'|,|-fd*|.d+ |1|'|,|-fd	|.d, |2|'d-|+d.|,d/ |-d0 f|j3d1|.d2 n |1|'|,|-fd3|.d, |2|'d4|) |,d5 |-d+ f|j3d6|.d" |)dkr1t0||)d"  d t0||)d"  d" }/}0|4|'|/|0f|,|-f|.d2 q|2|'d7|d"  d8t| d9| d:d;|j3d&d<d2 |5d=|'|j6d>g\}1}2|7|28d?}3nd@}3|r| 9g dA}4| g dB}5|5|j(dd%< |)|| | |d }6t#dCD ]}1|j*| + }7|j:| ;d+d+}8|6|7 }9|4|8j< }:| =| >| ?|:d" d2 d,d"};|;dDk r| @d+n$|;d2| A|;dE   | |:dF |:dG  |:dH |:dI  |:dJ |:dK  g }<| jBC|9dLk r| jBC|<dMk r nh| @d+|jDf| @d+|jDf}=}>|E|||=|>| | F|=dddd%f dN|>dddd%f  g}?| G|9dN|< g}@| jBH|?j<|? dO| Id%  |?j<|@ }A|j(dd%  | >|AdPdQ7  < |)|| q|rk|d nd&}B|Bd'  |j(d%< |j(d	< |)|| |||||||| ||| dRdS}C|&	 r|.t%|&}D| jJ|Cd3kd2dT}E|K|D|jL}F|F|E &t'dU |C|E &t'dV  &| jM|F|E< |j*| + }G||G}H|Hrt0|Hd t0|Hd" }I}J|1|F|I|JfdWdXd, |2|FdY|Bd.d:d;|j3d&dXd2 |5d=|K|F|jN|j6d>g\}1}K|7|K8d?}Lnd@}Lnd@}L|,||||||3|L|r2|j(dd% - nddZ qt|||d  |d! ||t||d[S )\zHLoad a GT trajectory: compute 2D keypoints + 3D positions for all steps.r   Ndatasetr  episodewindow   striderp  rD   zunknown dataset: rE   r   r;  r&   r   rC  rJ  rK  rM  rQ  rR  rS  rT  rU  rX  r[  r\  r`  c                    s    d dd df |   d ddf  }|d dkrd S t d |d  |d  d  t d |d  |d  d  gS )	Nre  rH   r   r   r   rj  r   r   r   ri  )r   )posp_camT_cam_worldZcam_Kr   r   project
  s
   (""z trajectory_load.<locals>.projectr   r   r   rG  rH  ra  r^  rb  r   ))   d   r  )r     2   )r  r6   r   )r6   r  r   )r   r  r  )r   r6   r  r  re  r  zNEXT g=z.2f   r  gffffff?rH   ru  zt+   g      ?zStep r   z (frame ))r  (   )r  r  r  r   P   asciir   r]  rc  r6   rf  rg  rh  ri  rj  rk  rl  rm  rn  ro  rH  rp  rq  rr  rs  rt  rv  rw  rx     r   r  r   zIK target (grip=)stepr@  keypoints_2dkeypoints_3dgrippersZkp_imgZik_imgrz  )r  r  Zep_startZep_endr  r  Zn_stepssteps)Or|  rL   r}  base64r   rM   r0   DATASET_PATHSr   r   r3  r=  r   r  rQ  r   rR  r   r  r  rL  r  rN  rO  rP  r  r  r  r  r  r  r  r  r  r   r|  r   rT   r  r   r  r  r  r  r   r  r  zipr   circleZputTextZFONT_HERSHEY_SIMPLEXr   imencoder  	b64encodedecoder  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  )Mr  r   r}  r  r   Z
dataset_idrE  r  r  Zdpathr>  r   r   r   r  rQ  rL  rN  rO  rP  mj_modelr  r  r  Zframe_indicesr  siZfidxr  r  r  r   Z
future_idxr  r  r  ZeefZpixr  r  ZcolorsZkiZkpgvur'  ZcolorZpuZpvri   bufZ
kp_img_b64r  r  ZtgtZcurr  ZpeZReZangr   ZjpZjrr  r  r  Zgw0renderedZbase_rgbmaskZik_visZep_posZep_pixeuZevZbuf2Z
ik_img_b64r   r  r   trajectory_load
  s.   


"






*$
"^("2( 

0

r  c                     s`   t d ur
t  r
t S dd ldd ldd l dd ldd lG  fddd} |  a t S )Nr   c                       s<   e Zd ZfddZdd Zdd Z fddZd	S )
z$_get_live_stream.<locals>.LiveStreamc                    s<   d | _ d | _  | _d| _ j| jdd| _| j  d S )NTrD  daemon)	latest_frame_b64latest_jointsZLocklock_runningThread_run_threadr   self)	threadingr   r   __init__q  s   
z-_get_live_stream.<locals>.LiveStream.__init__c                 S   s   | j  o| jS r  )r  is_aliver  r  r   r   r   r  y  s   z-_get_live_stream.<locals>.LiveStream.is_alivec                 S   s
   d| _ d S )NF)r  r  r   r   r   stop|  s   
z)_get_live_stream.<locals>.LiveStream.stopc           '   
      s  dd l }tdd  fdd}z|d |jddd	}|  |js*|d
 W d S |d fdd}||dd}|| jdjd_	d_
dd lfdd}j|dd}|  dd l}dd l}	|	jdd td ddlm}
 ddlm}m}m} g dg dg dg dg}d}d}jd | dd!| | gdd"| d#| gg d$gjd}|j|
j}||}| ||j!j"d%}|d& d}j#rj$ j%}j	& }j
}W d    n1 sw   Y  |d u r|dkr|d' d(}'d) q|dk r|d*|  d}t(d+D ]
}|| |j)|< q |*|| ||
||||
|| |||||d,d,}+|j,}j-|d-kd.d/}|& }|| .t/d0 || .t/d1  .j||< |j0| & }|d d2d d2f | |d d2d2f  }|d. dkrt1|d3 |d  |d.  |d4  } t1|d5 |d6  |d.  |d7  }!d|   krd,k rn nd|!  krd,k rn n2|| |!fd8d9d( 3d:+|j4j5d;g\}"}#6|#7d<}$j$ |$_8W d    n	1 s	w   Y  'd= j#sW d S W d S  t9yD }% z|d>|%  dd l:}&|&j; d?  <  W Y d }%~%d S d }%~%ww )@Nr   z/tmp/livestream_debug.logr{  c                    s.     |  d    td|  dd d S )Nrj   z[LiveStream] T)flush)r  r  r3  )msg)_lfr   r   _log  s   z7_get_live_stream.<locals>.LiveStream._run.<locals>._logzStarting...Z	localhosti#  )hostr.  zFailed to connect to rosbridgezConnected to rosbridgec                    sp   |  dg }|  dg }tt||  fddtddD }j |_W d    d S 1 s1w   Y  d S )Nrv   Zpositionc                    s   g | ]}  d | dqS )Z	fr3_jointr   )r0   )rn   r   Zpos_dictr   r   r     s    zP_get_live_stream.<locals>.LiveStream._run.<locals>.on_joints.<locals>.<listcomp>r   rp  )r0   r   r  r|  r  r  )r  namesZ	positionsqr  r  r   	on_joints  s   "z<_get_live_stream.<locals>.LiveStream._run.<locals>.on_jointsz/joint_stateszsensor_msgs/msg/JointState)  r  re  r\  Fc                     s   j rQz:jjddd} |  }j|jd} | j}|d ur<j |_	d_
W d    n1 s7w   Y  W n	 tyF   Y nw d j sd S d S )Nzhttp://localhost:8877/frame.jpgr   )r  r\  T皙?)r  r   Zurlopenr   Z
frombufferr  ZimdecodeZIMREAD_COLORr  
_raw_frame_has_camerarQ   rJ  )r  Z	img_bytesZimg_arrr   )r}  r  r  rI  urllibr   r   camera_fetch_loop  s"   
zD_get_live_stream.<locals>.LiveStream._run.<locals>.camera_fetch_loopTr  rJ  rK  rM  rQ  rR  rS  rT  g     @z@gŨoS?rV  rW  rY  rZ  r[  r`  zMuJoCo loaded, streaming...zWaiting for joint states...r  gQ?zGot joints! has_camera=ra  r  ru  rH   rv  rw  rx  re  r  rj  r  r   ri  rp  r  r   K   r  r  zError: )r   )=roslibpyr3  ZRosrB  Zis_connectedZTopicZ	subscriber  r  r  r  Zurllib.requestr  r   r  rQ  r   rR  r   r  r  rL  r  rN  rO  rP  r  r  r  r  r  r  r  r  r  r  r  r  r  rJ  r|  r  r  r  r  r  r  r   r  r   r  r  r  r  r  r  r  rQ   	traceback	print_excr  )'r  r  r  r  r   Z	joint_subr  Z
cam_threadr  rQ  rL  rN  rO  rP  r  Z_crop_x0Z_sZ	cam_K_448r  r  r  Z_frame_countjointsr  Zhas_camr   r  r  r  ZoverlayZeef_posr  r  r'  ri   r  b64rZ   r	  r  r}  r  r  rI  )r  r  r  r   r    s   









0($$8
4*z)_get_live_stream.<locals>.LiveStream._runN)__name__
__module____qualname__r  r  r  r  r   r  r   r   
LiveStreamp  s
    r  )_live_streamr  r|  r}  r  r  rI  )r  r   r  r   _get_live_streami  s   ( r  z/api/inference/live/startc                  C   s"   t  } td|  rdiS diS )Nr   ZstartedZfailed)r  r   r  )streamr   r   r   
live_start  s   r  z/api/inference/live/framec                  C   sx   t d u st  stddidfS t j t j} t j}W d    n1 s$w   Y  | d u r5tddidfS t| |dS )NrD   zstream not startedr  zno frame yet)r   r  )r  r  r   r  r  r  )r  r  r   r   r   
live_frame  s   r  z/api/inference/live/stopc                   C   s   t rt   d a tddiS )Nr   Zstopped)r  r  r   r   r   r   r   	live_stop  s   r  z100.104.232.94i#  i#  
target_urlc              
      s   ddl }tj}dd tj D }z|j|| ||dkrt nddddd	}W n" |jjyI } ztd
|  d| ddddW  Y d}~S d}~ww h d  fdd|j	j D }t|j
dd|j|dS )zStream a request to target_url and yield the response. Preserves headers,
    streams the body so gRPC-Web trailers/chunks pass through.r   Nc                 S   s"   i | ]\}}|  d vr||qS ))r  zcontent-lengthr   rn   r   r'  r   r   r   r$  <  s    z!_stream_proxy.<locals>.<dictcomp>r4   T)r  NF)r   r   r  r  Zallow_redirectsz$Rerun proxy: puget not reachable at r
  r  i  z
text/plainr  >   zcontent-encodingZ
connectionztransfer-encodingz
keep-alivec                    s$   g | ]\}}|   vr||fqS r   r  r  Zexcludedr   r   r   K  s    z!_stream_proxy.<locals>.<listcomp>i    )Z
chunk_size)r   r   )requestsr   r   r   r   r?   
exceptionsZRequestExceptionr	   r  Ziter_contentZstatus_code)r  r  r   r   ZupstreamrZ   Zresp_headersr   r  r   _stream_proxy7  s0   

r  z/rerun_pugetr   )r4   r:   r   )defaultsr5   z/rerun_puget/z/rerun_puget/<path:path>c                 C   6   dt  dt d|  }tjr|dtj  7 }t|S )uM   Proxy the rerun web viewer SPA from puget:9092 → omidlab.net/rerun_puget/*.http://r  r   ?)_RERUN_PUGET_HOST_RERUN_VIEWER_PORTr   query_stringr  r  r   rD  r   r   r   rerun_viewer_proxyT  s   r'  z/rerun_proxyz/rerun_proxy/z/rerun_proxy/<path:path>c                 C   r   )u   Proxy the rerun gRPC-Web data stream from puget:9093 → omidlab.net/rerun_proxy/*.

    The rerun viewer SPA reads the ?url=rerun+<here>/ query param at load time
    and connects to this endpoint for the data stream.
    r!  r  r   r"  r#  _RERUN_STREAM_PORTr   r%  r  r  r&  r   r   r   rerun_grpc_proxy_  s   	r*  /proxyz/proxy/z/proxy/<path:path>c                 C   sD   dt  dt d}| r|d|  7 }tjr|dtj  7 }t|S )Nr!  r  r+  r   r"  r(  r&  r   r   r   rerun_grpc_proxy_rootr  s   r,  __main__r/  i  )r   defaultz--public
store_truezCreate public URL via tunnel)actionhelpz--tunnelr1  )r.  choices)rD  r\   r  z$Serving reports at http://localhost:z0.0.0.0F)r  r.  debug)r   r  )r  )r   )r-  )__doc__argparserA  rQ  r  rW  r   r/   r   r   r   r   r   r   r	   r
   r   r  r   r%  r   __file__r2  r   r   r  r  Zafter_requestr   Zbefore_requestr   Zrouter    r$   r'   r+   r1   r7   r2   r3   r9   r@   r[   r]   rb   rs   r   r   r   r   r   r   r   r   
__import__compiler   r   r   r   r   r   Z
wandb_runsr  r  r  r  r  r  r  r  r  r  r  r  r  r  r   r&  r(  r)  r,  r5  rF  rL  rP  rV  rY  r^  r`  rj  r  r  r  r  r  r  r@  r*  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r  r*  r  r(  r  r  r  mkdirr)  r   r  r  boolr  r  r  r  rT   r  r  r  r  r	  r  rN   r  r,  r8  r<  r~  r:  r?  rB  rF  rA  r  r  r  r  r  r  r  r#  r$  r)  r  r'  r*  r,  ArgumentParserparseradd_argument
parse_argsr\   Zpublicr  r.  r2  Ztunnel_threadr   r3  rB  r   r   r   r   <module>   s6   
,










#
'


O
q



Q
=	



:








		


3


   ]


 m	
	



	
	


! 
 $ A





 
3




  < $
    	