Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions scripts/init-db.js
Original file line number Diff line number Diff line change
Expand Up @@ -2684,6 +2684,37 @@ await addColumnIfMissing("bot_sessions", "cwd", "TEXT");
// SCHEMA_GENERATION bump.
await addColumnIfMissing("bot_sessions", "archived_at", "TEXT");

// perch_session_files (PR-E, audit item 12): durable history of files the
// agent SENT to a perch chat via send_user_file's crow-file: relay. One row
// per announce; the chat replays these into the transcript on reload (the
// pi-session transcript endpoint carries messages, not tool calls, so the
// card needs its own rail). Keyed on (bot_id, thread_id) exactly like the
// transcript endpoint — NOT on bot_sessions.id, which a re-INSERT can move.
// `stored` is the basename inside the session's outputsDir (what the fd-based
// workspace route serves), NULL when servable=0 (source refused/too large/
// copy failed — the reload then renders the same name-only dim card as the
// live frame). A SEPARATE table, deliberately: no bot_sessions column, so
// the canonical-COLUMN control-CHECK rebuild block below is untouched. A
// new table rides BOTH rails (init-db here + scripts/migrations/0007) —
// CREATE IF NOT EXISTS makes them converge idempotently on every instance.
await initTable("perch_session_files table", `
CREATE TABLE IF NOT EXISTS perch_session_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bot_id TEXT NOT NULL,
thread_id TEXT NOT NULL,
name TEXT NOT NULL,
stored TEXT,
mime TEXT NOT NULL DEFAULT 'application/octet-stream',
size INTEGER NOT NULL DEFAULT 0,
caption TEXT NOT NULL DEFAULT '',
servable INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_perch_session_files_thread
ON perch_session_files (bot_id, thread_id);
`);

// bot_sessions.control CHECK widen, 'run'/'stop' -> 'run'/'stop'/'interrupted'
// (Track 3 Task 7): stopAll() parks a session that was genuinely mid-turn
// when the gateway shut down with control='interrupted' instead of 'run', so
Expand Down
48 changes: 48 additions & 0 deletions scripts/migrations/0007-perch-session-files.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// scripts/migrations/0007-perch-session-files.mjs
//
// perch_session_files (Perch PR-E, audit item 12): the durable card-history
// rail for files an agent sends to a perch chat via send_user_file's
// crow-file: relay. A NEW TABLE carries no SCHEMA_GENERATION bump by design
// (a bump re-runs every DROP/CREATE against live DBs — this rail has no
// reason to ride it), which is exactly the 2026-09-12 convergence gap 0005
// and 0006 exist for: a co-hosted instance on a shared checkout converges
// onto new code on its OWN restart, its boot guard skips init-db (generation
// matches), and code that INSERTs into a table nobody created 500s. This rail
// creates the table independently, byte-identical to init-db.js's own CREATE
// body (one shape, two rails), so either rail running first wins and both
// are idempotent.
//
// Idempotent, never destructive: CREATE TABLE/INDEX IF NOT EXISTS only.
import Database from "better-sqlite3";

export const id = "0007-perch-session-files";

const DDL = [
`CREATE TABLE IF NOT EXISTS perch_session_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bot_id TEXT NOT NULL,
thread_id TEXT NOT NULL,
name TEXT NOT NULL,
stored TEXT,
mime TEXT NOT NULL DEFAULT 'application/octet-stream',
size INTEGER NOT NULL DEFAULT 0,
caption TEXT NOT NULL DEFAULT '',
servable INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_perch_session_files_thread
ON perch_session_files (bot_id, thread_id)`,
];

export function run({ dbPath, log = () => {} }) {
const db = new Database(dbPath);
db.pragma("busy_timeout = 10000");
try {
const had = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='perch_session_files'").get();
for (const sql of DDL) db.prepare(sql).run();
log(` perch_session_files: ${had ? "no-op" : "created"}`);
} finally {
db.close();
}
return { applied: true, results: ["table"] };
}
7 changes: 6 additions & 1 deletion scripts/pi-bots/bridge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,13 @@ export class PiRpc {
// spawns — so every channel caller's csv stays byte-identical, and the
// per-session narrowing below can still take it away (envelope model:
// narrowing only ever removes).
// PR-E (audit item 12) rides the SAME gate: pi-lab's send-user-file
// extension registers send_user_file only under askUserPath "ui"
// (PERMISSION_POLICY + INTERACTIVE), so appending it exactly where
// ask_user is appended keeps every channel bot's csv byte-identical and
// lets per-session narrowing below still take it away.
if (opts.extraEnv && opts.extraEnv.PI_BOT_INTERACTIVE === "1") {
tools = [tools, "ask_user"].filter(Boolean).join(",");
tools = [tools, "ask_user", "send_user_file"].filter(Boolean).join(",");
}
// C-6: per-session narrowing is applied to the FINAL csv — AFTER the
// subagent append — so a session can narrow `subagent` away too. Narrowing
Expand Down
104 changes: 96 additions & 8 deletions servers/gateway/dashboard/perch-hub/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,9 @@ export function perchHubJs(lang = "en") {
var TOOL_DONE='${tJs("perch.toolDone", lang)}';
var TOOL_FAILED='${tJs("perch.toolFailed", lang)}';
var COMMANDS_HIBERNATING='${tJs("perch.commandsHibernating", lang)}';
/* PR-E (item 12): the chat's inline sent-file cards. */
var FILE_DOWNLOAD='${tJs("perch.fileDownload", lang)}';
var FILE_NOT_SERVABLE='${tJs("perch.fileNotServable", lang)}';

/* Row identity. One bot with eight sessions renders eight rows that read
"R4 Assistant / awake" and nothing else — measured verbatim in a browser
Expand Down Expand Up @@ -1089,6 +1092,7 @@ export function perchHubJs(lang = "en") {
clearEl(el('perch-transcript')); clearEl(el('perch-ask'));
clearEl(el('perch-activity-list')); /* the previous session's log is not this one's */
toolChips={}; /* Wave 3: the chip index dies with the transcript */
fileSeen={}; /* PR-E: same seam for the card dedupe */
resetControls(); /* the PREVIOUS session's picker must not bleed in */
var known=rowIndex[sid];
if(known){ showHeader(known.botId,known.botName); afterHeader(mySid,known.botId); return; }
Expand Down Expand Up @@ -1305,6 +1309,14 @@ export function perchHubJs(lang = "en") {
}
setTurnInFlight(false);
});
on('file',function(d){
if(!d||!d.name) return;
if(!histSettled){ histBuf.push(d); return; } /* round 3 R4 seam applies to cards too */
var k=fileKey(d);
if(fileSeen[k]) return; /* batch replay already drew it */
fileSeen[k]=true;
renderFileCard(d);
});
on('ask_user',function(d){ renderAsk(d); });
on('error',function(d){ appendActivity(d.text||'error'); });
on('plan_state',function(d){
Expand Down Expand Up @@ -1436,23 +1448,32 @@ export function perchHubJs(lang = "en") {
if(!sid||!botId) return;
clearEl(el('perch-transcript'));
toolChips={}; /* the chips just died with the transcript; a stale index would write into detached DOM */
fileSeen={};
histSettled=false; histBuf=[];
loadHistory(botId,sid);
}

function loadHistory(botId,sid){
var mySid=sid;
perchApi('GET','/bots/'+encodeURIComponent(botId)+'/sessions/'+encodeURIComponent(sid)+'/transcript')
.then(function(r){
if(current.sid!==mySid) return; /* identity guard, as everywhere */
/* PR-E: the transcript (messages) and the sent-file card history arrive in
ONE join — the file cards flush only after BOTH land, so a slow history
fetch can never interleave cards out of order with batch messages. */
var histP=perchApi('GET','/interactive/'+encodeURIComponent(sid)+'/files/history')
.then(function(h){ return (h.ok&&h.j&&Array.isArray(h.j.items))?h.j.items:[]; });
Promise.all([
perchApi('GET','/bots/'+encodeURIComponent(botId)+'/sessions/'+encodeURIComponent(sid)+'/transcript'),
histP,
]).then(function(pair){
var r=pair[0];
if(current.sid!==mySid) return; /* identity guard, as everywhere */
/* A FAILED FETCH IS NOT AN EMPTY TRANSCRIPT. The old
\`(r.ok&&r.j&&r.j.events)||[]\` collapsed a 500, a dropped tunnel and
a logged-out session into the same "No transcript yet." — a
reassuring sentence about a conversation that is still there. Say
which happened. */
if(!r.ok||!r.j){ appendNote(TRANSCRIPT_FAILED); flushHistBuf([]); return; }
if(!r.ok||!r.j){ appendNote(TRANSCRIPT_FAILED); flushHistBuf([], pair[1]); return; }
var events=r.j.events||[];
if(!events.length){ appendNote(NO_TRANSCRIPT); flushHistBuf([]); return; }
if(!events.length){ appendNote(NO_TRANSCRIPT); flushHistBuf([], pair[1]); return; }
var batchTexts=[];
events.filter(function(e){ return e&&e.type==='message'; }).forEach(function(e){
var m=e.message||{};
Expand All @@ -1464,7 +1485,7 @@ export function perchHubJs(lang = "en") {
if(String(m.role||'?')!=='user') batchTexts.push(txt);
appendMessage(String(m.role||'?')==='user'?'user':'bot', String(m.role||'?'), txt, e.html);
});
flushHistBuf(batchTexts);
flushHistBuf(batchTexts, pair[1]);
});
}

Expand All @@ -1483,14 +1504,39 @@ export function perchHubJs(lang = "en") {
not in the batch, and delaying them delays the composer. */
var histSettled=false;
var histBuf=[];
/* PR-E: name+stored keys of file cards already rendered THIS transcript —
dies with the transcript everywhere toolChips does (same seam). */
var fileSeen={};
function renderTextFrame(d){
appendMessage('bot','bot',d.text,d.html);
if(d.turnId) renderedTurn=d.turnId; else turnRendered=true;
}
function flushHistBuf(batchTexts){
/* PR-E: a card's identity across the live/replay seam. name+stored, because
the same original name can be sent twice (the jail stores report.png and
report-2.png) and both are DISTINCT cards. String()ed explicitly — an
earlier version of this used a bare pipe (x|''), a BITWISE or that
collapsed every key to "0|0" and silently dropped the second card of any
session. */
function fileKey(d){ return String((d&&d.name)||'')+'|'+String((d&&d.stored)||''); }
function flushHistBuf(batchTexts, sentFiles){
histSettled=true;
/* PR-E: persisted sent-file cards replay first (they are older context —
the live buffer below can only hold TODAY's frames), deduped by
name+stored against buffered frames so a file sent between subscribe and
this batch lands exactly once. */
var files=sentFiles||[];
files.forEach(function(it){
if(!it||!it.name) return;
fileSeen[fileKey(it)]=true;
renderFileCard(it);
});
var buf=histBuf; histBuf=[];
buf.forEach(function(f){
if(f.type==='file'){
if(fileSeen[fileKey(f)]) return;
renderFileCard(f);
return;
}
if(f.reply){
var already=f.turnId?(renderedTurn===f.turnId):turnRendered;
if(!already&&f.text&&batchTexts.indexOf(f.text)<0) appendMessage('bot','bot',f.text,f.html);
Expand Down Expand Up @@ -1631,7 +1677,7 @@ export function perchHubJs(lang = "en") {
setAttn(false); /* nor its unanswered-question banner */
/* Wave 2/3: the new session inherits none of the old one's readings. */
planState=null; renderPlan();
toolChips={}; commandsCache=null; hideCmdMenu();
toolChips={}; fileSeen={}; commandsCache=null; hideCmdMenu();
resetFacts();
}

Expand Down Expand Up @@ -2423,6 +2469,48 @@ export function perchHubJs(lang = "en") {
if(d&&d.toolCallId!=null) delete toolChips[d.toolCallId];
}

/* ---- PR-E (item 12): inline sent-file cards -----------------------------
pi-lab's shape: an image renders inline, anything else is a download card
with name/caption/size; a file that could not be jail-copied renders
name-only with a dim "not servable" note. Servable rows link the EXISTING
fd-based workspace route — this adds no new serving path, and that jail's
O_NOFOLLOW discipline is what makes each download safe, not this markup.
createElement/textContent + encodeURIComponent only: names and captions
are child-controlled bytes and never reach a markup sink. Rides live
frames AND the /files/history reload batch (deduped by fileSeen). */
function renderFileCard(d){
var tr=el('perch-transcript'); if(!tr) return;
var name=String(d.name||'');
var servable=!!d.servable && !!d.stored;
// stored is normally a basename; the in-jail case can carry a nested
// relative path, so encode SEGMENT-wise (a blanket encodeURIComponent
// would turn its '/' into %2F).
var segs=String(d.stored).split('/').map(encodeURIComponent).join('/');
var url=servable?API+'/interactive/'+encodeURIComponent(current.sid)+'/workspace/'+segs:'';
var wrap=document.createElement('div'); wrap.className='entry filecard'+(servable?'':' dim');
var isImg=servable && String(d.mime||'').indexOf('image/')===0;
if(isImg){
var img=document.createElement('img'); img.className='file-img'; img.src=url; img.alt=name;
img.loading='lazy';
wrap.appendChild(img);
}
wrap.appendChild(line('file-title',name));
if(d.caption) wrap.appendChild(line('file-cap',String(d.caption)));
var meta=fmtSize(d.size);
if(servable){
wrap.appendChild(line('file-meta',meta));
var a=document.createElement('a'); a.className='file-dl'; a.href=url;
a.setAttribute('download',name);
a.textContent=FILE_DOWNLOAD;
wrap.appendChild(a);
}else{
wrap.appendChild(line('file-meta',meta));
wrap.appendChild(line('file-note',FILE_NOT_SERVABLE));
}
tr.appendChild(wrap);
tr.scrollTop=tr.scrollHeight;
}

/* ---- Wave 3: the slash-command menu ------------------------------------
Fed by pi's OWN get_commands registry through the engine (never a
hardcoded list — a bot's commands depend on its loaded extensions and
Expand Down
13 changes: 13 additions & 0 deletions servers/gateway/dashboard/perch-hub/css.js
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,19 @@ text-transform:uppercase;letter-spacing:.07em;color:var(--dim);margin:6px 0 3px}
#perch-hub-root .tool-details pre{background:var(--sky);padding:7px 9px;border-radius:6px;
overflow-x:auto;overflow-y:auto;max-height:240px;margin:0;white-space:pre-wrap;word-break:break-word;
font:11px/1.5 "JetBrains Mono",ui-monospace,monospace}
/* PR-E (audit item 12): inline sent-file cards in the chat. Column box on the
.entry row (which is itself a flex row — flex-direction:column + width:100%
own the line). Images inline capped to the card width; a not-jail-served
file renders dim with a name and the honest note, never a dead link. */
#perch-hub-root .filecard{flex-direction:column;gap:3px;align-self:stretch;width:100%;min-width:0;
background:var(--card);border:1px solid var(--line);border-radius:10px;padding:9px 11px;margin:2px 0}
#perch-hub-root .filecard.dim{opacity:.6}
#perch-hub-root .filecard .file-title{font-weight:600;font-size:13.5px;word-break:break-all;color:var(--ink)}
#perch-hub-root .filecard .file-cap{font-size:13px;color:var(--ink);white-space:pre-wrap;word-break:break-word}
#perch-hub-root .filecard .file-meta{white-space:normal}
#perch-hub-root .filecard .file-note{font-size:12px;color:var(--dim)}
#perch-hub-root .filecard .file-dl{display:inline-block;margin-top:4px;color:var(--teal);font-size:13px;text-decoration:none}
#perch-hub-root .filecard .file-img{max-width:100%;height:auto;border-radius:6px;margin-bottom:4px}
/* The menu anchors to #perch-composer (position:sticky = its containing
block) and grows UPWARD — bottom:100% — so it can never cover Send. */
#perch-cmdmenu{position:absolute;bottom:100%;left:0;right:0;margin-bottom:6px;background:var(--card);
Expand Down
5 changes: 5 additions & 0 deletions servers/gateway/dashboard/shared/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -2359,6 +2359,11 @@ export const translations = {
en: "Could not list the files.",
es: "No se pudo listar los archivos.",
},
"perch.fileDownload": { en: "Download", es: "Descargar" },
"perch.fileNotServable": {
en: "This file lives outside the session's outputs folder — the dashboard cannot serve it. Ask the bot to save it there.",
es: "Este archivo vive fuera de la carpeta de resultados de la sesión — el panel no puede servirlo. Pídele al bot que lo guarde allí.",
},
"perch.filesCwdHeading": { en: "Working directory", es: "Directorio de trabajo" },
"perch.filesOutputsHeading": { en: "Outputs", es: "Resultados" },
"perch.filesViewerClose": { en: "Close", es: "Cerrar" },
Expand Down
Loading
Loading