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
66 changes: 66 additions & 0 deletions src/heartbeat.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/** Decorative street life. Only explicit, newly observed PR outcomes produce carts. */
import { parsePrUrl } from './pr.mjs';
const TTL = 20, MAX_SEEN = 256, MAX_CARTS = 16;
const town = t => t?.raw?.town || t?.town || '';
const active = t => t?.status === 'working' && !t.quiet && !t.sourceStale;
const clock = () => globalThis.performance.now() / 1000;
function identity(event) {
const parsed = parsePrUrl(event.url);
const key = parsed ? `${parsed.host}/${parsed.repo.toLowerCase()}#${parsed.number}` : String(event.prKey || '').toLowerCase();
const match = key.match(/^([\w.-]+)\/([\w.-]+)\/([\w.-]+)#([1-9]\d*)$/);
return match ? { key, number: Number(match[4]) } : null;
}
function rect(ctx, color, x, y, w, h) { ctx.fillStyle = color; ctx.fillRect(Math.round(x), Math.round(y), w, h); }
export function createHeartbeat() {
let sourceKey, shipments = [];
const seen = new Map();
function reset() { seen.clear(); shipments = []; sourceKey = undefined; }
function prune(now) { shipments = shipments.filter(s => now - s.started < TTL); }
function observe(events = [], options = {}) {
const now = Number.isFinite(options.now) ? options.now : clock();
if (options.sourceChanged || (sourceKey !== undefined && sourceKey !== options.sourceKey)) { reset(); }
sourceKey = options.sourceKey; prune(now);
for (const event of events) {
if (event?.kind !== 'pr') continue;
const outcome = /^PR · (merged|closed)$/.exec(event.text || '')?.[1], pr = identity(event);
if (!outcome || !pr || seen.has(pr.key)) continue;
seen.set(pr.key, now);
while (seen.size > MAX_SEEN) seen.delete(seen.keys().next().value);
shipments.push({ ...pr, outcome, town: event.town || '', agentIds: [...new Set([event.agentId, ...(event.agentIds || [])].filter(Boolean))], started: now });
if (shipments.length > MAX_CARTS) shipments.shift();
}
}
function drawGround(ctx, { plots = [], tasks = [], time = 0, reduce = false, stale = false, townFilter = '' } = {}) {
ctx.save();
for (const p of plots) {
const residents = [p.agent, ...(p.agent?.roommates || []), ...(p.kids || []).map(k=>k.agent)].filter(Boolean);
const members = tasks.filter(t=>residents.some(a=>a.id===t.id));
if (townFilter && town(p.agent) !== townFilter) continue;
const y = p.y + 67;
if (members.some(t=>t.attention)) {
rect(ctx,'#584c44',p.x+5,y-9,2,10); rect(ctx,'#8d7351',p.x+3,y-10,6,5); rect(ctx,'#ffd98b',p.x+4,y-9,4,3);
}
if (!members.some(active)) continue;
const phase = reduce || stale ? 0 : Math.floor(time * 5) % 12;
for (let i=0;i<3;i++) rect(ctx, stale?'#7b8270':i===1?'#d8bb76':'#8da76c', p.x+11+i*12+phase,y,4,1);
}
ctx.restore();
}
function drawLife(ctx, { districts = [], plots = [], tasks = [], time = 0, reduce = false, stale = false, townFilter = '' } = {}) {
prune(time); ctx.save();
for (const s of shipments) {
const p = plots.find(p=>[p.agent,...(p.agent?.roommates||[]),...(p.kids||[]).map(k=>k.agent)].some(a=>a&&(s.agentIds.includes(a.id)||(a.prKey&&a.prKey===s.key))));
if (!p || (townFilter && town(p.agent)!==townFilter)) continue;
// The whole route is on this cottage's existing front lane.
const progress = reduce || stale ? .5 : Math.max(0,Math.min(1,(time-s.started)/TTL));
const routeProgress = s.outcome === 'closed' ? 1-progress : progress;
const x = p.x+8+routeProgress*30, y=p.y+67;
rect(ctx,'#4b4546',x+2,y+4,3,3); rect(ctx,'#4b4546',x+12,y+4,3,3);
rect(ctx,'#a28a63',x,y,17,4); rect(ctx,'#6d5a49',x+17,y+1,5,1);
if(s.outcome==='merged') { rect(ctx,'#c6a77a',x+4,y-7,9,7); rect(ctx,'#f0d29a',x+8,y-7,1,7); }
if (ctx.fillText) { ctx.fillStyle='#f1dfb0'; ctx.font='7px monospace'; ctx.textAlign='center'; ctx.fillText(`#${s.number}`,Math.round(x+8),y-10); }
}
ctx.restore();
}
return { observe, reset, drawGround, drawLife, get state() { return { sourceKey, seenCount:seen.size, shipments:shipments.map(s=>({...s,agentIds:[...s.agentIds]})) }; } };
}
65 changes: 65 additions & 0 deletions src/parent-paths.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {districtRoute} from './town-layout.mjs';

const compact = points => points.filter((p,i) => !i || p.x !== points[i-1].x || p.y !== points[i-1].y)
.filter((p,i,all) => !i || i === all.length-1 ||
!(all[i-1].x === p.x && p.x === all[i+1].x || all[i-1].y === p.y && p.y === all[i+1].y));

/** Paths describe recorded family links, never proximity or matching branches.
* Yard turns go around the sign/workbench; longer links reuse the actual streets.
*/
export function parentPaths(plots, world, {houseHeight = 66, laneOffset = 5} = {}) {
const endpoints = new Map(), blocks = new Map(world.blocks.map(b => [b.id,b]));
const streetRoutes = new Map();
for (const plot of plots) {
for (const agent of [plot.agent,...(plot.agent.roommates || [])]) {
endpoints.set(agent.id,{agent,plot,door:{x:plot.x+27,y:plot.y+houseHeight-2}});
}
for (const kid of plot.kids || []) {
endpoints.set(kid.agent.id,{agent:kid.agent,plot,shed:true,door:{x:kid.x+11,y:kid.y+15}});
}
}
const laneY = endpoint => endpoint.plot.y+houseHeight+laneOffset;
// A shed opens south. Its apron joins the lane around the yard's west edge.
const toLane = endpoint => endpoint.shed
? [endpoint.door,{x:endpoint.door.x,y:endpoint.door.y+2},
{x:endpoint.plot.x-4,y:endpoint.door.y+2},
{x:endpoint.plot.x-4,y:laneY(endpoint)}]
: [endpoint.door,{x:endpoint.door.x,y:laneY(endpoint)}];
const toStreet = (endpoint,block) => [...toLane(endpoint),
{x:block.x+7,y:laneY(endpoint)},
{x:block.x+7,y:block.y+block.h+(world.road || 48)/2},
{x:block.x+block.w/2,y:block.y+block.h+(world.road || 48)/2}];
const paths = [];
for (const child of endpoints.values()) {
const parent = endpoints.get(child.agent.parent);
if (!parent || parent.agent.id === child.agent.id) continue;
// Roommates use one doorway; a line inside their shared house says nothing.
if (parent.plot === child.plot && !parent.shed && !child.shed) continue;
let points;
if (parent.plot === child.plot && parent.shed && child.shed && parent.door.y === child.door.y) {
points = [parent.door,{x:parent.door.x,y:parent.door.y+2},
{x:child.door.x,y:child.door.y+2},child.door];
} else if (parent.plot.blockId === child.plot.blockId) {
const from = toLane(parent), to = toLane(child);
const a = from.at(-1), b = to.at(-1);
if (a.y === b.y) points = [...from,...to.reverse()];
else {
const block = blocks.get(parent.plot.blockId);
if (!block) continue;
points = [...from,{x:block.x+7,y:a.y},{x:block.x+7,y:b.y},...to.reverse()];
}
} else {
const from = blocks.get(parent.plot.blockId), to = blocks.get(child.plot.blockId);
if (!from || !to) continue;
const key = from.id+'\0'+to.id;
if (!streetRoutes.has(key)) streetRoutes.set(key,districtRoute(world,from,to));
const street = streetRoutes.get(key);
if (!street.length) continue;
points = [...toStreet(parent,from),...street,...toStreet(child,to).reverse()];
}
paths.push({parent:parent.agent,child:child.agent,parentHost:parent.plot.agent.id,
childHost:child.plot.agent.id,kind:child.shed && parent.plot === child.plot ? 'yard' : 'street',
points:compact(points)});
}
return paths;
}
4 changes: 2 additions & 2 deletions src/town-layout.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/** The old two-district width remains the reference for 100% cottage scale. */
export const MAP_REFERENCE_WIDTH = 768;

/** Choose a packing budget once, including space for fullscreen Details. */
/** Choose a packing budget once. Floating Details must not permanently narrow the world. */
export function townWidthBudget({windowWidth, standardWidth}) {
const scale = Math.max(1, standardWidth) / MAP_REFERENCE_WIDTH;
const clearWidth = windowWidth - (windowWidth > 600 ? 364 : 24);
const clearWidth = windowWidth - 24;
return Math.round(Math.max(MAP_REFERENCE_WIDTH, Math.min(1920, clearWidth / scale)));
}

Expand Down
76 changes: 76 additions & 0 deletions src/town-trades.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/** World cues derived from bounded, observed facts. Never a task status authority. */
import {validTime} from './feed-client.mjs';
import {normalizeTown} from './world.mjs';
export const TOOL_WINDOW_MS=120000, ROAD_WINDOW_MS=180000, GROWTH_WINDOW_MS=600000;
const LIMIT=4096, EVENTS=512;
const reads=new Set(['read','read_file','read_files','readfile','glob','grep','rg','search','search_query','search_code','code_search','web_search','websearch','webfetch','read_resource','read_mcp_resource','recall_memory','fetch']);
const toolLeaf=name=>typeof name==='string'?name.split(/__|[./:]/).at(-1).toLowerCase():'';
const active=t=>t.status==='working'&&!t.quiet&&!t.sourceStale;
const resident=t=>t.raw?.inventoryScope==='dashboard'||(t.raw?.inventoryScope!=='history'&&t.raw?.occupancy!=='settled');
const recent=(at,now,window)=>Number.isFinite(at)&&at<=now&&now-at<window;
function remember(map,key,value){map.set(key,value);while(map.size>LIMIT)map.delete(map.keys().next().value);}
function linksOf(tasks,relationships=[],handoffs=[]){
const aliases=new Map(),towns=new Set(tasks.map(t=>t.town)),links=new Map();
for(const t of tasks)for(const id of [t.id,t.raw?.taskId].filter(Boolean)){
const key=String(id);aliases.set(key,aliases.has(key)&&aliases.get(key)?.id!==t.id?null:t);
}
const resolve=id=>typeof id==='string'?aliases.get(id.replace(/^task:/,'')):null;
const endpoint=id=>{const task=resolve(id),town=typeof id==='string'&&!id.startsWith('task:')?normalizeTown(id):null;return task?{id:task.id,town:task.town}:towns.has(town)?{id:null,town}:null;};
function add(id,kind,from,to,stamp){
const a=endpoint(from),b=endpoint(to);if(!a||!b||(a.id===b.id&&a.town===b.town))return;
links.set(id,{id,kind,fromId:a.id,toId:b.id,fromTown:a.town,toTown:b.town,recordedAt:validTime(stamp)});
}
for(const t of tasks)if(t.parent&&resolve(t.parent))add(`parent:${t.parent}:${t.id}`,'parent',t.parent,t.id);
for(const r of Array.isArray(relationships)?relationships:[]){
if(typeof r?.from!=='string'||typeof r?.to!=='string')continue;
const kind=typeof r.kind==='string'?r.kind:'relationship';
add(`relationship:${r.id||`${r.from}:${r.to}:${kind}`}`,kind,r.from,r.to);
}
for(const h of Array.isArray(handoffs)?handoffs:[]){
if(typeof h?.id!=='string'||typeof h?.from!=='string'||typeof h?.to!=='string')continue;
add(`handoff:${h.id}`,'handoff',h.from,h.to,h.timestamp??h.at);
}
return links;
}
export function createTownTrades(){
let sourceKey,initialized=false,arrivals=[],roadEvents=[];
const seenResidents=new Map(),seenLinks=new Map();
function reset(){sourceKey=undefined;initialized=false;arrivals=[];roadEvents=[];seenResidents.clear();seenLinks.clear();}
function prune(now){arrivals=arrivals.filter(e=>recent(e.at,now,GROWTH_WINDOW_MS));roadEvents=roadEvents.filter(e=>recent(e.at,now,ROAD_WINDOW_MS));}
function observeSnapshot({tasks=[],relationships=[],handoffs=[]}={},options={}){
const {now=Date.now(),stale=false}=options;
if(sourceKey!==undefined&&sourceKey!==options.sourceKey)reset();
sourceKey=options.sourceKey;prune(now);if(stale)return;
for(const t of tasks){
if(!seenResidents.has(t.id)&&initialized&&resident(t)&&!t.sourceStale)arrivals.push({taskId:t.id,town:t.town,at:now});
remember(seenResidents,t.id,now);
}
for(const [id,link] of linksOf(tasks,relationships,handoffs)){
if(!seenLinks.has(id)&&initialized&&(link.kind!=='handoff'||recent(link.recordedAt,now,ROAD_WINDOW_MS)))roadEvents.push({...link,at:link.kind==='handoff'?link.recordedAt:now});
remember(seenLinks,id,now);
}
arrivals=arrivals.slice(-EVENTS);roadEvents=roadEvents.slice(-EVENTS);initialized=true;
}
function snapshot({tasks=[],now=Date.now(),stale=false}={}){
prune(now);const visible=tasks.filter(resident),ids=new Set(visible.map(t=>t.id)),towns=new Set(visible.map(t=>t.town));
const miners=[],workshops=[],stories=[];let toolTasks=0,missingToolTasks=0;
for(const t of visible){
const tool=t.raw?.toolActivity,at=validTime(tool?.at),hasTool=tool&&typeof tool.name==='string'&&tool.name.trim().length>0&&['started','succeeded','failed','cancelled'].includes(tool.state)&&at!==null&&at<=now;
if(hasTool)toolTasks++;else missingToolTasks++;
if(stale||!active(t)||!hasTool||tool.stale||!recent(at,now,TOOL_WINDOW_MS)||!['started','succeeded'].includes(tool.state))continue;
const gathering=reads.has(toolLeaf(tool.name)),role=gathering?'miner':'workshop';
(gathering?miners:workshops).push({taskId:t.id,town:t.town,toolName:tool.name,state:'working',at});
stories.push({id:`${role}:${t.id}`,role,taskId:t.id,town:t.town,title:`${gathering?'Gathering material':'Workshop activity'} · ${t.raw?.name||t.title}`,reason:`${tool.name} ${tool.state==='started'?'started':'succeeded'}. ${gathering?'A read or search tool':'Tool use'} was observed within the last two minutes; the task is recorded as working. ${gathering?'':'The tool name alone does not establish what work it performed. '}This does not assert that the tool is still running.`,at});
}
const roads=roadEvents.filter(r=>towns.has(r.toTown)||towns.has(r.fromTown)).filter(r=>(!r.toId||ids.has(r.toId))&&(!r.fromId||ids.has(r.fromId)));
for(const r of roads)stories.push({id:r.id,role:'road',taskId:r.toId||r.fromId||null,town:r.toTown,fromTown:r.fromTown,toTown:r.toTown,fromId:r.fromId,toId:r.toId,title:`Laying a connection · ${r.toTown}`,reason:`${r.kind==='handoff'?'Recorded handoff':'Newly observed '+r.kind+' link'} from ${r.fromTown}${r.fromId?' / '+r.fromId:''} to ${r.toTown}${r.toId?' / '+r.toId:''}. Road work lasts three minutes on existing streets.`,at:r.at});
const gardens=[...towns].map(town=>{
const local=visible.filter(t=>t.town===town),newcomers=arrivals.filter(a=>a.town===town),count=newcomers.length;
const g={town,arrivals:count,activeCount:stale?0:local.filter(active).length,growth:count>=3?'spurt':count?'growing':'settled',taskIds:newcomers.map(a=>a.taskId).filter(id=>ids.has(id))};
stories.push({id:`garden:${town}`,role:'gardener',taskId:g.taskIds[0]||null,town,title:`${count>=3?'Growth spurt':count?'New seedlings':'A settled garden'} · ${town}`,reason:count?`${count} residents first observed here during the last ten minutes. Each seedling marks an arrival observed by this browser, not an inferred task creation. ${count>=3?'The gardener pauses beside a full bench.':'The gardener carries new pots.'}`:'No new residents observed here during the last ten minutes. The first snapshot is a baseline; a calm garden does not mean the tasks are idle.',at:newcomers.at(-1)?.at??null});
return g;
});
return {miners,workshops,roads,gardens,stories,coverage:{toolTasks,missingToolTasks},stale};
}
return {observeSnapshot,snapshot,reset};
}
Loading
Loading