Problem
In server/services/tribeOutreach.js:269-373, findUnansweredTribeThreads() detects unanswered 1:1 messages from Tribe members within the actionable window (withinDays = 14).
To detect unanswered conversations, line 316-337 executes fetchScope for every chat source (imessage, signal) and every two-way email account (gmail, etc.):
const fetchScope = (label, filter) =>
Promise.all([
listEvents({ ...filter, from, kind: 'message.received', limit: EVENT_CAP }),
listEvents({ ...filter, from, kind: 'message.sent', limit: EVENT_CAP }),
])
With EVENT_CAP = 2000, 2 chat sources and 2 active email accounts fire up to 8 queries requesting up to 2,000 rows each (up to 16,000 events total). Each query pulls full rows from human_activity_events (title, summary, participants JSONB, and metadata JSONB).
Then in tribeOutreach.js:348-365, it loops through every single received event:
for (const ev of received) {
if (ev.metadata?.isReaction) continue;
if (!isTwoWay(ev)) continue;
if (Array.isArray(ev.participants) && ev.participants.length > 1) continue;
const enriched = enrichActivityEvent(ev, ctx);
const personId = enriched.personId || null;
...
In server/services/identityResolve.js:157-169, enrichActivityEvent(event, ctx) reads event.metadata?.handle and calls resolveHandle(handle, ctx). Unlike resolveHandles (:113-121) which maintains a handle cache, enrichActivityEvent has no memoization. For hundreds or thousands of received messages from the same contacts, resolveHandle repeatedly executes:
identityFromHandle(raw) regex matching
normalizePhone / normalizeIdentifier string normalization
matchPerson(identity, tribeIndex) map/set traversals
resolveHandleAgainstContacts(raw, contactIndex) cache lookups
Furthermore, findUnansweredTribeThreads has no short-term cache or invalidation key. Every 120 seconds, the dashboard widget triggers this full multi-scope query and handle resolution loop, even when no new messages have been ingested.
Trigger
client/src/components/ProactiveAlertsWidget.jsx:58-61 mounts on the primary Dashboard (/) and auto-refetches api.getAlertsSummary() every 120,000 ms (2 minutes).
getAlertsSummary() (server/services/proactiveAlerts.js:373-380) calls generateAlerts() -> checkUnansweredTribeThreads() (:291-329), invoking findUnansweredTribeThreads().
GET /api/tribe/outreach (server/routes/tribe.js:123-127) calls findUnansweredTribeThreads({ limit }) on Tribe page mount.
Impact
Every 2 minutes while the dashboard is open, PortOS issues up to 8 database queries across human_activity_events (one of the largest tables in PortOS), transferring and parsing JSONB metadata for thousands of events, followed by thousands of redundant handle parsing and regex matching operations in Node.js. On an install with active messaging history, this generates unnecessary database load and CPU churn for an alert state that rarely changes.
Fix
- Add handle memoization to the outreach resolution loop in
server/services/tribeOutreach.js:348-365: maintain a local handleCache = new Map() so resolveHandle / enrichActivityEvent is called at most once per unique handle across all received events, rather than once per event turn.
- Cache the result of
findUnansweredTribeThreads() with a 60–120s TTL (or memoize the alert check in server/services/proactiveAlerts.js), serving back-to-back dashboard requests and 2-minute polls without re-querying up to 16,000 events when no sync has occurred.
- Rejected alternative: Querying only unread/unanswered messages in SQL — rejected because "unanswered" requires pairing inbound turns against subsequent outbound turns in the same conversation across multiple sources (
chatGuid, threadId, conversationId), which is already cleanly unified in groupUnansweredThreads.
Files: server/services/tribeOutreach.js, server/services/identityResolve.js, server/services/proactiveAlerts.js, server/services/tribeOutreach.test.js.
Tests:
- In
server/services/tribeOutreach.test.js, add a test asserting that resolveHandle is called only once per unique handle across multiple received events from the same sender.
- In
server/services/tribeOutreach.test.js or server/services/proactiveAlerts.test.js, verify that calls within the TTL return cached results without re-querying listEvents.
cd server && npm test green.
Acceptance criteria
Problem
In
server/services/tribeOutreach.js:269-373,findUnansweredTribeThreads()detects unanswered 1:1 messages from Tribe members within the actionable window (withinDays = 14).To detect unanswered conversations, line 316-337 executes
fetchScopefor every chat source (imessage,signal) and every two-way email account (gmail, etc.):With
EVENT_CAP = 2000, 2 chat sources and 2 active email accounts fire up to 8 queries requesting up to 2,000 rows each (up to 16,000 events total). Each query pulls full rows fromhuman_activity_events(title,summary,participantsJSONB, andmetadataJSONB).Then in
tribeOutreach.js:348-365, it loops through every single received event:In
server/services/identityResolve.js:157-169,enrichActivityEvent(event, ctx)readsevent.metadata?.handleand callsresolveHandle(handle, ctx). UnlikeresolveHandles(:113-121) which maintains a handle cache,enrichActivityEventhas no memoization. For hundreds or thousands of received messages from the same contacts,resolveHandlerepeatedly executes:identityFromHandle(raw)regex matchingnormalizePhone/normalizeIdentifierstring normalizationmatchPerson(identity, tribeIndex)map/set traversalsresolveHandleAgainstContacts(raw, contactIndex)cache lookupsFurthermore,
findUnansweredTribeThreadshas no short-term cache or invalidation key. Every 120 seconds, the dashboard widget triggers this full multi-scope query and handle resolution loop, even when no new messages have been ingested.Trigger
client/src/components/ProactiveAlertsWidget.jsx:58-61mounts on the primary Dashboard (/) and auto-refetchesapi.getAlertsSummary()every 120,000 ms (2 minutes).getAlertsSummary()(server/services/proactiveAlerts.js:373-380) callsgenerateAlerts()->checkUnansweredTribeThreads()(:291-329), invokingfindUnansweredTribeThreads().GET /api/tribe/outreach(server/routes/tribe.js:123-127) callsfindUnansweredTribeThreads({ limit })on Tribe page mount.Impact
Every 2 minutes while the dashboard is open, PortOS issues up to 8 database queries across
human_activity_events(one of the largest tables in PortOS), transferring and parsing JSONB metadata for thousands of events, followed by thousands of redundant handle parsing and regex matching operations in Node.js. On an install with active messaging history, this generates unnecessary database load and CPU churn for an alert state that rarely changes.Fix
server/services/tribeOutreach.js:348-365: maintain a localhandleCache = new Map()soresolveHandle/enrichActivityEventis called at most once per unique handle across all received events, rather than once per event turn.findUnansweredTribeThreads()with a 60–120s TTL (or memoize the alert check inserver/services/proactiveAlerts.js), serving back-to-back dashboard requests and 2-minute polls without re-querying up to 16,000 events when no sync has occurred.chatGuid,threadId,conversationId), which is already cleanly unified ingroupUnansweredThreads.Files:
server/services/tribeOutreach.js,server/services/identityResolve.js,server/services/proactiveAlerts.js,server/services/tribeOutreach.test.js.Tests:
server/services/tribeOutreach.test.js, add a test asserting thatresolveHandleis called only once per unique handle across multiple received events from the same sender.server/services/tribeOutreach.test.jsorserver/services/proactiveAlerts.test.js, verify that calls within the TTL return cached results without re-queryinglistEvents.cd server && npm testgreen.Acceptance criteria
findUnansweredTribeThreadsis memoized so each distinct handle is resolved at most once per detection pass.human_activity_events.cd server && npm testgreen.