diff --git a/mcp-server/index.ts b/mcp-server/index.ts index d7ca82d..71ec866 100644 --- a/mcp-server/index.ts +++ b/mcp-server/index.ts @@ -22,6 +22,58 @@ import { buildPermitBrief } from "./entity.js"; const BASE = process.env.LAKEHOUSE_URL || "http://localhost:3100"; const PORT = parseInt(process.env.MCP_PORT || "3700"); + +// ─── Staffer roster — used by the per-staffer hot-swap index (G). ──── +// +// J's vision: each staffer has their own molded view of the corpus. +// When Maria searches, the system surfaces *Maria's* prior fills and +// her territory's playbooks first. When Aisha searches, the same +// corpus gets re-shaped to her geo and recent activity. This is what +// generic CRM fast-search can't do: a relevance gradient that +// compounds with each staffer's own signal. +// +// First implementation is geography-based — each staffer has a primary +// state and a list of cities they recruit for. Playbook queries get +// scoped to that territory when staffer_id is provided. As the system +// accumulates per-staffer signal (call_log assignments, email threads, +// SMS history), the scope expands beyond geography. +// +// Adding a staffer: append to this list. The /api/staffers endpoint +// exposes the public-safe fields to the UI dropdown. +const STAFFERS: Array<{ + id: string; + name: string; + display: string; + territory: { state: string; cities: string[] }; + greeting: string; +}> = [ + { + id: "maria", + name: "Maria", + display: "Maria · Chicago coordinator", + territory: { state: "IL", cities: ["Chicago", "Joliet", "Rockford", "Peoria", "Springfield", "Decatur"] }, + greeting: "Maria's territory: Illinois warehouse + manufacturing fills", + }, + { + id: "devon", + name: "Devon", + display: "Devon · Indiana coordinator", + territory: { state: "IN", cities: ["Indianapolis", "Fort Wayne", "South Bend", "Evansville", "Bloomington", "Terre Haute"] }, + greeting: "Devon's territory: Indiana production + assembly fills", + }, + { + id: "aisha", + name: "Aisha", + display: "Aisha · Wisconsin/Michigan coordinator", + territory: { state: "WI", cities: ["Milwaukee", "Madison", "Green Bay", "Detroit", "Grand Rapids", "Lansing"] }, + greeting: "Aisha's territory: Wisconsin + Michigan logistics", + }, +]; + +function lookupStaffer(id: string | undefined): typeof STAFFERS[number] | null { + if (!id) return null; + return STAFFERS.find((s) => s.id === id) || null; +} const MODE = process.env.MCP_TRANSPORT || "http"; // "stdio" or "http" // Active trace for the current request — set per-request in the HTTP handler @@ -824,6 +876,20 @@ async function main() { } } + // Staffer roster — read by the UI dropdown so each coordinator + // can act under their own identity (per-staffer hot-swap index). + if (url.pathname === "/api/staffers" || url.pathname === "/staffers") { + return ok({ + staffers: STAFFERS.map((s) => ({ + id: s.id, + name: s.name, + display: s.display, + territory: s.territory, + greeting: s.greeting, + })), + }); + } + if (url.pathname === "/system/summary") { const [ds, indexes, workersCount, candsCount] = await Promise.all([ api("GET", "/catalog/datasets").catch(() => [] as any), @@ -1862,6 +1928,21 @@ async function main() { const explicitState = String(b.state || "").trim().toUpperCase(); const explicitRole = String(b.role || "").trim(); + // (G) Per-staffer context. When the UI sends a staffer_id, + // playbook queries scope to that staffer's territory — their + // recent fills, their geo's recurring patterns. The corpus is + // the same for everyone; the relevance gradient is unique to + // each staffer because each pulls a different shape from it. + const staffer = lookupStaffer(String(b.staffer_id || "").trim()); + // If the staffer has a territory and the user hasn't already + // pinned a state/city via dropdown or NL, default the search + // to their territory. They can override by typing a different + // city or selecting a different state. + if (staffer && !explicitState) { + filters.push(`state = '${staffer.territory.state}'`); + understood.push(`as ${staffer.name}: ${staffer.territory.state}`); + } + // (B) Headcount parser — coordinator says "8 production // workers", "I need 12 forklift operators", "5 welders by // Friday". Match a leading or embedded count followed by @@ -2048,9 +2129,14 @@ async function main() { // Derive role+geo for the pattern query so the meta-index // surface lines up with what the user actually asked for. + // (G) When a staffer is acting, default the geo to their + // primary territory — their playbook view is shaped by + // where they actually fill, not the global Chicago/IL prior. const roleForPatterns = understood.find(u => u.startsWith('role:'))?.split(': ')[1] || q; - const cityForPatterns = understood.find(u => u.startsWith('city:'))?.split(': ')[1] || 'Chicago'; - const stateForPatterns = understood.find(u => u.startsWith('state:'))?.split(': ')[1] || 'IL'; + const cityForPatterns = understood.find(u => u.startsWith('city:'))?.split(': ')[1] + || staffer?.territory.cities[0] || 'Chicago'; + const stateForPatterns = understood.find(u => u.startsWith('state:'))?.split(': ')[1] + || staffer?.territory.state || 'IL'; const [searchR, directR, patternR] = await Promise.all([ api("POST", "/vectors/hybrid", { @@ -2084,6 +2170,7 @@ async function main() { return ok({ type: "smart_search", summary: `Found ${searchR.sql_matches || 0} workers matching your criteria${understood.length ? ' (' + understood.join(', ') + ')' : ''}`, + staffer: staffer ? { id: staffer.id, name: staffer.name, display: staffer.display, territory: staffer.territory } : null, understood, sql_results: sqlWorkers, vector_results: vectorWorkers, diff --git a/mcp-server/search.html b/mcp-server/search.html index d507f36..28edaa9 100644 --- a/mcp-server/search.html +++ b/mcp-server/search.html @@ -302,11 +302,18 @@ body{font-family:'Inter',-apple-system,system-ui,'Segoe UI',sans-serif;backgroun

-
+
+ +
+
@@ -363,7 +370,38 @@ var P=location.pathname.indexOf('/lakehouse')>=0?'/lakehouse':''; var A=location.origin+P; var AC=['#1a2744','#1a3a2a','#2a1a3a','#3a2a1a','#1a3a3a','#2a2a1a']; var lastQuery=''; -window.addEventListener('load',function(){loadSystemSummary();loadLegacyBridge();loadDay();loadStaffingForecast();loadLiveContracts();loadMarket();loadLearning();loadWorkerSearchSamples();loadArchSignals()}); +window.addEventListener('load',function(){loadSystemSummary();loadLegacyBridge();loadDay();loadStaffingForecast();loadLiveContracts();loadMarket();loadLearning();loadWorkerSearchSamples();loadArchSignals();loadStaffers()}); + +// Per-staffer hot-swap dropdown — runs independently of the simulation +// fetch so the staffer selector populates even if any other init step +// errors out. /api/staffers returns the synthetic coordinator roster. +function loadStaffers(){ + var sel=document.getElementById('sstaffer'); + var greeting=document.getElementById('sstaffer-greeting'); + if(!sel) return; + // /staffers (not /api/staffers) — the /api/* generic passthrough + // forwards anything under /api/ to the Rust gateway on :3100 and the + // gateway doesn't know the staffer roster (it lives in the mcp-server + // module). The bare /staffers route serves directly. + fetch(A+'/staffers').then(function(r){return r.json()}).then(function(d){ + (d.staffers||[]).forEach(function(s){ + var o=document.createElement('option');o.value=s.id;o.textContent=s.display||s.name; + sel.appendChild(o); + }); + sel._roster=d.staffers||[]; + }).catch(function(){}); + sel.addEventListener('change',function(){ + var roster=sel._roster||[]; + var s=roster.find(function(x){return x.id===sel.value}); + if(s){ + greeting.textContent='Acting as '+s.name+' — '+(s.greeting||'')+' · territory: '+s.territory.cities.slice(0,3).join(', ')+'…'; + var stSel=document.getElementById('sst'); + if(stSel && !stSel.value){stSel.value=s.territory.state} + }else{ + greeting.textContent=''; + } + }); +} // Deep-link: visiting the dashboard with #open-briefs in the URL auto- // expands every Entity Brief panel once the contract cards finish @@ -2417,15 +2455,17 @@ function doSearch(){ var q=document.getElementById('sq').value.trim();if(!q)return; lastQuery=q; var st=document.getElementById('sst').value,rl=document.getElementById('srl').value; + var stafferEl=document.getElementById('sstaffer'); + var stafferId=stafferEl?stafferEl.value:''; // Pass dropdown filters as structured fields. Old code appended // ' in '+st to the message, which the server misparsed: the // preposition "in" matched the regex for state code "IN" (Indiana) // and every search returned Indiana workers regardless of dropdown. - // Sending structured state/role lets the server skip NL parsing - // for those fields entirely. + // Sending structured state/role + staffer_id lets the server skip + // NL parsing for those fields and apply per-staffer scoping. var out=document.getElementById('sresults');out.textContent='Finding the best matches...'; fetch(A+'/intelligence/chat',{method:'POST',headers:{'Content-Type':'application/json'}, - body:JSON.stringify({message:q,state:st||undefined,role:rl||undefined}) + body:JSON.stringify({message:q,state:st||undefined,role:rl||undefined,staffer_id:stafferId||undefined}) }).then(function(r){return r.json()}).then(function(d){ out.textContent=''; // Type-specific renderers — added 2026-04-27 for the persona-driven @@ -2457,7 +2497,11 @@ function doSearch(){ var mem=document.createElement('div'); mem.style.cssText='background:#0d2818;border:1px solid #2ea04360;border-radius:6px;padding:8px 12px;margin-bottom:10px;font-size:11px;color:#86efac;line-height:1.5'; var label=document.createElement('span');label.style.cssText='color:#3fb950;font-weight:600;margin-right:6px'; - label.textContent='MEMORY ('+(d.pattern_playbooks_matched||0)+' playbook'+(d.pattern_playbooks_matched===1?'':'s')+'):'; + // When a staffer is acting, label the panel with their name — + // "MARIA'S MEMORY (12 playbooks)" makes the per-user shaping + // visible in the UI, not just the response data. + var memOwner=d.staffer&&d.staffer.name?d.staffer.name.toUpperCase()+"'S MEMORY":'MEMORY'; + label.textContent=memOwner+' ('+(d.pattern_playbooks_matched||0)+' playbook'+(d.pattern_playbooks_matched===1?'':'s')+'):'; mem.appendChild(label); var pattern = d.discovered_pattern || ''; if(!pattern || pattern.indexOf('No similar')>=0 || pattern.indexOf('0 workers')>=0){