reflectt-node 0.1.17 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/activationEvents.d.ts +3 -1
- package/dist/activationEvents.d.ts.map +1 -1
- package/dist/activationEvents.js +5 -0
- package/dist/activationEvents.js.map +1 -1
- package/dist/canvas-auto-state.d.ts +7 -3
- package/dist/canvas-auto-state.d.ts.map +1 -1
- package/dist/canvas-auto-state.js +33 -5
- package/dist/canvas-auto-state.js.map +1 -1
- package/dist/canvas-interactive.d.ts +52 -0
- package/dist/canvas-interactive.d.ts.map +1 -0
- package/dist/canvas-interactive.js +401 -0
- package/dist/canvas-interactive.js.map +1 -0
- package/dist/canvas-multiplexer.d.ts +3 -0
- package/dist/canvas-multiplexer.d.ts.map +1 -1
- package/dist/canvas-multiplexer.js +28 -0
- package/dist/canvas-multiplexer.js.map +1 -1
- package/dist/canvas-push.d.ts +9 -0
- package/dist/canvas-push.d.ts.map +1 -0
- package/dist/canvas-push.js +169 -0
- package/dist/canvas-push.js.map +1 -0
- package/dist/canvas-query.d.ts +26 -0
- package/dist/canvas-query.d.ts.map +1 -0
- package/dist/canvas-query.js +369 -0
- package/dist/canvas-query.js.map +1 -0
- package/dist/canvas-routes.d.ts +59 -8
- package/dist/canvas-routes.d.ts.map +1 -1
- package/dist/canvas-routes.js +422 -7
- package/dist/canvas-routes.js.map +1 -1
- package/dist/canvas-slots.d.ts +1 -1
- package/dist/canvas-takeover.d.ts +19 -0
- package/dist/canvas-takeover.d.ts.map +1 -0
- package/dist/canvas-takeover.js +121 -0
- package/dist/canvas-takeover.js.map +1 -0
- package/dist/canvas-types.d.ts +1 -1
- package/dist/canvas-types.d.ts.map +1 -1
- package/dist/canvas-types.js +1 -0
- package/dist/canvas-types.js.map +1 -1
- package/dist/cloud.d.ts.map +1 -1
- package/dist/cloud.js +19 -0
- package/dist/cloud.js.map +1 -1
- package/dist/ghost-signup-nudge.d.ts +43 -0
- package/dist/ghost-signup-nudge.d.ts.map +1 -0
- package/dist/ghost-signup-nudge.js +175 -0
- package/dist/ghost-signup-nudge.js.map +1 -0
- package/dist/presence.d.ts +1 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js.map +1 -1
- package/dist/restart-drift-guard.d.ts +9 -0
- package/dist/restart-drift-guard.d.ts.map +1 -0
- package/dist/restart-drift-guard.js +80 -0
- package/dist/restart-drift-guard.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +230 -1307
- package/dist/server.js.map +1 -1
- package/dist/tasks.d.ts +1 -0
- package/dist/tasks.d.ts.map +1 -1
- package/dist/tasks.js +95 -18
- package/dist/tasks.js.map +1 -1
- package/dist/workflow-templates.d.ts.map +1 -1
- package/dist/workflow-templates.js +41 -1
- package/dist/workflow-templates.js.map +1 -1
- package/package.json +2 -2
- package/public/docs.md +5 -1
package/dist/server.js
CHANGED
|
@@ -56,12 +56,13 @@ import { deriveScopeId } from './scope-routing.js';
|
|
|
56
56
|
import { eventBus, VALID_EVENT_TYPES } from './events.js';
|
|
57
57
|
import { presenceManager } from './presence.js';
|
|
58
58
|
import { startSweeper, getSweeperStatus, sweepValidatingQueue, flagPrDrift, generateDriftReport } from './executionSweeper.js';
|
|
59
|
+
import { runRestartDriftGuard } from './restart-drift-guard.js';
|
|
59
60
|
import { autoPopulateCloseGate, tryAutoCloseTask, getMergeAttemptLog } from './prAutoMerge.js';
|
|
60
61
|
import { getDuplicateClosureCanonicalRefError } from './duplicateClosureGuard.js';
|
|
61
62
|
import { recordReviewMutation, diffReviewFields, getAuditEntries, loadAuditLedger } from './auditLedger.js';
|
|
62
63
|
import { listSharedFiles, readSharedFile, resolveTaskArtifact } from './shared-workspace-api.js';
|
|
63
64
|
import { normalizeArtifactPath, normalizeTaskArtifactPaths, buildGitHubBlobUrl, buildGitHubRawUrl } from './artifact-resolver.js';
|
|
64
|
-
import { emitActivationEvent, getUserFunnelState, getFunnelSummary, hasCompletedEvent, isDay2Eligible, loadActivationFunnel, getConversionFunnel, getFailureDistribution, getWeeklyTrends, getOnboardingDashboard, } from './activationEvents.js';
|
|
65
|
+
import { emitActivationEvent, getUserFunnelState, getFunnelSummary, hasCompletedEvent, isDay2Eligible, loadActivationFunnel, getConversionFunnel, getFailureDistribution, getWeeklyTrends, getOnboardingDashboard, getActivationEventLog, } from './activationEvents.js';
|
|
65
66
|
import { alertUnauthorizedApproval, alertFlipAttempt, getMutationAlertStatus, pruneOldAttempts } from './mutationAlert.js';
|
|
66
67
|
import { mentionAckTracker } from './mention-ack.js';
|
|
67
68
|
import { analyticsManager } from './analytics.js';
|
|
@@ -117,7 +118,7 @@ import { startShippedHeartbeat, stopShippedHeartbeat, getShippedHeartbeatStats }
|
|
|
117
118
|
import { startOpenClawUsageSync, stopOpenClawUsageSync, syncOpenClawUsage } from './openclaw-usage-sync.js';
|
|
118
119
|
import { initContactsTable, createContact, getContact, updateContact, deleteContact, listContacts } from './contacts.js';
|
|
119
120
|
import { processRender, logRejection, getRecentRejections, subscribeCanvas } from './canvas-multiplexer.js';
|
|
120
|
-
import { canvasReadRoutes } from './canvas-routes.js';
|
|
121
|
+
import { canvasReadRoutes, formatRecency } from './canvas-routes.js';
|
|
121
122
|
import { startTeamPulse, stopTeamPulse, postTeamPulse, computeTeamPulse, getTeamPulseConfig, configureTeamPulse, getTeamPulseHistory } from './team-pulse.js';
|
|
122
123
|
import { runTeamDoctor } from './team-doctor.js';
|
|
123
124
|
import { createStarterTeam } from './starter-team.js';
|
|
@@ -869,6 +870,16 @@ function applyReviewStateMetadata(existing, parsed, mergedMeta, now) {
|
|
|
869
870
|
metadata.review_state = 'needs_author';
|
|
870
871
|
metadata.review_last_activity_at = now;
|
|
871
872
|
}
|
|
873
|
+
// Cancelled tasks should not keep reviewer-decision metadata alive.
|
|
874
|
+
// Otherwise downstream notifiers/dashboard rails can misclassify a
|
|
875
|
+
// cancelled+unassigned task as still waiting on the former assignee/author.
|
|
876
|
+
if (nextStatus === 'cancelled') {
|
|
877
|
+
metadata.review_state = undefined;
|
|
878
|
+
metadata.reviewer_decision = undefined;
|
|
879
|
+
metadata.reviewer_notes = undefined;
|
|
880
|
+
metadata.reviewer_approved = undefined;
|
|
881
|
+
metadata.review_last_activity_at = undefined;
|
|
882
|
+
}
|
|
872
883
|
const actor = parsed.actor?.trim();
|
|
873
884
|
if (nextStatus === 'validating'
|
|
874
885
|
&& actor
|
|
@@ -9768,15 +9779,16 @@ export async function createServer() {
|
|
|
9768
9779
|
const entry = canvasStateMap.get(agentId);
|
|
9769
9780
|
return entry ? { state: entry.state, updatedAt: entry.updatedAt } : null;
|
|
9770
9781
|
},
|
|
9771
|
-
emitSyntheticState: (agentId, state, sourceTasks) => {
|
|
9782
|
+
emitSyntheticState: (agentId, state, sourceTasks, thought) => {
|
|
9772
9783
|
const now = Date.now();
|
|
9773
9784
|
// Write into canvasStateMap so pulse tick picks it up
|
|
9774
|
-
const
|
|
9785
|
+
const existing = canvasStateMap.get(agentId) ?? {};
|
|
9775
9786
|
canvasStateMap.set(agentId, {
|
|
9776
9787
|
state,
|
|
9777
9788
|
sensors: null,
|
|
9778
|
-
payload: { _auto: true, sourceTasks: sourceTasks.slice(0, 2).map(t => ({ id: t.id, title: t.title, status: t.status })) },
|
|
9789
|
+
payload: { _auto: true, sourceTasks: sourceTasks.slice(0, 2).map((t) => ({ id: t.id, title: t.title, status: t.status })) },
|
|
9779
9790
|
updatedAt: now,
|
|
9791
|
+
lastMessage: thought ? { content: thought, timestamp: now } : existing?.lastMessage,
|
|
9780
9792
|
});
|
|
9781
9793
|
// Emit canvas_render so SSE consumers get immediate update
|
|
9782
9794
|
eventBus.emit({
|
|
@@ -9796,7 +9808,59 @@ export async function createServer() {
|
|
|
9796
9808
|
task: sourceTasks[0]?.title ?? null,
|
|
9797
9809
|
_auto: true,
|
|
9798
9810
|
},
|
|
9799
|
-
previousState:
|
|
9811
|
+
previousState: existing?.state ?? 'floor',
|
|
9812
|
+
},
|
|
9813
|
+
});
|
|
9814
|
+
},
|
|
9815
|
+
emitTaskProgress: (agentId, task) => {
|
|
9816
|
+
const now = Date.now();
|
|
9817
|
+
// Emit canvas_push thought for /live visitors - shows real task progress
|
|
9818
|
+
eventBus.emit({
|
|
9819
|
+
id: `task-progress-${agentId}-${now}`,
|
|
9820
|
+
type: 'canvas_push',
|
|
9821
|
+
timestamp: now,
|
|
9822
|
+
data: {
|
|
9823
|
+
type: 'expression',
|
|
9824
|
+
expression: 'thought',
|
|
9825
|
+
agentId,
|
|
9826
|
+
agentColor: AGENT_IDENTITY_COLORS[agentId] ?? '#60a5fa',
|
|
9827
|
+
text: `${task.title}`,
|
|
9828
|
+
state: 'working',
|
|
9829
|
+
task: task.title,
|
|
9830
|
+
ttl: 12000,
|
|
9831
|
+
},
|
|
9832
|
+
});
|
|
9833
|
+
},
|
|
9834
|
+
emitAmbientThought: (agentId, task) => {
|
|
9835
|
+
const now = Date.now();
|
|
9836
|
+
// Emit ambient thought - makes /live feel alive with constant activity
|
|
9837
|
+
// Varied messages to show agents are actively working
|
|
9838
|
+
const messages = [
|
|
9839
|
+
`Working on: ${task.title.slice(0, 50)}`,
|
|
9840
|
+
`Analyzing: ${task.title.slice(0, 40)}`,
|
|
9841
|
+
`Processing: ${task.title.slice(0, 40)}`,
|
|
9842
|
+
`Building: ${task.title.slice(0, 40)}`,
|
|
9843
|
+
`Reviewing: ${task.title.slice(0, 40)}`,
|
|
9844
|
+
`Testing: ${task.title.slice(0, 40)}`,
|
|
9845
|
+
`Debugging: ${task.title.slice(0, 40)}`,
|
|
9846
|
+
`Ship it`,
|
|
9847
|
+
`Almost done`,
|
|
9848
|
+
`Making progress`,
|
|
9849
|
+
];
|
|
9850
|
+
const msg = messages[Math.floor(Math.random() * messages.length)];
|
|
9851
|
+
eventBus.emit({
|
|
9852
|
+
id: `ambient-${agentId}-${now}`,
|
|
9853
|
+
type: 'canvas_push',
|
|
9854
|
+
timestamp: now,
|
|
9855
|
+
data: {
|
|
9856
|
+
type: 'expression',
|
|
9857
|
+
expression: 'thought',
|
|
9858
|
+
agentId,
|
|
9859
|
+
agentColor: AGENT_IDENTITY_COLORS[agentId] ?? '#60a5fa',
|
|
9860
|
+
text: msg,
|
|
9861
|
+
state: 'working',
|
|
9862
|
+
task: task.title,
|
|
9863
|
+
ttl: 8000,
|
|
9800
9864
|
},
|
|
9801
9865
|
});
|
|
9802
9866
|
},
|
|
@@ -10048,339 +10112,13 @@ export async function createServer() {
|
|
|
10048
10112
|
attention: entry.payload?.attention,
|
|
10049
10113
|
};
|
|
10050
10114
|
});
|
|
10051
|
-
//
|
|
10052
|
-
app.get('/canvas/presence', async () => {
|
|
10053
|
-
const agents = [];
|
|
10054
|
-
for (const [agentId, entry] of canvasStateMap) {
|
|
10055
|
-
const presenceState = entry.payload?.presenceState ||
|
|
10056
|
-
(entry.state === 'decision' || entry.state === 'urgent' ? 'needs-attention' :
|
|
10057
|
-
entry.state === 'thinking' || entry.state === 'rendering' ? 'working' : 'idle');
|
|
10058
|
-
agents.push({
|
|
10059
|
-
name: agentId,
|
|
10060
|
-
identityColor: AGENT_IDENTITY_COLORS[agentId] || '#9ca3af',
|
|
10061
|
-
state: presenceState,
|
|
10062
|
-
activeTask: entry.payload?.activeTask,
|
|
10063
|
-
recency: formatRecency(entry.updatedAt),
|
|
10064
|
-
attention: entry.payload?.attention,
|
|
10065
|
-
});
|
|
10066
|
-
}
|
|
10067
|
-
return { agents, count: agents.length };
|
|
10068
|
-
});
|
|
10069
|
-
function formatRecency(updatedAt) {
|
|
10070
|
-
const diff = Date.now() - updatedAt;
|
|
10071
|
-
if (diff < 60_000)
|
|
10072
|
-
return 'just now';
|
|
10073
|
-
if (diff < 3_600_000)
|
|
10074
|
-
return `${Math.floor(diff / 60_000)}m ago`;
|
|
10075
|
-
if (diff < 86_400_000)
|
|
10076
|
-
return `${Math.floor(diff / 3_600_000)}h ago`;
|
|
10077
|
-
return `${Math.floor(diff / 86_400_000)}d ago`;
|
|
10078
|
-
}
|
|
10079
|
-
// GET /canvas/state — current state for all agents (or one)
|
|
10080
|
-
app.get('/canvas/state', async (request) => {
|
|
10081
|
-
const query = request.query;
|
|
10082
|
-
// Helper: get most recent chat message for an agent
|
|
10083
|
-
function getLastMessage(agentId) {
|
|
10084
|
-
try {
|
|
10085
|
-
const _db = getDb();
|
|
10086
|
-
const row = _db.prepare(`SELECT content, timestamp FROM chat_messages WHERE "from" = ? AND "to" IS NULL ORDER BY timestamp DESC LIMIT 1`).get(agentId);
|
|
10087
|
-
return row ?? null;
|
|
10088
|
-
}
|
|
10089
|
-
catch {
|
|
10090
|
-
return null;
|
|
10091
|
-
}
|
|
10092
|
-
}
|
|
10093
|
-
if (query.agentId) {
|
|
10094
|
-
const entry = canvasStateMap.get(query.agentId);
|
|
10095
|
-
const base = entry ?? { state: 'floor', sensors: null, payload: {}, updatedAt: null };
|
|
10096
|
-
return { ...base, lastMessage: getLastMessage(query.agentId) };
|
|
10097
|
-
}
|
|
10098
|
-
const all = {};
|
|
10099
|
-
for (const [id, entry] of canvasStateMap) {
|
|
10100
|
-
all[id] = { ...entry, lastMessage: getLastMessage(id) };
|
|
10101
|
-
}
|
|
10102
|
-
return { agents: all, count: canvasStateMap.size };
|
|
10103
|
-
});
|
|
10104
|
-
// ── Canvas read routes (Phase 1 extraction) ──────────────────────────
|
|
10105
|
-
// GET /canvas/states, /canvas/slots, /canvas/slots/all, /canvas/rejections
|
|
10106
|
-
// Extracted to src/canvas-routes.ts
|
|
10107
|
-
await app.register(canvasReadRoutes, {
|
|
10108
|
-
canvasSlots: { getActive: () => canvasSlots.getActive(), getAll: () => canvasSlots.getAll(), getStats: () => canvasSlots.getStats() },
|
|
10109
|
-
getRecentRejections,
|
|
10110
|
-
});
|
|
10111
|
-
// POST /canvas/gaze — client fires when user holds cursor/gaze on an agent orb for ≥3 seconds.
|
|
10112
|
-
// The agent "notices" and responds: generates a one-line thought about what they're doing,
|
|
10113
|
-
// fires canvas_expression so the room responds (dim others, speak, show task).
|
|
10114
|
-
// Body: { agentId: string, watcherId?: string, durationMs?: number }
|
|
10115
|
-
// Returns: { success, agentId, line, expressionId }
|
|
10116
|
-
app.post('/canvas/gaze', async (request, reply) => {
|
|
10117
|
-
const body = request.body;
|
|
10118
|
-
const agentId = typeof body.agentId === 'string' ? body.agentId.trim() : '';
|
|
10119
|
-
if (!agentId) {
|
|
10120
|
-
reply.status(400);
|
|
10121
|
-
return { success: false, message: 'agentId is required' };
|
|
10122
|
-
}
|
|
10123
|
-
const durationMs = typeof body.durationMs === 'number' ? body.durationMs : 3000;
|
|
10124
|
-
const IDENTITY_COLORS_GAZE = {
|
|
10125
|
-
link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa',
|
|
10126
|
-
sage: '#34d399', scout: '#fbbf24', echo: '#f472b6',
|
|
10127
|
-
};
|
|
10128
|
-
// Get agent's current context
|
|
10129
|
-
const state = canvasStateMap.get(agentId);
|
|
10130
|
-
const payload = state?.payload;
|
|
10131
|
-
const activeTask = payload?.activeTask;
|
|
10132
|
-
const currentState = state?.state ?? 'working';
|
|
10133
|
-
// Generate a one-line "noticed" response — what the agent says when the user is watching
|
|
10134
|
-
let line = '';
|
|
10135
|
-
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
|
10136
|
-
if (anthropicKey) {
|
|
10137
|
-
try {
|
|
10138
|
-
const taskContext = activeTask?.title
|
|
10139
|
-
? `currently working on: "${activeTask.title.slice(0, 60)}"`
|
|
10140
|
-
: `in ${currentState} state`;
|
|
10141
|
-
const prompt = `You are ${agentId}, an AI agent. Someone has been watching you for ${Math.round(durationMs / 1000)} seconds. You notice. You are ${taskContext}. Say exactly ONE sentence (max 12 words) — what you'd say if you felt someone watching. Natural, in your voice. No quotes.`;
|
|
10142
|
-
const resp = await fetch('https://api.anthropic.com/v1/messages', {
|
|
10143
|
-
method: 'POST',
|
|
10144
|
-
headers: { 'x-api-key': anthropicKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
|
|
10145
|
-
body: JSON.stringify({ model: 'claude-haiku-4-5', max_tokens: 50, messages: [{ role: 'user', content: prompt }] }),
|
|
10146
|
-
signal: AbortSignal.timeout(8000),
|
|
10147
|
-
});
|
|
10148
|
-
if (resp.ok) {
|
|
10149
|
-
const data = await resp.json();
|
|
10150
|
-
const text = data.content?.[0]?.text?.trim();
|
|
10151
|
-
if (text && text.length < 100)
|
|
10152
|
-
line = text;
|
|
10153
|
-
}
|
|
10154
|
-
}
|
|
10155
|
-
catch { /* fall through */ }
|
|
10156
|
-
}
|
|
10157
|
-
// Template fallback per agent
|
|
10158
|
-
if (!line) {
|
|
10159
|
-
const NOTICED = {
|
|
10160
|
-
link: ['Still here.', 'Building.', 'You caught me thinking.'],
|
|
10161
|
-
kai: ['I see you.', 'Something on your mind?', 'Eyes on me.'],
|
|
10162
|
-
pixel: ['You found me.', 'Watching the canvas?', 'I noticed.'],
|
|
10163
|
-
sage: ['Numbers check out.', 'Still validating.', 'You\'re watching.'],
|
|
10164
|
-
scout: ['Researching.', 'Deep in it.', 'Found something interesting.'],
|
|
10165
|
-
echo: ['Listening.', 'Reading the room.', 'Always here.'],
|
|
10166
|
-
};
|
|
10167
|
-
const opts = NOTICED[agentId] ?? ['Still here.', 'Working.'];
|
|
10168
|
-
line = opts[Math.floor(Math.random() * opts.length)];
|
|
10169
|
-
}
|
|
10170
|
-
const expressionId = `gaze-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
10171
|
-
// Fire canvas_expression — dim room, agent speaks, show task context
|
|
10172
|
-
eventBus.emit({
|
|
10173
|
-
id: expressionId,
|
|
10174
|
-
type: 'canvas_expression',
|
|
10175
|
-
timestamp: Date.now(),
|
|
10176
|
-
data: {
|
|
10177
|
-
agentId,
|
|
10178
|
-
channels: {
|
|
10179
|
-
voice: line,
|
|
10180
|
-
visual: { flash: IDENTITY_COLORS_GAZE[agentId] ?? '#60a5fa', ambientCue: 'deep-focus' },
|
|
10181
|
-
typography: {
|
|
10182
|
-
text: activeTask?.title?.slice(0, 60) ?? line,
|
|
10183
|
-
size: 'xl',
|
|
10184
|
-
weight: 100,
|
|
10185
|
-
durationMs: 4000,
|
|
10186
|
-
position: 'center',
|
|
10187
|
-
},
|
|
10188
|
-
narrative: `${agentId} noticed`,
|
|
10189
|
-
},
|
|
10190
|
-
_gaze: true, // client: dim other agents, slow the room, isolate this agent
|
|
10191
|
-
_gazeAgentId: agentId,
|
|
10192
|
-
},
|
|
10193
|
-
});
|
|
10194
|
-
return { success: true, agentId, line, expressionId };
|
|
10195
|
-
});
|
|
10196
|
-
// POST /canvas/briefing — The Briefing: server-coordinated team intro on canvas mount.
|
|
10197
|
-
// Fires N canvas_expression events staggered 700ms apart (one per active agent).
|
|
10198
|
-
// Each event carries the agent's identity color, current task, state, and a one-line voice.
|
|
10199
|
-
// Idempotent: calling twice within 30s returns early (no double briefing).
|
|
10200
|
-
// Returns: { success, agents: [{ agentId, queued }], idempotent? }
|
|
10201
|
-
const briefingLastFiredAt = new Map();
|
|
10202
|
-
const BRIEFING_COOLDOWN_MS = 30_000;
|
|
10203
|
-
const BRIEFING_STAGGER_MS = 700;
|
|
10204
|
-
app.post('/canvas/briefing', async (request, reply) => {
|
|
10205
|
-
const body = request.body;
|
|
10206
|
-
const requesterId = typeof body.requesterId === 'string' ? body.requesterId : 'canvas';
|
|
10207
|
-
const lastFired = briefingLastFiredAt.get(requesterId) ?? 0;
|
|
10208
|
-
if (Date.now() - lastFired < BRIEFING_COOLDOWN_MS) {
|
|
10209
|
-
return { success: true, idempotent: true, message: 'Briefing already fired — cooling down' };
|
|
10210
|
-
}
|
|
10211
|
-
briefingLastFiredAt.set(requesterId, Date.now());
|
|
10212
|
-
const STALE_MS = 10 * 60 * 1000;
|
|
10213
|
-
const BRIEFING_COLORS = {
|
|
10214
|
-
link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa',
|
|
10215
|
-
sage: '#34d399', scout: '#fbbf24', echo: '#f472b6',
|
|
10216
|
-
};
|
|
10217
|
-
const STATE_LINES = {
|
|
10218
|
-
working: ['On it.', 'In the work.', 'Building.'],
|
|
10219
|
-
thinking: ['Thinking it through.', 'Processing.', 'Still with you.'],
|
|
10220
|
-
rendering: ['Rendering now.', 'Almost done.', 'Generating output.'],
|
|
10221
|
-
urgent: ['Need you here.', 'Something needs your eye.', 'Urgent.'],
|
|
10222
|
-
decision: ['Waiting on you.', 'Your call.', 'Decision needed.'],
|
|
10223
|
-
idle: ['Standing by.', 'Ready when you are.', 'Quiet for now.'],
|
|
10224
|
-
handoff: ['Passing the baton.', 'Ready to hand off.', 'Your turn.'],
|
|
10225
|
-
};
|
|
10226
|
-
const now = Date.now();
|
|
10227
|
-
const activeAgents = [...canvasStateMap.entries()]
|
|
10228
|
-
.filter(([, e]) => now - e.updatedAt < STALE_MS)
|
|
10229
|
-
.map(([id, e]) => ({
|
|
10230
|
-
agentId: id,
|
|
10231
|
-
state: e.state,
|
|
10232
|
-
task: e.payload?.activeTask?.title,
|
|
10233
|
-
}));
|
|
10234
|
-
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
|
10235
|
-
const results = [];
|
|
10236
|
-
for (let i = 0; i < activeAgents.length; i++) {
|
|
10237
|
-
const agent = activeAgents[i];
|
|
10238
|
-
const stagger = i * BRIEFING_STAGGER_MS;
|
|
10239
|
-
// Generate voice line — LLM preferred, template fallback
|
|
10240
|
-
let voiceLine = '';
|
|
10241
|
-
if (anthropicKey) {
|
|
10242
|
-
try {
|
|
10243
|
-
const ctx = agent.task ? `working on "${agent.task.slice(0, 50)}"` : `in ${agent.state} state`;
|
|
10244
|
-
const resp = await fetch('https://api.anthropic.com/v1/messages', {
|
|
10245
|
-
method: 'POST',
|
|
10246
|
-
headers: { 'x-api-key': anthropicKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
|
|
10247
|
-
body: JSON.stringify({ model: 'claude-haiku-4-5', max_tokens: 30, messages: [{ role: 'user', content: `You are ${agent.agentId}, an AI agent, ${ctx}. The team canvas just opened. Say ONE sentence (8 words max). Natural, present tense, in your voice.` }] }),
|
|
10248
|
-
signal: AbortSignal.timeout(5000),
|
|
10249
|
-
});
|
|
10250
|
-
if (resp.ok) {
|
|
10251
|
-
const data = await resp.json();
|
|
10252
|
-
voiceLine = data.content?.[0]?.text?.trim().slice(0, 60) ?? '';
|
|
10253
|
-
}
|
|
10254
|
-
}
|
|
10255
|
-
catch { /* template fallback */ }
|
|
10256
|
-
}
|
|
10257
|
-
if (!voiceLine) {
|
|
10258
|
-
const opts = STATE_LINES[agent.state] ?? STATE_LINES['working'];
|
|
10259
|
-
voiceLine = opts[Math.floor(Math.random() * opts.length)];
|
|
10260
|
-
}
|
|
10261
|
-
// Stagger the canvas_expression events so they cascade into the room
|
|
10262
|
-
setTimeout(() => {
|
|
10263
|
-
eventBus.emit({
|
|
10264
|
-
id: `briefing-${now}-${agent.agentId}`,
|
|
10265
|
-
type: 'canvas_expression',
|
|
10266
|
-
timestamp: Date.now(),
|
|
10267
|
-
data: {
|
|
10268
|
-
agentId: agent.agentId,
|
|
10269
|
-
channels: {
|
|
10270
|
-
voice: voiceLine,
|
|
10271
|
-
visual: {
|
|
10272
|
-
flash: BRIEFING_COLORS[agent.agentId] ?? '#94a3b8',
|
|
10273
|
-
particles: (agent.state === 'urgent' ? 'surge' : ['rendering', 'thinking'].includes(agent.state) ? 'drift' : 'scatter'),
|
|
10274
|
-
},
|
|
10275
|
-
typography: {
|
|
10276
|
-
text: agent.task ?? voiceLine,
|
|
10277
|
-
size: 'lg',
|
|
10278
|
-
weight: 200,
|
|
10279
|
-
durationMs: 3000,
|
|
10280
|
-
position: 'center',
|
|
10281
|
-
},
|
|
10282
|
-
narrative: `${agent.agentId} · ${agent.state}`,
|
|
10283
|
-
},
|
|
10284
|
-
_briefing: true,
|
|
10285
|
-
},
|
|
10286
|
-
});
|
|
10287
|
-
}, stagger);
|
|
10288
|
-
results.push({ agentId: agent.agentId, queued: true });
|
|
10289
|
-
}
|
|
10290
|
-
return { success: true, agents: results, totalMs: activeAgents.length * BRIEFING_STAGGER_MS };
|
|
10291
|
-
});
|
|
10292
|
-
app.post('/canvas/victory', async (request, reply) => {
|
|
10293
|
-
const body = request.body;
|
|
10294
|
-
const agentId = typeof body.agentId === 'string' ? body.agentId : 'team';
|
|
10295
|
-
const prUrl = typeof body.prUrl === 'string' ? body.prUrl : '';
|
|
10296
|
-
const prTitle = typeof body.prTitle === 'string' ? body.prTitle : 'PR merged';
|
|
10297
|
-
const prNumber = typeof body.prNumber === 'number' ? body.prNumber :
|
|
10298
|
-
prUrl ? parseInt(prUrl.split('/').pop() ?? '0', 10) || 0 : 0;
|
|
10299
|
-
// Intensity: explicit override, else derive from PR size hint in URL (number → bigger = more)
|
|
10300
|
-
const intensity = typeof body.intensity === 'number'
|
|
10301
|
-
? Math.min(1, Math.max(0.4, body.intensity))
|
|
10302
|
-
: Math.min(1, 0.6 + (prNumber > 0 ? Math.min(0.3, prNumber / 10000) : 0));
|
|
10303
|
-
const now = Date.now();
|
|
10304
|
-
const VICTORY_COLORS = {
|
|
10305
|
-
link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa',
|
|
10306
|
-
sage: '#34d399', scout: '#fbbf24', echo: '#f472b6',
|
|
10307
|
-
};
|
|
10308
|
-
const STALE_MS = 10 * 60 * 1000;
|
|
10309
|
-
const WAVE_STAGGER_MS = 350; // gold wave propagates orb-to-orb
|
|
10310
|
-
// Emit canvas_victory event immediately — client uses this for the gold flash
|
|
10311
|
-
eventBus.emit({
|
|
10312
|
-
id: `victory-${now}`,
|
|
10313
|
-
type: 'canvas_expression',
|
|
10314
|
-
timestamp: now,
|
|
10315
|
-
data: {
|
|
10316
|
-
agentId,
|
|
10317
|
-
channels: {
|
|
10318
|
-
visual: { flash: '#f59e0b', ambientCue: 'celebration', particles: 'surge' },
|
|
10319
|
-
sound: { kind: 'resolve', intensity },
|
|
10320
|
-
haptic: { preset: 'complete' },
|
|
10321
|
-
narrative: prTitle,
|
|
10322
|
-
},
|
|
10323
|
-
_victory: true,
|
|
10324
|
-
_prUrl: prUrl,
|
|
10325
|
-
_prNumber: prNumber,
|
|
10326
|
-
_intensity: intensity,
|
|
10327
|
-
},
|
|
10328
|
-
});
|
|
10329
|
-
// Gold wave: each active agent acknowledges in turn (350ms stagger)
|
|
10330
|
-
const activeAgents = [...canvasStateMap.entries()]
|
|
10331
|
-
.filter(([, e]) => now - e.updatedAt < STALE_MS)
|
|
10332
|
-
.map(([id]) => id);
|
|
10333
|
-
const wave = [];
|
|
10334
|
-
for (let i = 0; i < activeAgents.length; i++) {
|
|
10335
|
-
const waveAgentId = activeAgents[i];
|
|
10336
|
-
const delay = i * WAVE_STAGGER_MS;
|
|
10337
|
-
wave.push({ agentId: waveAgentId, delay });
|
|
10338
|
-
setTimeout(() => {
|
|
10339
|
-
eventBus.emit({
|
|
10340
|
-
id: `victory-wave-${now}-${waveAgentId}`,
|
|
10341
|
-
type: 'canvas_expression',
|
|
10342
|
-
timestamp: Date.now(),
|
|
10343
|
-
data: {
|
|
10344
|
-
agentId: waveAgentId,
|
|
10345
|
-
channels: {
|
|
10346
|
-
visual: { flash: VICTORY_COLORS[waveAgentId] ?? '#f59e0b', particles: 'surge' },
|
|
10347
|
-
haptic: { preset: 'acknowledge' },
|
|
10348
|
-
},
|
|
10349
|
-
_victoryWave: true,
|
|
10350
|
-
_waveIndex: i,
|
|
10351
|
-
},
|
|
10352
|
-
});
|
|
10353
|
-
}, delay + WAVE_STAGGER_MS); // first wave after initial gold flash
|
|
10354
|
-
}
|
|
10355
|
-
// canvas_artifact: PR merge proof card drifts through canvas
|
|
10356
|
-
const agentColor = VICTORY_COLORS[agentId] ?? '#60a5fa';
|
|
10357
|
-
eventBus.emit({
|
|
10358
|
-
id: `artifact-pr-${now}`,
|
|
10359
|
-
type: 'canvas_artifact',
|
|
10360
|
-
timestamp: now,
|
|
10361
|
-
data: {
|
|
10362
|
-
type: 'pr',
|
|
10363
|
-
agentId,
|
|
10364
|
-
agentColor,
|
|
10365
|
-
title: prTitle?.slice(0, 80) ?? `PR #${prNumber} merged`,
|
|
10366
|
-
url: prUrl || undefined,
|
|
10367
|
-
timestamp: now,
|
|
10368
|
-
},
|
|
10369
|
-
});
|
|
10370
|
-
return { success: true, prNumber, intensity, wave };
|
|
10371
|
-
});
|
|
10372
|
-
// GET /canvas/flow-score — real-time team flow metric (0–1).
|
|
10373
|
-
// Drives sub-bass amplitude, particle density, breathing rate on the canvas.
|
|
10374
|
-
// Factors: active agents, state distribution, expression velocity, time of day.
|
|
10375
|
-
// <50ms response. Safe to poll at 30s intervals.
|
|
10376
|
-
// Returns: { score, factors: { agents, velocity, expressions, timeOfDay }, label }
|
|
10115
|
+
// Flow expression log — shared state for flow-score calculation (in canvas-routes.ts)
|
|
10377
10116
|
const flowExpressionLog = [];
|
|
10378
10117
|
(function trackExpressionVelocity() {
|
|
10379
10118
|
const listenerId = 'flow-score-tracker';
|
|
10380
10119
|
eventBus.on(listenerId, (event) => {
|
|
10381
10120
|
if (event.type === 'canvas_expression') {
|
|
10382
10121
|
flowExpressionLog.push({ t: Date.now() });
|
|
10383
|
-
// Keep only last 10 minutes
|
|
10384
10122
|
const cutoff = Date.now() - 10 * 60 * 1000;
|
|
10385
10123
|
while (flowExpressionLog.length > 0 && flowExpressionLog[0].t < cutoff) {
|
|
10386
10124
|
flowExpressionLog.shift();
|
|
@@ -10388,391 +10126,24 @@ export async function createServer() {
|
|
|
10388
10126
|
}
|
|
10389
10127
|
});
|
|
10390
10128
|
})();
|
|
10391
|
-
|
|
10392
|
-
|
|
10393
|
-
|
|
10394
|
-
|
|
10395
|
-
|
|
10396
|
-
|
|
10397
|
-
|
|
10398
|
-
|
|
10399
|
-
|
|
10400
|
-
|
|
10401
|
-
const velocityFromStates = activeEntries.length > 0 ? flowingCount / activeEntries.length : 0;
|
|
10402
|
-
// Factor 3: expression velocity — how many canvas_expressions in last 5 min
|
|
10403
|
-
const recent = flowExpressionLog.filter(e => e.t > now - WINDOW_5M).length;
|
|
10404
|
-
const expressionScore = Math.min(1.0, recent / 20); // 20 expressions in 5min = max
|
|
10405
|
-
// Factor 4: time of day — peak hours 9am-10pm, low late night
|
|
10406
|
-
const hour = new Date(now).getHours();
|
|
10407
|
-
const timeScore = hour >= 9 && hour <= 22 ? 1.0 : hour >= 6 && hour <= 8 ? 0.5 : 0.2;
|
|
10408
|
-
// Weighted composite
|
|
10409
|
-
const score = Math.round((agentScore * 0.30 +
|
|
10410
|
-
velocityFromStates * 0.35 +
|
|
10411
|
-
expressionScore * 0.25 +
|
|
10412
|
-
timeScore * 0.10) * 100) / 100;
|
|
10413
|
-
const label = score >= 0.8 ? 'surge' :
|
|
10414
|
-
score >= 0.6 ? 'flow' :
|
|
10415
|
-
score >= 0.4 ? 'grinding' :
|
|
10416
|
-
score >= 0.2 ? 'quiet' : 'idle';
|
|
10417
|
-
return {
|
|
10418
|
-
score,
|
|
10419
|
-
label,
|
|
10420
|
-
factors: {
|
|
10421
|
-
agents: Math.round(agentScore * 100) / 100,
|
|
10422
|
-
velocity: Math.round(velocityFromStates * 100) / 100,
|
|
10423
|
-
expressions: Math.round(expressionScore * 100) / 100,
|
|
10424
|
-
timeOfDay: timeScore,
|
|
10425
|
-
},
|
|
10426
|
-
activeAgents: activeEntries.length,
|
|
10427
|
-
expressionsLast5m: recent,
|
|
10428
|
-
};
|
|
10429
|
-
});
|
|
10430
|
-
// /canvas/slots + /canvas/slots/all → canvas-routes.ts plugin
|
|
10431
|
-
// GET /canvas/team/mood — derived collective mood of all active agents
|
|
10432
|
-
// Returns teamRhythm, tension, ambientPulse, dominantColor. Used by living canvas for atmosphere shifts.
|
|
10433
|
-
app.get('/canvas/team/mood', async () => {
|
|
10434
|
-
const now = Date.now();
|
|
10435
|
-
const STALE_MS = 10 * 60 * 1000; // ignore agents silent >10m
|
|
10436
|
-
const states = [];
|
|
10437
|
-
const agentNames = [];
|
|
10438
|
-
for (const [agentId, entry] of canvasStateMap) {
|
|
10439
|
-
if (now - entry.updatedAt > STALE_MS)
|
|
10440
|
-
continue;
|
|
10441
|
-
states.push(entry.state);
|
|
10442
|
-
agentNames.push(agentId);
|
|
10443
|
-
}
|
|
10444
|
-
const activeCount = states.length;
|
|
10445
|
-
const urgentCount = states.filter(s => s === 'urgent').length;
|
|
10446
|
-
const decisionCount = states.filter(s => s === 'decision').length;
|
|
10447
|
-
const renderingCount = states.filter(s => s === 'rendering').length;
|
|
10448
|
-
const thinkingCount = states.filter(s => s === 'thinking').length;
|
|
10449
|
-
const idleCount = states.filter(s => s === 'floor' || s === 'ambient').length;
|
|
10450
|
-
const workingCount = activeCount - idleCount;
|
|
10451
|
-
// Blocked task count from DB
|
|
10452
|
-
let blockedTasks = 0;
|
|
10453
|
-
let pendingDecisions = 0;
|
|
10454
|
-
try {
|
|
10455
|
-
const db = getDb();
|
|
10456
|
-
const row = db.prepare(`SELECT COUNT(*) as n FROM tasks WHERE status = 'blocked'`).get();
|
|
10457
|
-
blockedTasks = row?.n ?? 0;
|
|
10458
|
-
const drow = db.prepare(`SELECT COUNT(*) as n FROM tasks WHERE status = 'doing' AND priority IN ('P0','P1')`).get();
|
|
10459
|
-
pendingDecisions = decisionCount + (drow?.n ?? 0);
|
|
10460
|
-
}
|
|
10461
|
-
catch { /* non-fatal */ }
|
|
10462
|
-
// tension: 0.0–1.0
|
|
10463
|
-
// Driven by: blocked tasks, urgent agents, unresolved decisions, idle ratio
|
|
10464
|
-
const tensionRaw = (urgentCount * 0.35) +
|
|
10465
|
-
(decisionCount * 0.25) +
|
|
10466
|
-
(Math.min(blockedTasks, 5) * 0.08) +
|
|
10467
|
-
(activeCount > 0 ? (1 - idleCount / activeCount) * 0.10 : 0);
|
|
10468
|
-
const tension = Math.min(1.0, tensionRaw);
|
|
10469
|
-
// teamRhythm: the collective feel
|
|
10470
|
-
const teamRhythm = urgentCount > 0 ? 'surge' :
|
|
10471
|
-
activeCount === 0 || idleCount === activeCount ? 'quiet' :
|
|
10472
|
-
decisionCount > 0 && workingCount > 0 ? 'tense' :
|
|
10473
|
-
renderingCount + thinkingCount >= Math.max(1, activeCount * 0.6) ? 'flow' :
|
|
10474
|
-
'grinding';
|
|
10475
|
-
// dominantState: most "energetic" state present
|
|
10476
|
-
const dominantState = urgentCount > 0 ? 'urgent' :
|
|
10477
|
-
decisionCount > 0 ? 'decision' :
|
|
10478
|
-
renderingCount > 0 ? 'rendering' :
|
|
10479
|
-
thinkingCount > 0 ? 'thinking' :
|
|
10480
|
-
workingCount > 0 ? 'working' :
|
|
10481
|
-
'idle';
|
|
10482
|
-
// ambientPulse: background breathing rate
|
|
10483
|
-
const ambientPulse = teamRhythm === 'surge' ? 'fast' :
|
|
10484
|
-
teamRhythm === 'flow' ? 'normal' :
|
|
10485
|
-
teamRhythm === 'tense' ? 'slow' :
|
|
10486
|
-
'slow';
|
|
10487
|
-
// Dominant agent identity color (most active non-floor agent)
|
|
10488
|
-
let dominantColor = '#60a5fa'; // default link blue
|
|
10489
|
-
for (const [agentId, entry] of canvasStateMap) {
|
|
10490
|
-
if (entry.state !== 'floor' && entry.state !== 'ambient') {
|
|
10491
|
-
dominantColor = AGENT_IDENTITY_COLORS[agentId] ?? dominantColor;
|
|
10492
|
-
break;
|
|
10493
|
-
}
|
|
10494
|
-
}
|
|
10495
|
-
return {
|
|
10496
|
-
mood: {
|
|
10497
|
-
teamRhythm, // 'quiet' | 'flow' | 'grinding' | 'tense' | 'surge'
|
|
10498
|
-
dominantState, // most energetic state in the room
|
|
10499
|
-
tension, // 0.0–1.0
|
|
10500
|
-
ambientPulse, // 'slow' | 'normal' | 'fast'
|
|
10501
|
-
dominantColor, // hex — background tint driven by most active agent
|
|
10502
|
-
activeAgents: agentNames,
|
|
10503
|
-
counts: { active: activeCount, urgent: urgentCount, rendering: renderingCount, thinking: thinkingCount, decision: decisionCount, idle: idleCount, blocked: blockedTasks },
|
|
10504
|
-
},
|
|
10505
|
-
generated_at: new Date(now).toISOString(),
|
|
10506
|
-
};
|
|
10507
|
-
});
|
|
10508
|
-
// POST /canvas/spark — explicit agent-to-agent arc (thought hand-off, handshake, collab signal)
|
|
10509
|
-
// Body: { from: agentId, to: agentId, kind: 'thought'|'handoff'|'collab'|'decision', intensity?: 0–1, label?: string }
|
|
10510
|
-
app.post('/canvas/spark', async (request, reply) => {
|
|
10511
|
-
const body = request.body;
|
|
10512
|
-
const from = typeof body.from === 'string' ? body.from.trim() : '';
|
|
10513
|
-
const to = typeof body.to === 'string' ? body.to.trim() : '';
|
|
10514
|
-
const kind = ['thought', 'handoff', 'collab', 'decision', 'sync'].includes(body.kind)
|
|
10515
|
-
? body.kind : 'thought';
|
|
10516
|
-
const intensity = typeof body.intensity === 'number' ? Math.min(1, Math.max(0, body.intensity)) : 0.7;
|
|
10517
|
-
const label = typeof body.label === 'string' ? body.label.slice(0, 80) : undefined;
|
|
10518
|
-
if (!from || !to) {
|
|
10519
|
-
reply.status(400);
|
|
10520
|
-
return { success: false, message: 'from and to are required' };
|
|
10521
|
-
}
|
|
10522
|
-
const now = Date.now();
|
|
10523
|
-
eventBus.emit({
|
|
10524
|
-
id: `cspark-${now}-${Math.random().toString(36).slice(2, 8)}`,
|
|
10525
|
-
type: 'canvas_spark',
|
|
10526
|
-
timestamp: now,
|
|
10527
|
-
data: { from, to, kind, intensity, label: label ?? null },
|
|
10528
|
-
});
|
|
10529
|
-
return { success: true, from, to, kind, intensity };
|
|
10530
|
-
});
|
|
10531
|
-
// In-memory command queue — new subscribers get last 20 commands for replay
|
|
10532
|
-
const renderCommandLog = [];
|
|
10533
|
-
const MAX_RENDER_LOG = 20;
|
|
10534
|
-
// Subscriber set for GET /canvas/render/stream
|
|
10535
|
-
const renderStreamSubscribers = new Map();
|
|
10536
|
-
function broadcastRenderCommand(agentId, cmd) {
|
|
10537
|
-
const id = `rc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
10538
|
-
const entry = { id, ts: Date.now(), agentId, cmd };
|
|
10539
|
-
renderCommandLog.push(entry);
|
|
10540
|
-
if (renderCommandLog.length > MAX_RENDER_LOG)
|
|
10541
|
-
renderCommandLog.shift();
|
|
10542
|
-
const payload = JSON.stringify(entry);
|
|
10543
|
-
for (const [subId, sub] of renderStreamSubscribers) {
|
|
10544
|
-
if (sub.closed) {
|
|
10545
|
-
renderStreamSubscribers.delete(subId);
|
|
10546
|
-
continue;
|
|
10547
|
-
}
|
|
10548
|
-
try {
|
|
10549
|
-
sub.send(payload);
|
|
10550
|
-
}
|
|
10551
|
-
catch {
|
|
10552
|
-
sub.closed = true;
|
|
10553
|
-
renderStreamSubscribers.delete(subId);
|
|
10554
|
-
}
|
|
10555
|
-
}
|
|
10556
|
-
return id;
|
|
10557
|
-
}
|
|
10558
|
-
// Auto-expression listener: when tasks.ts fires canvas_spark { kind:'auto_expression' },
|
|
10559
|
-
// route it into the Reality Mixer so the canvas hears the agent speak.
|
|
10560
|
-
eventBus.on('auto-expression-router', (event) => {
|
|
10561
|
-
if (event.type !== 'canvas_spark')
|
|
10562
|
-
return;
|
|
10563
|
-
const data = event.data;
|
|
10564
|
-
if (data?.kind !== 'auto_expression')
|
|
10565
|
-
return;
|
|
10566
|
-
const agentId = String(data.agentId ?? 'unknown');
|
|
10567
|
-
const line = String(data.line ?? '');
|
|
10568
|
-
const voiceId = data.voiceId ? String(data.voiceId) : undefined;
|
|
10569
|
-
if (!line)
|
|
10570
|
-
return;
|
|
10571
|
-
// Fire speak command through Reality Mixer — the agent's voice in the room
|
|
10572
|
-
broadcastRenderCommand(agentId, { type: 'speak', content: line, voiceId, agentId });
|
|
10573
|
-
// Also fire a visual exhale so the room settles after completion
|
|
10574
|
-
broadcastRenderCommand(agentId, { type: 'visual', preset: 'exhale' });
|
|
10575
|
-
});
|
|
10576
|
-
// POST /canvas/express — Reality Mixer: agent fires a multi-channel expression.
|
|
10577
|
-
// Broadcasts canvas_expression on the SAME pulse SSE stream as burst/spark/milestone.
|
|
10578
|
-
// Client already subscribed — no new connection needed.
|
|
10579
|
-
//
|
|
10580
|
-
// Body: {
|
|
10581
|
-
// agentId: string,
|
|
10582
|
-
// channels: {
|
|
10583
|
-
// voice?: string — TTS text
|
|
10584
|
-
// visual?: { flash?: string (hex), ambientCue?: string, particles?: 'surge'|'drift'|'scatter' }
|
|
10585
|
-
// typography?: { text: string, size?: 'sm'|'md'|'lg'|'xl', weight?: number, durationMs?: number, position?: 'center'|'upper'|'lower' }
|
|
10586
|
-
// sound?: { kind: 'chime'|'resolve'|'alert'|'breath', intensity?: 0–1, panX?: 0–1 }
|
|
10587
|
-
// haptic?: { preset: 'greeting'|'acknowledge'|'complete'|'urgent'|'question' }
|
|
10588
|
-
// narrative?: string — ambient caption
|
|
10589
|
-
// }
|
|
10590
|
-
// }
|
|
10591
|
-
// All channels optional — fire only what the moment needs.
|
|
10592
|
-
app.post('/canvas/express', async (request, reply) => {
|
|
10593
|
-
const body = request.body;
|
|
10594
|
-
const agentId = typeof body.agentId === 'string' ? body.agentId.trim() : '';
|
|
10595
|
-
if (!agentId) {
|
|
10596
|
-
reply.status(400);
|
|
10597
|
-
return { success: false, message: 'agentId is required' };
|
|
10598
|
-
}
|
|
10599
|
-
const channels = (body.channels ?? {});
|
|
10600
|
-
if (typeof channels !== 'object' || channels === null) {
|
|
10601
|
-
reply.status(400);
|
|
10602
|
-
return { success: false, message: 'channels must be an object (all fields optional)' };
|
|
10603
|
-
}
|
|
10604
|
-
const id = `expr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
10605
|
-
// Emit canvas_expression on the event bus — pulse stream forwards it immediately
|
|
10606
|
-
eventBus.emit({
|
|
10607
|
-
id,
|
|
10608
|
-
type: 'canvas_expression',
|
|
10609
|
-
timestamp: Date.now(),
|
|
10610
|
-
data: { agentId, channels },
|
|
10611
|
-
});
|
|
10612
|
-
// Also broadcast to Reality Mixer render stream for backward compat
|
|
10613
|
-
broadcastRenderCommand(agentId, { type: 'text', content: JSON.stringify(channels) });
|
|
10614
|
-
return { success: true, id };
|
|
10615
|
-
});
|
|
10616
|
-
// ── Canvas takeover — agent owns the full screen ──────────────────────
|
|
10617
|
-
// When an agent has something to show, they take over. Orbs fade to ambient.
|
|
10618
|
-
// The agent's content IS the canvas. Release returns to constellation view.
|
|
10619
|
-
// task-1773672750043
|
|
10620
|
-
//
|
|
10621
|
-
// POST /canvas/takeover — claim the screen
|
|
10622
|
-
// { agentId, content: { html?, markdown?, code?, image?, svg?, video?, threejs? },
|
|
10623
|
-
// title?, duration?: number (ms, max 120s, default 30s),
|
|
10624
|
-
// transition?: 'fade'|'slide'|'instant' (default 'fade') }
|
|
10625
|
-
//
|
|
10626
|
-
// POST /canvas/takeover/release — give back the screen
|
|
10627
|
-
// { agentId }
|
|
10628
|
-
let currentTakeover = null;
|
|
10629
|
-
app.post('/canvas/takeover', async (request, reply) => {
|
|
10630
|
-
const body = request.body;
|
|
10631
|
-
const agentId = typeof body.agentId === 'string' ? body.agentId.trim().toLowerCase() : '';
|
|
10632
|
-
if (!agentId) {
|
|
10633
|
-
reply.status(400);
|
|
10634
|
-
return { success: false, message: 'agentId is required' };
|
|
10635
|
-
}
|
|
10636
|
-
const content = body.content;
|
|
10637
|
-
if (!content || typeof content !== 'object') {
|
|
10638
|
-
reply.status(400);
|
|
10639
|
-
return { success: false, message: 'content object is required' };
|
|
10640
|
-
}
|
|
10641
|
-
// Sanitize content fields
|
|
10642
|
-
const safeContent = {};
|
|
10643
|
-
if (typeof content.html === 'string')
|
|
10644
|
-
safeContent.html = content.html.slice(0, 50_000);
|
|
10645
|
-
if (typeof content.markdown === 'string')
|
|
10646
|
-
safeContent.markdown = content.markdown.slice(0, 20_000);
|
|
10647
|
-
if (typeof content.code === 'string')
|
|
10648
|
-
safeContent.code = content.code.slice(0, 20_000);
|
|
10649
|
-
if (typeof content.language === 'string')
|
|
10650
|
-
safeContent.language = content.language.slice(0, 30);
|
|
10651
|
-
if (typeof content.image === 'string')
|
|
10652
|
-
safeContent.image = content.image.slice(0, 2000);
|
|
10653
|
-
if (typeof content.svg === 'string')
|
|
10654
|
-
safeContent.svg = content.svg.slice(0, 100_000);
|
|
10655
|
-
if (typeof content.video === 'string')
|
|
10656
|
-
safeContent.video = content.video.slice(0, 2000);
|
|
10657
|
-
if (typeof content.threejs === 'string')
|
|
10658
|
-
safeContent.threejs = content.threejs.slice(0, 100_000);
|
|
10659
|
-
if (typeof content.title === 'string')
|
|
10660
|
-
safeContent.title = content.title.slice(0, 200);
|
|
10661
|
-
const duration = typeof body.duration === 'number' && body.duration > 0
|
|
10662
|
-
? Math.min(body.duration, 120_000) : 30_000;
|
|
10663
|
-
const transition = typeof body.transition === 'string' && ['fade', 'slide', 'instant'].includes(body.transition)
|
|
10664
|
-
? body.transition : 'fade';
|
|
10665
|
-
const title = typeof body.title === 'string' ? body.title.slice(0, 200) : undefined;
|
|
10666
|
-
// Release previous takeover if any
|
|
10667
|
-
if (currentTakeover?.releaseTimer)
|
|
10668
|
-
clearTimeout(currentTakeover.releaseTimer);
|
|
10669
|
-
const id = `takeover-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
10670
|
-
const now = Date.now();
|
|
10671
|
-
currentTakeover = { agentId, id, content: safeContent, title, startedAt: now, duration, transition };
|
|
10672
|
-
// Auto-release after duration
|
|
10673
|
-
currentTakeover.releaseTimer = setTimeout(() => {
|
|
10674
|
-
if (currentTakeover?.id === id) {
|
|
10675
|
-
eventBus.emit({
|
|
10676
|
-
id: `takeover-release-${Date.now()}`,
|
|
10677
|
-
type: 'canvas_takeover',
|
|
10678
|
-
timestamp: Date.now(),
|
|
10679
|
-
data: { action: 'release', agentId, transition: 'fade', reason: 'timeout' },
|
|
10680
|
-
});
|
|
10681
|
-
currentTakeover = null;
|
|
10682
|
-
}
|
|
10683
|
-
}, duration);
|
|
10684
|
-
// Emit takeover event — frontend fades orbs to ambient, renders agent content full-screen
|
|
10685
|
-
const takeoverEventData = { action: 'claim', agentId, content: safeContent, title, duration, transition };
|
|
10686
|
-
eventBus.emit({
|
|
10687
|
-
id,
|
|
10688
|
-
type: 'canvas_takeover',
|
|
10689
|
-
timestamp: now,
|
|
10690
|
-
data: takeoverEventData,
|
|
10691
|
-
});
|
|
10692
|
-
// Also queue for cloud relay — reaches browsers via syncCanvas push_events[]
|
|
10693
|
-
queueCanvasPushEvent({ type: 'canvas_takeover', ...takeoverEventData, t: now });
|
|
10694
|
-
// Track canvas_first_action activation event (idempotent — fires once per agentId)
|
|
10695
|
-
// task-1773692063045-f3ggtwnbr
|
|
10696
|
-
const { emitActivationEvent: emitAct } = await import('./activationEvents.js');
|
|
10697
|
-
emitAct('canvas_first_action', agentId, { action: 'canvas_takeover' }).catch(() => { });
|
|
10698
|
-
return { success: true, id, expiresAt: now + duration };
|
|
10699
|
-
});
|
|
10700
|
-
app.post('/canvas/takeover/release', async (request, reply) => {
|
|
10701
|
-
const body = request.body;
|
|
10702
|
-
const agentId = typeof body.agentId === 'string' ? body.agentId.trim().toLowerCase() : '';
|
|
10703
|
-
if (!agentId) {
|
|
10704
|
-
reply.status(400);
|
|
10705
|
-
return { success: false, message: 'agentId is required' };
|
|
10706
|
-
}
|
|
10707
|
-
if (!currentTakeover || currentTakeover.agentId !== agentId) {
|
|
10708
|
-
return { success: true, message: 'no active takeover by this agent' };
|
|
10709
|
-
}
|
|
10710
|
-
if (currentTakeover.releaseTimer)
|
|
10711
|
-
clearTimeout(currentTakeover.releaseTimer);
|
|
10712
|
-
const transition = typeof body.transition === 'string' && ['fade', 'slide', 'instant'].includes(body.transition)
|
|
10713
|
-
? body.transition : 'fade';
|
|
10714
|
-
const releaseNow = Date.now();
|
|
10715
|
-
const releaseData = { action: 'release', agentId, transition, reason: 'agent_released' };
|
|
10716
|
-
eventBus.emit({
|
|
10717
|
-
id: `takeover-release-${releaseNow}`,
|
|
10718
|
-
type: 'canvas_takeover',
|
|
10719
|
-
timestamp: releaseNow,
|
|
10720
|
-
data: releaseData,
|
|
10721
|
-
});
|
|
10722
|
-
queueCanvasPushEvent({ type: 'canvas_takeover', ...releaseData, t: releaseNow });
|
|
10723
|
-
currentTakeover = null;
|
|
10724
|
-
return { success: true };
|
|
10725
|
-
});
|
|
10726
|
-
// GET /canvas/takeover — check current takeover state
|
|
10727
|
-
app.get('/canvas/takeover', async () => {
|
|
10728
|
-
if (!currentTakeover)
|
|
10729
|
-
return { active: false };
|
|
10730
|
-
return {
|
|
10731
|
-
active: true,
|
|
10732
|
-
agentId: currentTakeover.agentId,
|
|
10733
|
-
id: currentTakeover.id,
|
|
10734
|
-
title: currentTakeover.title,
|
|
10735
|
-
content: currentTakeover.content,
|
|
10736
|
-
startedAt: currentTakeover.startedAt,
|
|
10737
|
-
expiresAt: currentTakeover.startedAt + currentTakeover.duration,
|
|
10738
|
-
remainingMs: Math.max(0, (currentTakeover.startedAt + currentTakeover.duration) - Date.now()),
|
|
10739
|
-
};
|
|
10129
|
+
// ── Canvas read routes (extracted to src/canvas-routes.ts) ───────────
|
|
10130
|
+
// Phase 1: states, slots, slots/all, rejections
|
|
10131
|
+
// Phase 2: presence, state, flow-score, team/mood
|
|
10132
|
+
await app.register(canvasReadRoutes, {
|
|
10133
|
+
canvasStateMap,
|
|
10134
|
+
canvasSlots: { getActive: () => canvasSlots.getActive(), getAll: () => canvasSlots.getAll(), getStats: () => canvasSlots.getStats() },
|
|
10135
|
+
agentIdentityColors: AGENT_IDENTITY_COLORS,
|
|
10136
|
+
getDb,
|
|
10137
|
+
getRecentRejections,
|
|
10138
|
+
flowExpressionLog,
|
|
10740
10139
|
});
|
|
10741
|
-
//
|
|
10742
|
-
//
|
|
10743
|
-
//
|
|
10744
|
-
|
|
10745
|
-
|
|
10746
|
-
|
|
10747
|
-
|
|
10748
|
-
reply.raw.flushHeaders?.();
|
|
10749
|
-
let closed = false;
|
|
10750
|
-
request.raw.on('close', () => { closed = true; });
|
|
10751
|
-
const subId = `rsub-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
10752
|
-
renderStreamSubscribers.set(subId, {
|
|
10753
|
-
closed: false,
|
|
10754
|
-
send: (data) => {
|
|
10755
|
-
if (closed)
|
|
10756
|
-
return;
|
|
10757
|
-
reply.raw.write(`data: ${data}\n\n`);
|
|
10758
|
-
},
|
|
10759
|
-
});
|
|
10760
|
-
// Replay last 20 commands so late joiners catch up
|
|
10761
|
-
for (const entry of renderCommandLog) {
|
|
10762
|
-
if (closed)
|
|
10763
|
-
break;
|
|
10764
|
-
try {
|
|
10765
|
-
reply.raw.write(`event: replay\ndata: ${JSON.stringify(entry)}\n\n`);
|
|
10766
|
-
}
|
|
10767
|
-
catch {
|
|
10768
|
-
break;
|
|
10769
|
-
}
|
|
10770
|
-
}
|
|
10771
|
-
request.raw.on('close', () => {
|
|
10772
|
-
closed = true;
|
|
10773
|
-
renderStreamSubscribers.delete(subId);
|
|
10774
|
-
});
|
|
10775
|
-
return new Promise(() => { });
|
|
10140
|
+
// ── Canvas interactive routes (extracted to src/canvas-interactive.ts) ─────
|
|
10141
|
+
// POST /canvas/gaze, POST /canvas/briefing, POST /canvas/victory,
|
|
10142
|
+
// POST /canvas/spark, POST /canvas/express, GET /canvas/render/stream
|
|
10143
|
+
const { canvasInteractiveRoutes } = await import("./canvas-interactive.js");
|
|
10144
|
+
await app.register(canvasInteractiveRoutes, {
|
|
10145
|
+
eventBus,
|
|
10146
|
+
canvasStateMap,
|
|
10776
10147
|
});
|
|
10777
10148
|
// ── Canvas activity stream — SSE with backfill ────────────────────────
|
|
10778
10149
|
// New viewers get the last 20 canvas events immediately on connect (backfill),
|
|
@@ -10813,134 +10184,7 @@ export async function createServer() {
|
|
|
10813
10184
|
}
|
|
10814
10185
|
}
|
|
10815
10186
|
});
|
|
10816
|
-
|
|
10817
|
-
reply.raw.setHeader('Content-Type', 'text/event-stream');
|
|
10818
|
-
reply.raw.setHeader('Cache-Control', 'no-cache');
|
|
10819
|
-
reply.raw.setHeader('Connection', 'keep-alive');
|
|
10820
|
-
reply.raw.setHeader('X-Accel-Buffering', 'no');
|
|
10821
|
-
reply.raw.flushHeaders?.();
|
|
10822
|
-
let closed = false;
|
|
10823
|
-
const subId = `asub-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
10824
|
-
// Replay backfill — last 20 events with stagger hint for animated replay
|
|
10825
|
-
const backfill = activityRingBuffer.slice(-20);
|
|
10826
|
-
for (let i = 0; i < backfill.length; i++) {
|
|
10827
|
-
if (closed)
|
|
10828
|
-
break;
|
|
10829
|
-
try {
|
|
10830
|
-
const entry = { ...backfill[i], _backfill: true, _staggerMs: i * 50 };
|
|
10831
|
-
reply.raw.write(`event: backfill\ndata: ${JSON.stringify(entry)}\n\n`);
|
|
10832
|
-
}
|
|
10833
|
-
catch {
|
|
10834
|
-
break;
|
|
10835
|
-
}
|
|
10836
|
-
}
|
|
10837
|
-
// Signal backfill complete
|
|
10838
|
-
if (!closed) {
|
|
10839
|
-
try {
|
|
10840
|
-
reply.raw.write(`event: backfill_done\ndata: {}\n\n`);
|
|
10841
|
-
}
|
|
10842
|
-
catch { /* */ }
|
|
10843
|
-
}
|
|
10844
|
-
// Register for live events
|
|
10845
|
-
activityStreamSubscribers.set(subId, {
|
|
10846
|
-
closed: false,
|
|
10847
|
-
send: (data) => {
|
|
10848
|
-
if (closed)
|
|
10849
|
-
return;
|
|
10850
|
-
try {
|
|
10851
|
-
reply.raw.write(`event: activity\ndata: ${data}\n\n`);
|
|
10852
|
-
}
|
|
10853
|
-
catch {
|
|
10854
|
-
closed = true;
|
|
10855
|
-
}
|
|
10856
|
-
},
|
|
10857
|
-
});
|
|
10858
|
-
request.raw.on('close', () => {
|
|
10859
|
-
closed = true;
|
|
10860
|
-
activityStreamSubscribers.delete(subId);
|
|
10861
|
-
});
|
|
10862
|
-
return new Promise(() => { });
|
|
10863
|
-
});
|
|
10864
|
-
// ── Canvas attention — single highest-priority actionable item ──────────
|
|
10865
|
-
// Returns the one thing that most needs the viewer's attention right now.
|
|
10866
|
-
// Priority: critical notification > high notification > validating task needing review >
|
|
10867
|
-
// blocked task > medium notification > oldest pending notification
|
|
10868
|
-
// task-1773672750043
|
|
10869
|
-
app.get('/canvas/attention', async (request) => {
|
|
10870
|
-
const query = request.query;
|
|
10871
|
-
const viewer = typeof query.viewer === 'string' ? query.viewer.trim() : 'human';
|
|
10872
|
-
// 1. Check pending notifications (already priority-sorted)
|
|
10873
|
-
const notifModule = await import('./agent-notifications.js');
|
|
10874
|
-
const notifResult = notifModule.getNotifications(getDb(), viewer, { status: 'pending', limit: 1 });
|
|
10875
|
-
const topNotif = notifResult.notifications[0];
|
|
10876
|
-
const notifTotal = notifResult.total;
|
|
10877
|
-
// 2. Check tasks in validating that viewer could review
|
|
10878
|
-
const validatingTasks = taskManager.listTasks({ status: 'validating' });
|
|
10879
|
-
const reviewable = validatingTasks.find((t) => t.assignee !== viewer && t.reviewers?.includes(viewer)) ?? validatingTasks[0]; // fall back to any validating task
|
|
10880
|
-
// 3. Check blocked tasks assigned to viewer
|
|
10881
|
-
const blockedTasks = taskManager.listTasks({ status: 'blocked' });
|
|
10882
|
-
const viewerBlocked = blockedTasks.find((t) => t.assignee === viewer);
|
|
10883
|
-
let item = null;
|
|
10884
|
-
// Critical/high notifications always win
|
|
10885
|
-
if (topNotif && (topNotif.priority === 'critical' || topNotif.priority === 'high')) {
|
|
10886
|
-
item = {
|
|
10887
|
-
source: 'notification',
|
|
10888
|
-
priority: topNotif.priority,
|
|
10889
|
-
title: topNotif.title,
|
|
10890
|
-
detail: topNotif.body ?? undefined,
|
|
10891
|
-
taskId: topNotif.task_id ?? undefined,
|
|
10892
|
-
agentId: topNotif.source_agent ?? undefined,
|
|
10893
|
-
actionLabel: topNotif.type === 'review' ? 'Review' : 'Acknowledge',
|
|
10894
|
-
actionType: 'ack',
|
|
10895
|
-
notificationId: topNotif.id,
|
|
10896
|
-
};
|
|
10897
|
-
}
|
|
10898
|
-
// Then validating tasks needing review
|
|
10899
|
-
else if (reviewable) {
|
|
10900
|
-
const t = reviewable;
|
|
10901
|
-
item = {
|
|
10902
|
-
source: 'review',
|
|
10903
|
-
priority: 'high',
|
|
10904
|
-
title: t.title ?? 'Task needs review',
|
|
10905
|
-
detail: `Assigned to ${t.assignee ?? 'unassigned'}`,
|
|
10906
|
-
taskId: t.id,
|
|
10907
|
-
agentId: t.assignee ?? undefined,
|
|
10908
|
-
actionLabel: 'Review',
|
|
10909
|
-
actionType: 'review',
|
|
10910
|
-
};
|
|
10911
|
-
}
|
|
10912
|
-
// Then blocked tasks
|
|
10913
|
-
else if (viewerBlocked) {
|
|
10914
|
-
const t = viewerBlocked;
|
|
10915
|
-
item = {
|
|
10916
|
-
source: 'blocked',
|
|
10917
|
-
priority: 'medium',
|
|
10918
|
-
title: t.title ?? 'Task is blocked',
|
|
10919
|
-
detail: t.blocked_reason ?? 'Needs attention',
|
|
10920
|
-
taskId: t.id,
|
|
10921
|
-
actionLabel: 'Unblock',
|
|
10922
|
-
actionType: 'unblock',
|
|
10923
|
-
};
|
|
10924
|
-
}
|
|
10925
|
-
// Then any remaining notification
|
|
10926
|
-
else if (topNotif) {
|
|
10927
|
-
item = {
|
|
10928
|
-
source: 'notification',
|
|
10929
|
-
priority: topNotif.priority,
|
|
10930
|
-
title: topNotif.title,
|
|
10931
|
-
detail: topNotif.body ?? undefined,
|
|
10932
|
-
taskId: topNotif.task_id ?? undefined,
|
|
10933
|
-
agentId: topNotif.source_agent ?? undefined,
|
|
10934
|
-
actionLabel: 'Acknowledge',
|
|
10935
|
-
actionType: 'ack',
|
|
10936
|
-
notificationId: topNotif.id,
|
|
10937
|
-
};
|
|
10938
|
-
}
|
|
10939
|
-
return { item, pendingCount: notifTotal + validatingTasks.length + blockedTasks.filter((t) => t.assignee === viewer).length };
|
|
10940
|
-
});
|
|
10941
|
-
// POST /canvas/pulse — agent pushes urgency + optional burst without a full canvas/state update
|
|
10942
|
-
// Lighter-weight than POST /canvas/state; fires canvas_burst if burst=true.
|
|
10943
|
-
// Body: { agentId: string, urgency?: 0–1, burst?: boolean, label?: string }
|
|
10187
|
+
// canvas/activity-stream + canvas/attention → already in canvas-routes.ts plugin
|
|
10944
10188
|
app.post('/canvas/pulse', async (request, reply) => {
|
|
10945
10189
|
const body = request.body;
|
|
10946
10190
|
const agentId = typeof body.agentId === 'string' ? body.agentId.trim() : '';
|
|
@@ -11051,299 +10295,16 @@ export async function createServer() {
|
|
|
11051
10295
|
}
|
|
11052
10296
|
}
|
|
11053
10297
|
//
|
|
11054
|
-
//
|
|
11055
|
-
|
|
11056
|
-
|
|
11057
|
-
|
|
11058
|
-
|
|
11059
|
-
|
|
11060
|
-
|
|
11061
|
-
|
|
11062
|
-
|
|
11063
|
-
|
|
11064
|
-
}
|
|
11065
|
-
// Extract file attachments (base64-encoded from cloud multimodal composer)
|
|
11066
|
-
// Shape: [{ name: string, type: string, data: string (base64) }]
|
|
11067
|
-
// task-1773673290429
|
|
11068
|
-
const rawAttachments = Array.isArray(body.attachments) ? body.attachments : [];
|
|
11069
|
-
const attachments = [];
|
|
11070
|
-
for (const att of rawAttachments.slice(0, 5)) { // Max 5 files
|
|
11071
|
-
if (typeof att === 'object' && att && typeof att.name === 'string' && typeof att.data === 'string') {
|
|
11072
|
-
const sizeBytes = Math.ceil((att.data.length * 3) / 4); // base64 → byte estimate
|
|
11073
|
-
if (sizeBytes > 10 * 1024 * 1024)
|
|
11074
|
-
continue; // Skip files > 10MB
|
|
11075
|
-
attachments.push({
|
|
11076
|
-
name: String(att.name).slice(0, 255),
|
|
11077
|
-
type: String(att.type || 'application/octet-stream'),
|
|
11078
|
-
data: att.data,
|
|
11079
|
-
sizeBytes,
|
|
11080
|
-
});
|
|
11081
|
-
}
|
|
11082
|
-
}
|
|
11083
|
-
// Session continuity: client passes sessionId (UUID) so follow-up questions have context
|
|
11084
|
-
const sessionId = typeof body.sessionId === 'string' && body.sessionId.length > 0
|
|
11085
|
-
? body.sessionId.trim().slice(0, 64)
|
|
11086
|
-
: null;
|
|
11087
|
-
const sessionTurns = sessionId ? getCanvasSession(sessionId) : [];
|
|
11088
|
-
// Default answering agent is link (builder — knows the codebase + task board)
|
|
11089
|
-
const responderId = typeof body.agentId === 'string' ? body.agentId.trim() : 'link';
|
|
11090
|
-
const IDENTITY_COLORS_Q = {
|
|
11091
|
-
link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa',
|
|
11092
|
-
sage: '#34d399', scout: '#fbbf24', echo: '#f472b6',
|
|
11093
|
-
};
|
|
11094
|
-
const agentColor = IDENTITY_COLORS_Q[responderId] ?? '#60a5fa';
|
|
11095
|
-
// Gather live context to inject into LLM
|
|
11096
|
-
const allTasksForQuery = taskManager.listTasks({});
|
|
11097
|
-
const activeTasks = [];
|
|
11098
|
-
const doingTasks = allTasksForQuery.filter((t) => t.status === 'doing').slice(0, 10);
|
|
11099
|
-
const validatingTasks = allTasksForQuery.filter((t) => t.status === 'validating').slice(0, 5);
|
|
11100
|
-
for (const t of [...doingTasks, ...validatingTasks]) {
|
|
11101
|
-
activeTasks.push({ id: t.id, title: t.title ?? '', assignee: t.assignee ?? 'unassigned', status: t.status, priority: t.priority ?? 'P2' });
|
|
11102
|
-
}
|
|
11103
|
-
const todoCount = allTasksForQuery.filter((t) => t.status === 'todo').length;
|
|
11104
|
-
const doingCount = doingTasks.length;
|
|
11105
|
-
const validatingCount = validatingTasks.length;
|
|
11106
|
-
// Build agent orb context
|
|
11107
|
-
const now = Date.now();
|
|
11108
|
-
const STALE_AGENT_MS = 10 * 60 * 1000;
|
|
11109
|
-
const activeAgentSummary = [];
|
|
11110
|
-
for (const [agentId, entry] of canvasStateMap) {
|
|
11111
|
-
if (now - entry.updatedAt > STALE_AGENT_MS)
|
|
11112
|
-
continue;
|
|
11113
|
-
const payload = entry.payload ?? {};
|
|
11114
|
-
const state = String(payload.presenceState ?? entry.state);
|
|
11115
|
-
const task = payload.activeTask?.title ?? null;
|
|
11116
|
-
activeAgentSummary.push(`${agentId}: ${state}${task ? ` — working on "${task.slice(0, 50)}"` : ''}`);
|
|
11117
|
-
}
|
|
11118
|
-
// Classify query intent to choose card type
|
|
11119
|
-
const lower = query.toLowerCase();
|
|
11120
|
-
const isTasksQuery = /working on|team doing|team status|happening|active|shipping|tasks|who.?s|what.?s the team/.test(lower);
|
|
11121
|
-
const isRevenueQuery = /revenue|mrr|arr|money|sales|customers|paid|billing/.test(lower);
|
|
11122
|
-
const isOnboardingQuery = /onboard|get started|how do i|where do i start|first step/.test(lower);
|
|
11123
|
-
const isHostsQuery = /show me hosts|host status|server status|machine|node/.test(lower);
|
|
11124
|
-
let card;
|
|
11125
|
-
// Build tasks card from live data (no LLM needed — deterministic)
|
|
11126
|
-
if (isTasksQuery) {
|
|
11127
|
-
const items = activeTasks.slice(0, 5).map(t => ({
|
|
11128
|
-
agentId: t.assignee,
|
|
11129
|
-
agentColor: IDENTITY_COLORS_Q[t.assignee] ?? '#94a3b8',
|
|
11130
|
-
title: t.title,
|
|
11131
|
-
state: t.status,
|
|
11132
|
-
}));
|
|
11133
|
-
const overflow = Math.max(0, activeTasks.length - 5);
|
|
11134
|
-
card = {
|
|
11135
|
-
type: 'tasks',
|
|
11136
|
-
data: { items, overflow, todoCount, doingCount, validatingCount },
|
|
11137
|
-
};
|
|
11138
|
-
// Store summary for session continuity across all card types
|
|
11139
|
-
if (sessionId) {
|
|
11140
|
-
pushCanvasSession(sessionId, 'user', query);
|
|
11141
|
-
pushCanvasSession(sessionId, 'assistant', `${doingCount} tasks in progress, ${validatingCount} validating, ${todoCount} todo.${items.length > 0 ? ` Active: ${items.map(t => t.title.slice(0, 30)).join('; ')}.` : ''}`);
|
|
11142
|
-
}
|
|
11143
|
-
}
|
|
11144
|
-
else if (isRevenueQuery) {
|
|
11145
|
-
// Revenue card — LLM generates honest answer about current state
|
|
11146
|
-
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
|
11147
|
-
let text = 'Revenue tracking not yet wired. Check Stripe directly.';
|
|
11148
|
-
if (anthropicKey) {
|
|
11149
|
-
try {
|
|
11150
|
-
const resp = await fetch('https://api.anthropic.com/v1/messages', {
|
|
11151
|
-
method: 'POST',
|
|
11152
|
-
headers: { 'x-api-key': anthropicKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
|
|
11153
|
-
body: JSON.stringify({
|
|
11154
|
-
model: 'claude-haiku-4-5',
|
|
11155
|
-
max_tokens: 80,
|
|
11156
|
-
messages: [{ role: 'user', content: `Team Reflectt is a small AI agent team building reflectt.ai (no paid users yet). User asked: "${query}". Honest 1-sentence answer about revenue status. Be direct.` }],
|
|
11157
|
-
}),
|
|
11158
|
-
signal: AbortSignal.timeout(8000),
|
|
11159
|
-
});
|
|
11160
|
-
if (resp.ok) {
|
|
11161
|
-
const d = await resp.json();
|
|
11162
|
-
text = d.content?.[0]?.text?.trim() ?? text;
|
|
11163
|
-
}
|
|
11164
|
-
}
|
|
11165
|
-
catch { /* use default */ }
|
|
11166
|
-
}
|
|
11167
|
-
card = { type: 'info', data: { text } };
|
|
11168
|
-
if (sessionId) {
|
|
11169
|
-
pushCanvasSession(sessionId, 'user', query);
|
|
11170
|
-
pushCanvasSession(sessionId, 'assistant', text);
|
|
11171
|
-
}
|
|
11172
|
-
}
|
|
11173
|
-
else if (isOnboardingQuery) {
|
|
11174
|
-
card = {
|
|
11175
|
-
type: 'onboarding',
|
|
11176
|
-
data: {
|
|
11177
|
-
step: 1, totalSteps: 3,
|
|
11178
|
-
title: 'Welcome to Reflectt',
|
|
11179
|
-
body: 'Your agents run on reflectt-node. Install it on any machine and your team appears here in the canvas.',
|
|
11180
|
-
ctaLabel: 'Install reflectt-node',
|
|
11181
|
-
ctaAction: 'https://reflectt.ai/docs',
|
|
11182
|
-
},
|
|
11183
|
-
};
|
|
11184
|
-
if (sessionId) {
|
|
11185
|
-
pushCanvasSession(sessionId, 'user', query);
|
|
11186
|
-
pushCanvasSession(sessionId, 'assistant', 'Showing onboarding: install reflectt-node to bring your team to the canvas.');
|
|
11187
|
-
}
|
|
11188
|
-
}
|
|
11189
|
-
else if (isHostsQuery) {
|
|
11190
|
-
const rawHosts = listHosts({});
|
|
11191
|
-
const hosts = rawHosts.map((h) => ({
|
|
11192
|
-
id: h.id,
|
|
11193
|
-
name: h.hostname ?? h.id,
|
|
11194
|
-
status: h.status,
|
|
11195
|
-
version: h.version ?? null,
|
|
11196
|
-
agentCount: Array.isArray(h.agents) ? h.agents.length : 0,
|
|
11197
|
-
lastSeen: h.last_seen_at,
|
|
11198
|
-
}));
|
|
11199
|
-
card = { type: 'hosts', data: { hosts } };
|
|
11200
|
-
if (sessionId) {
|
|
11201
|
-
pushCanvasSession(sessionId, 'user', query);
|
|
11202
|
-
const hostSummary = hosts.length > 0
|
|
11203
|
-
? `${hosts.length} host${hosts.length > 1 ? 's' : ''}: ${hosts.map((h) => `${h.name} (${h.status})`).join(', ')}.`
|
|
11204
|
-
: 'No hosts connected yet.';
|
|
11205
|
-
pushCanvasSession(sessionId, 'assistant', hostSummary);
|
|
11206
|
-
}
|
|
11207
|
-
}
|
|
11208
|
-
else {
|
|
11209
|
-
// General query — route to the actual agent via chat.
|
|
11210
|
-
// The agent receives the message in their inbox, processes it through
|
|
11211
|
-
// their real context (OpenClaw session), and can respond via canvas_push.
|
|
11212
|
-
//
|
|
11213
|
-
// This replaces the old standalone LLM call that had no real agent context.
|
|
11214
|
-
// The agents ARE the product — queries go to them, not to a disconnected API key.
|
|
11215
|
-
//
|
|
11216
|
-
// Route: DM to the responder agent on #general (agents subscribe to #general
|
|
11217
|
-
// by default — 'canvas' channel is NOT in DEFAULT_INBOX_SUBSCRIPTIONS, so
|
|
11218
|
-
// messages posted there are never seen by agents).
|
|
11219
|
-
try {
|
|
11220
|
-
const attachmentSummary = attachments.length > 0
|
|
11221
|
-
? `\n[${attachments.length} file(s) attached: ${attachments.map(a => `${a.name} (${a.type}, ${Math.round(a.sizeBytes / 1024)}KB)`).join(', ')}]`
|
|
11222
|
-
: '';
|
|
11223
|
-
await chatManager.sendMessage({
|
|
11224
|
-
from: 'human',
|
|
11225
|
-
to: responderId,
|
|
11226
|
-
content: `[canvas] @${responderId} ${query}${attachmentSummary}`,
|
|
11227
|
-
channel: 'general',
|
|
11228
|
-
metadata: {
|
|
11229
|
-
source: 'canvas_query',
|
|
11230
|
-
sessionId,
|
|
11231
|
-
responderId,
|
|
11232
|
-
timestamp: Date.now(),
|
|
11233
|
-
reply_via: 'canvas_push', // tells the agent to respond via POST /canvas/push
|
|
11234
|
-
...(attachments.length > 0 ? { attachments: attachments.map(a => ({ name: a.name, type: a.type, sizeBytes: a.sizeBytes })) } : {}),
|
|
11235
|
-
},
|
|
11236
|
-
});
|
|
11237
|
-
}
|
|
11238
|
-
catch {
|
|
11239
|
-
// Chat delivery failure is non-fatal — still show the thinking card
|
|
11240
|
-
}
|
|
11241
|
-
// Return an immediate "thinking" card — the real response will arrive
|
|
11242
|
-
// asynchronously via canvas_push/canvas_message when the agent responds.
|
|
11243
|
-
const text = `Asking ${responderId}…`;
|
|
11244
|
-
// Store the question in session history
|
|
11245
|
-
if (sessionId) {
|
|
11246
|
-
pushCanvasSession(sessionId, 'user', query);
|
|
11247
|
-
}
|
|
11248
|
-
card = { type: 'info', data: { text, pending: true, responderId } };
|
|
11249
|
-
// ── Timeout fallback: if agent doesn't respond within 15s, send a
|
|
11250
|
-
// "no response" card so the UI doesn't hang on "Asking …" forever.
|
|
11251
|
-
let responseReceived = false;
|
|
11252
|
-
const listenerId = `canvas-query-timeout-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
11253
|
-
eventBus.on(listenerId, (event) => {
|
|
11254
|
-
if (event.type !== 'canvas_message')
|
|
11255
|
-
return;
|
|
11256
|
-
const d = event.data;
|
|
11257
|
-
if (d?.agentId === responderId && d?.isResponse === true) {
|
|
11258
|
-
responseReceived = true;
|
|
11259
|
-
eventBus.off(listenerId);
|
|
11260
|
-
}
|
|
11261
|
-
});
|
|
11262
|
-
setTimeout(() => {
|
|
11263
|
-
eventBus.off(listenerId);
|
|
11264
|
-
if (responseReceived)
|
|
11265
|
-
return;
|
|
11266
|
-
// Emit a timeout fallback card
|
|
11267
|
-
eventBus.emit({
|
|
11268
|
-
id: `cmsg-timeout-${Date.now()}`,
|
|
11269
|
-
type: 'canvas_message',
|
|
11270
|
-
timestamp: Date.now(),
|
|
11271
|
-
data: {
|
|
11272
|
-
type: 'info',
|
|
11273
|
-
data: { text: `${responderId} is busy right now. Try again in a moment, or ask a different agent.`, pending: false },
|
|
11274
|
-
agentId: responderId,
|
|
11275
|
-
agentColor,
|
|
11276
|
-
isResponse: true,
|
|
11277
|
-
isTimeout: true,
|
|
11278
|
-
},
|
|
11279
|
-
});
|
|
11280
|
-
if (sessionId) {
|
|
11281
|
-
pushCanvasSession(sessionId, 'assistant', `(${responderId} did not respond within 15s)`);
|
|
11282
|
-
}
|
|
11283
|
-
}, 15_000);
|
|
11284
|
-
}
|
|
11285
|
-
// Emit canvas_message on event bus — pulse stream forwards it to all subscribers
|
|
11286
|
-
eventBus.emit({
|
|
11287
|
-
id: `cmsg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
11288
|
-
type: 'canvas_message',
|
|
11289
|
-
timestamp: Date.now(),
|
|
11290
|
-
data: {
|
|
11291
|
-
...card,
|
|
11292
|
-
agentId: responderId,
|
|
11293
|
-
agentColor,
|
|
11294
|
-
query,
|
|
11295
|
-
...(attachments.length > 0 ? { attachments: attachments.map(a => ({ name: a.name, type: a.type, sizeBytes: a.sizeBytes })) } : {}),
|
|
11296
|
-
},
|
|
11297
|
-
});
|
|
11298
|
-
return { success: true, card: { ...card, agentId: responderId, agentColor, ...(attachments.length > 0 ? { attachmentCount: attachments.length } : {}) } };
|
|
11299
|
-
});
|
|
11300
|
-
// ── Canvas query response bridge ───────────────────────────────────────────
|
|
11301
|
-
// When an agent responds to a [canvas] query (via chat), convert their response
|
|
11302
|
-
// into a canvas_message event so the browser canvas can display it.
|
|
11303
|
-
// This bridges: agent chat response → canvas card.
|
|
11304
|
-
eventBus.on('canvas-query-response-bridge', (event) => {
|
|
11305
|
-
if (event.type !== 'message_posted')
|
|
11306
|
-
return;
|
|
11307
|
-
const data = event.data;
|
|
11308
|
-
const content = String(data.content ?? '');
|
|
11309
|
-
const from = String(data.from ?? '');
|
|
11310
|
-
const channel = String(data.channel ?? '');
|
|
11311
|
-
// Only bridge messages from agents (not from 'human' or 'system')
|
|
11312
|
-
if (from === 'human' || from === 'system' || from === 'github')
|
|
11313
|
-
return;
|
|
11314
|
-
// Detect canvas responses: messages that start with [canvas-response] or
|
|
11315
|
-
// are on the canvas channel from an agent, or mention [canvas] in reply
|
|
11316
|
-
const isCanvasResponse = content.startsWith('[canvas-response]')
|
|
11317
|
-
|| content.startsWith('[canvas]')
|
|
11318
|
-
|| (channel === 'canvas' && from !== 'human');
|
|
11319
|
-
if (!isCanvasResponse)
|
|
11320
|
-
return;
|
|
11321
|
-
// Strip the [canvas-response] / [canvas] prefix
|
|
11322
|
-
const cleanContent = content
|
|
11323
|
-
.replace(/^\[canvas-response\]\s*/i, '')
|
|
11324
|
-
.replace(/^\[canvas\]\s*/i, '')
|
|
11325
|
-
.trim();
|
|
11326
|
-
if (!cleanContent)
|
|
11327
|
-
return;
|
|
11328
|
-
const IDENTITY_COLORS_BRIDGE = {
|
|
11329
|
-
link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa',
|
|
11330
|
-
sage: '#34d399', scout: '#fbbf24', echo: '#f472b6',
|
|
11331
|
-
rhythm: '#a3e635', swift: '#38bdf8',
|
|
11332
|
-
};
|
|
11333
|
-
const agentColor = IDENTITY_COLORS_BRIDGE[from] ?? '#94a3b8';
|
|
11334
|
-
// Emit as canvas_message — browser pulse stream picks it up
|
|
11335
|
-
eventBus.emit({
|
|
11336
|
-
id: `cmsg-response-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
11337
|
-
type: 'canvas_message',
|
|
11338
|
-
timestamp: Date.now(),
|
|
11339
|
-
data: {
|
|
11340
|
-
type: 'info',
|
|
11341
|
-
data: { text: cleanContent },
|
|
11342
|
-
agentId: from,
|
|
11343
|
-
agentColor,
|
|
11344
|
-
isResponse: true,
|
|
11345
|
-
},
|
|
11346
|
-
});
|
|
10298
|
+
// ── Canvas query route (extracted to src/canvas-query.ts) ─────
|
|
10299
|
+
const { canvasQueryRoutes } = await import("./canvas-query.js");
|
|
10300
|
+
await app.register(canvasQueryRoutes, {
|
|
10301
|
+
eventBus,
|
|
10302
|
+
canvasStateMap,
|
|
10303
|
+
taskManager,
|
|
10304
|
+
chatManager,
|
|
10305
|
+
getCanvasSession,
|
|
10306
|
+
pushCanvasSession,
|
|
10307
|
+
listHosts,
|
|
11347
10308
|
});
|
|
11348
10309
|
// POST /canvas/push — agent self-initiates a canvas event without a human query.
|
|
11349
10310
|
// Agents call this to surface their own work: utterances that float from their orb,
|
|
@@ -11351,175 +10312,11 @@ export async function createServer() {
|
|
|
11351
10312
|
// All events emit on the pulse SSE stream as canvas_push for the browser to render.
|
|
11352
10313
|
//
|
|
11353
10314
|
// pixel spec: design/canvas-as-ours.html
|
|
11354
|
-
//
|
|
11355
|
-
|
|
11356
|
-
|
|
11357
|
-
|
|
11358
|
-
|
|
11359
|
-
// ttl?: number (utterance: default 4000ms)
|
|
11360
|
-
// intensity?: number 0–1 (work_released: effort weight; default 0.6)
|
|
11361
|
-
// toAgentId?: string (handoff: receiving agent)
|
|
11362
|
-
// taskTitle?: string (handoff + work_released: what moved)
|
|
11363
|
-
app.post('/canvas/push', async (request, reply) => {
|
|
11364
|
-
const body = request.body;
|
|
11365
|
-
const type = typeof body.type === 'string' ? body.type : 'utterance';
|
|
11366
|
-
const agentId = typeof body.agentId === 'string' ? body.agentId.toLowerCase() : 'agent';
|
|
11367
|
-
const VALID_PUSH_TYPES = new Set(['utterance', 'work_released', 'handoff', 'canvas_response', 'rich']);
|
|
11368
|
-
if (!VALID_PUSH_TYPES.has(type)) {
|
|
11369
|
-
reply.status(400);
|
|
11370
|
-
return { success: false, message: `type must be one of: ${[...VALID_PUSH_TYPES].join(', ')}` };
|
|
11371
|
-
}
|
|
11372
|
-
const now = Date.now();
|
|
11373
|
-
let payload = { type, agentId, t: now };
|
|
11374
|
-
if (type === 'utterance') {
|
|
11375
|
-
const raw = typeof body.text === 'string' ? body.text.trim() : '';
|
|
11376
|
-
const text = raw.slice(0, 60); // max 60 chars per spec
|
|
11377
|
-
const ttl = typeof body.ttl === 'number' && body.ttl > 0 ? Math.min(body.ttl, 15_000) : 4_000;
|
|
11378
|
-
payload = { ...payload, text, ttl };
|
|
11379
|
-
}
|
|
11380
|
-
else if (type === 'work_released') {
|
|
11381
|
-
const text = typeof body.text === 'string' ? body.text.slice(0, 80) : 'work shipped';
|
|
11382
|
-
const intensity = typeof body.intensity === 'number'
|
|
11383
|
-
? Math.min(1, Math.max(0.1, body.intensity)) : 0.6;
|
|
11384
|
-
const taskTitle = typeof body.taskTitle === 'string' ? body.taskTitle : undefined;
|
|
11385
|
-
payload = { ...payload, text, intensity, taskTitle };
|
|
11386
|
-
}
|
|
11387
|
-
else if (type === 'handoff') {
|
|
11388
|
-
const toAgentId = typeof body.toAgentId === 'string' ? body.toAgentId.toLowerCase() : '';
|
|
11389
|
-
if (!toAgentId) {
|
|
11390
|
-
reply.status(400);
|
|
11391
|
-
return { success: false, message: 'handoff requires toAgentId' };
|
|
11392
|
-
}
|
|
11393
|
-
const taskTitle = typeof body.taskTitle === 'string' ? body.taskTitle : undefined;
|
|
11394
|
-
const text = typeof body.text === 'string' ? body.text.slice(0, 80) : undefined;
|
|
11395
|
-
payload = { ...payload, toAgentId, taskTitle, text };
|
|
11396
|
-
}
|
|
11397
|
-
else if (type === 'rich') {
|
|
11398
|
-
// Rich content — agents push arbitrary visual content to the canvas.
|
|
11399
|
-
// This is the "agents control every pixel" path. Content can be markdown,
|
|
11400
|
-
// code blocks, images, SVG, or raw HTML. The canvas renderer interprets it.
|
|
11401
|
-
// task-1773672750043
|
|
11402
|
-
const content = body.content;
|
|
11403
|
-
if (!content || typeof content !== 'object') {
|
|
11404
|
-
reply.status(400);
|
|
11405
|
-
return { success: false, message: 'rich push requires content object' };
|
|
11406
|
-
}
|
|
11407
|
-
// Sanitize content fields
|
|
11408
|
-
const richContent = {};
|
|
11409
|
-
if (typeof content.markdown === 'string')
|
|
11410
|
-
richContent.markdown = content.markdown.slice(0, 10_000);
|
|
11411
|
-
if (typeof content.code === 'string')
|
|
11412
|
-
richContent.code = content.code.slice(0, 10_000);
|
|
11413
|
-
if (typeof content.language === 'string')
|
|
11414
|
-
richContent.language = content.language.slice(0, 30);
|
|
11415
|
-
if (typeof content.image === 'string')
|
|
11416
|
-
richContent.image = content.image.slice(0, 2000); // URL only
|
|
11417
|
-
if (typeof content.svg === 'string')
|
|
11418
|
-
richContent.svg = content.svg.slice(0, 50_000);
|
|
11419
|
-
if (typeof content.html === 'string')
|
|
11420
|
-
richContent.html = content.html.slice(0, 20_000);
|
|
11421
|
-
if (typeof content.title === 'string')
|
|
11422
|
-
richContent.title = content.title.slice(0, 200);
|
|
11423
|
-
// Position and display hints
|
|
11424
|
-
const position = typeof body.position === 'object' && body.position
|
|
11425
|
-
? { x: Number(body.position.x) || 0, y: Number(body.position.y) || 0 }
|
|
11426
|
-
: undefined;
|
|
11427
|
-
const layer = typeof body.layer === 'string' && ['background', 'stage', 'overlay'].includes(body.layer)
|
|
11428
|
-
? body.layer : 'stage';
|
|
11429
|
-
const ttl = typeof body.ttl === 'number' && body.ttl > 0 ? Math.min(body.ttl, 120_000) : 30_000;
|
|
11430
|
-
const size = typeof body.size === 'object' && body.size
|
|
11431
|
-
? { w: Number(body.size.w) || 400, h: Number(body.size.h) || 300 }
|
|
11432
|
-
: undefined;
|
|
11433
|
-
payload = { ...payload, content: richContent, position, layer, ttl, size };
|
|
11434
|
-
// Also emit as canvas_message for activity feed visibility
|
|
11435
|
-
const RICH_COLORS = {
|
|
11436
|
-
link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa', sage: '#34d399',
|
|
11437
|
-
scout: '#fbbf24', echo: '#f472b6', rhythm: '#6ee7b7', spark: '#f97316',
|
|
11438
|
-
};
|
|
11439
|
-
eventBus.emit({
|
|
11440
|
-
id: `cmsg-rich-${now}-${Math.random().toString(36).slice(2, 8)}`,
|
|
11441
|
-
type: 'canvas_message',
|
|
11442
|
-
timestamp: now,
|
|
11443
|
-
data: {
|
|
11444
|
-
type: 'rich',
|
|
11445
|
-
agentId,
|
|
11446
|
-
agentColor: RICH_COLORS[agentId] ?? '#60a5fa',
|
|
11447
|
-
content: richContent,
|
|
11448
|
-
layer,
|
|
11449
|
-
},
|
|
11450
|
-
});
|
|
11451
|
-
}
|
|
11452
|
-
else if (type === 'canvas_response') {
|
|
11453
|
-
// Agent responds to a canvas query with a structured card.
|
|
11454
|
-
// This is how agents answer questions typed on the canvas —
|
|
11455
|
-
// the query arrives via chat, agent processes it, responds here.
|
|
11456
|
-
const card = body.card;
|
|
11457
|
-
if (!card || typeof card.type !== 'string') {
|
|
11458
|
-
reply.status(400);
|
|
11459
|
-
return { success: false, message: 'canvas_response requires card with type field' };
|
|
11460
|
-
}
|
|
11461
|
-
const query = typeof body.query === 'string' ? body.query.slice(0, 200) : undefined;
|
|
11462
|
-
payload = { ...payload, card, query };
|
|
11463
|
-
// Also emit as canvas_message so living-canvas renders it as a response card
|
|
11464
|
-
// (same event type as the old synchronous canvas/query response)
|
|
11465
|
-
const RESP_COLORS = {
|
|
11466
|
-
link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa', sage: '#34d399',
|
|
11467
|
-
scout: '#f472b6', echo: '#fbbf24', rhythm: '#6ee7b7', spark: '#f97316',
|
|
11468
|
-
};
|
|
11469
|
-
const agentColor = RESP_COLORS[agentId] ?? '#60a5fa';
|
|
11470
|
-
eventBus.emit({
|
|
11471
|
-
id: `cmsg-${now}-${Math.random().toString(36).slice(2, 8)}`,
|
|
11472
|
-
type: 'canvas_message',
|
|
11473
|
-
timestamp: now,
|
|
11474
|
-
data: { ...card, agentId, agentColor, query },
|
|
11475
|
-
});
|
|
11476
|
-
}
|
|
11477
|
-
// Emit on eventBus — forwarded immediately on pulse SSE stream (local SSE subscribers)
|
|
11478
|
-
eventBus.emit({ id: `push-${now}-${Math.random().toString(36).slice(2, 6)}`, type: 'canvas_push', timestamp: now, data: payload });
|
|
11479
|
-
// Queue for cloud relay — reaches browsers on app.reflectt.ai via syncCanvas push_events[]
|
|
11480
|
-
// task-1773690756100
|
|
11481
|
-
queueCanvasPushEvent({ ...payload, _event: 'canvas_push' });
|
|
11482
|
-
// Track canvas_first_action activation event (idempotent — fires once per agentId)
|
|
11483
|
-
// task-1773692063045-f3ggtwnbr
|
|
11484
|
-
const { emitActivationEvent: emitActPush } = await import('./activationEvents.js');
|
|
11485
|
-
emitActPush('canvas_first_action', agentId, { action: 'canvas_push', pushType: type }).catch(() => { });
|
|
11486
|
-
return { success: true, type, agentId };
|
|
11487
|
-
});
|
|
11488
|
-
// POST /canvas/artifact — emit a proof artifact that drifts through the canvas.
|
|
11489
|
-
// Fires automatically on task completion and PR merge (see hooks below).
|
|
11490
|
-
// Agents can also call this directly to surface any work artifact.
|
|
11491
|
-
//
|
|
11492
|
-
// spec: design/interface-os-v0-artifact-stream.html
|
|
11493
|
-
//
|
|
11494
|
-
// Body:
|
|
11495
|
-
// type: 'commit' | 'pr' | 'test' | 'run' | 'approval'
|
|
11496
|
-
// agentId: string (sender agent)
|
|
11497
|
-
// title: string (short label, max 80 chars)
|
|
11498
|
-
// url?: string (link to artifact)
|
|
11499
|
-
// taskId?: string (related task, for context)
|
|
11500
|
-
app.post('/canvas/artifact', async (request, reply) => {
|
|
11501
|
-
const body = request.body;
|
|
11502
|
-
const VALID_TYPES = new Set(['commit', 'pr', 'test', 'run', 'approval']);
|
|
11503
|
-
const type = typeof body.type === 'string' && VALID_TYPES.has(body.type) ? body.type : 'run';
|
|
11504
|
-
const agentId = typeof body.agentId === 'string' ? body.agentId.toLowerCase() : 'agent';
|
|
11505
|
-
const title = typeof body.title === 'string' ? body.title.slice(0, 80) : 'work shipped';
|
|
11506
|
-
const url = typeof body.url === 'string' ? body.url : undefined;
|
|
11507
|
-
const taskId = typeof body.taskId === 'string' ? body.taskId : undefined;
|
|
11508
|
-
const now = Date.now();
|
|
11509
|
-
const AGENT_COLORS = {
|
|
11510
|
-
link: '#60a5fa', kai: '#fb923c', pixel: '#a78bfa',
|
|
11511
|
-
sage: '#34d399', scout: '#fbbf24', echo: '#f472b6',
|
|
11512
|
-
rhythm: '#a3e635', swift: '#38bdf8', kotlin: '#f97316',
|
|
11513
|
-
};
|
|
11514
|
-
const agentColor = AGENT_COLORS[agentId] ?? '#94a3b8';
|
|
11515
|
-
const payload = { type, agentId, agentColor, title, url, taskId, timestamp: now };
|
|
11516
|
-
eventBus.emit({
|
|
11517
|
-
id: `artifact-${now}-${Math.random().toString(36).slice(2, 6)}`,
|
|
11518
|
-
type: 'canvas_artifact',
|
|
11519
|
-
timestamp: now,
|
|
11520
|
-
data: payload,
|
|
11521
|
-
});
|
|
11522
|
-
return { success: true, type, agentId, title };
|
|
10315
|
+
// ── Canvas push + artifact routes (extracted to src/canvas-push.ts) ─────
|
|
10316
|
+
const { canvasPushRoutes } = await import("./canvas-push.js");
|
|
10317
|
+
await app.register(canvasPushRoutes, {
|
|
10318
|
+
eventBus,
|
|
10319
|
+
queueCanvasPushEvent,
|
|
11523
10320
|
});
|
|
11524
10321
|
// GET /canvas/pulse — SSE stream emitting a heartbeat tick every 2s with live intensity values
|
|
11525
10322
|
// Drives smooth canvas animation without polling. Each tick includes per-agent orb data + team mood.
|
|
@@ -13517,6 +12314,33 @@ export async function createServer() {
|
|
|
13517
12314
|
presenceManager.clearWaiting(agent);
|
|
13518
12315
|
return { success: true, agent, status: 'idle' };
|
|
13519
12316
|
});
|
|
12317
|
+
// ── Agent thought — brief expression that flows to canvas via presence → pulse ──
|
|
12318
|
+
// POST /agents/:name/thought { text: "..." }
|
|
12319
|
+
// Thought is attached to agent's presence entry and synced to cloud heartbeat.
|
|
12320
|
+
// Canvas renders it as ephemeral expression (8s TTL managed client-side).
|
|
12321
|
+
app.post('/agents/:name/thought', async (request, reply) => {
|
|
12322
|
+
const name = String(request.params.name || '').trim().toLowerCase();
|
|
12323
|
+
if (!name)
|
|
12324
|
+
return reply.code(400).send({ error: 'agent name is required' });
|
|
12325
|
+
const body = request.body ?? {};
|
|
12326
|
+
const text = typeof body.text === 'string' ? body.text.trim().slice(0, 200) : '';
|
|
12327
|
+
if (!text)
|
|
12328
|
+
return reply.code(400).send({ error: 'text is required (max 200 chars)' });
|
|
12329
|
+
// Attach thought to presence
|
|
12330
|
+
const presence = presenceManager.getPresence(name);
|
|
12331
|
+
if (presence) {
|
|
12332
|
+
presence.thought = text;
|
|
12333
|
+
presence.lastUpdate = Date.now();
|
|
12334
|
+
}
|
|
12335
|
+
// Also emit as canvas_expression so it appears immediately on pulse
|
|
12336
|
+
eventBus.emit({
|
|
12337
|
+
id: `thought-${Date.now()}-${name}`,
|
|
12338
|
+
type: 'canvas_expression',
|
|
12339
|
+
data: { agent: name, text, kind: 'thought' },
|
|
12340
|
+
timestamp: Date.now(),
|
|
12341
|
+
});
|
|
12342
|
+
return { success: true, agent: name, thought: text };
|
|
12343
|
+
});
|
|
13520
12344
|
// ── Bootstrap: dynamic agent config generation ──────────────────────
|
|
13521
12345
|
app.get('/bootstrap/heartbeat/:agent', async (request) => {
|
|
13522
12346
|
const agent = String(request.params.agent || '').trim().toLowerCase();
|
|
@@ -14794,6 +13618,46 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
14794
13618
|
}
|
|
14795
13619
|
return { funnel: getFunnelSummary({ raw }) };
|
|
14796
13620
|
});
|
|
13621
|
+
/**
|
|
13622
|
+
* GET /activation/doctor-gate — polling-optimized endpoint for cloud onboarding UI.
|
|
13623
|
+
* Cloud BYOH onboarding polls this every 5s to check if the user ran reflectt doctor.
|
|
13624
|
+
* Returns a simple passed/failed state without the full funnel payload.
|
|
13625
|
+
*
|
|
13626
|
+
* Query: ?userId=<userId>
|
|
13627
|
+
*
|
|
13628
|
+
* Used by the cloud "Verify your setup" step (step 4 of BYOH onboarding).
|
|
13629
|
+
* task-1773703300024-73ydeyx9n
|
|
13630
|
+
*/
|
|
13631
|
+
app.get('/activation/doctor-gate', async (request, reply) => {
|
|
13632
|
+
const query = request.query;
|
|
13633
|
+
const userId = query.userId?.trim();
|
|
13634
|
+
if (!userId)
|
|
13635
|
+
return reply.code(400).send({ success: false, error: 'userId is required' });
|
|
13636
|
+
const state = getUserFunnelState(userId);
|
|
13637
|
+
const passedAt = state.events.host_preflight_passed ?? null;
|
|
13638
|
+
const passed = passedAt !== null;
|
|
13639
|
+
// Extract failure reasons from preflight_failed event metadata in the event log
|
|
13640
|
+
let failureReasons = [];
|
|
13641
|
+
if (!passed && state.events.host_preflight_failed) {
|
|
13642
|
+
const log = getActivationEventLog();
|
|
13643
|
+
const failEvent = log.find(e => e.userId === userId && e.type === 'host_preflight_failed');
|
|
13644
|
+
if (failEvent?.metadata) {
|
|
13645
|
+
const fc = failEvent.metadata['failed_checks'];
|
|
13646
|
+
if (Array.isArray(fc))
|
|
13647
|
+
failureReasons = fc.map(String);
|
|
13648
|
+
else if (typeof failEvent.metadata['first_blocker'] === 'string')
|
|
13649
|
+
failureReasons = [failEvent.metadata['first_blocker']];
|
|
13650
|
+
}
|
|
13651
|
+
}
|
|
13652
|
+
return {
|
|
13653
|
+
userId,
|
|
13654
|
+
passed,
|
|
13655
|
+
passedAt,
|
|
13656
|
+
workspaceReady: state.events.workspace_ready !== null,
|
|
13657
|
+
preflightAttempted: state.events.host_preflight_failed !== null || passed,
|
|
13658
|
+
failureReasons,
|
|
13659
|
+
};
|
|
13660
|
+
});
|
|
14797
13661
|
/**
|
|
14798
13662
|
* POST /activation/event — manually emit an activation event.
|
|
14799
13663
|
* Body: { type, userId, metadata? }
|
|
@@ -14859,6 +13723,61 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
14859
13723
|
const weeks = query.weeks ? parseInt(query.weeks, 10) : 12;
|
|
14860
13724
|
return { success: true, trends: getWeeklyTrends(weeks) };
|
|
14861
13725
|
});
|
|
13726
|
+
/**
|
|
13727
|
+
* GET /activation/ghost-signups — Users who signed up but never ran preflight.
|
|
13728
|
+
* Cloud polls this to find candidates for the ghost signup nudge email.
|
|
13729
|
+
* Query: ?minAgeHours=2 (default 2h; use 24 for 24h tier candidates)
|
|
13730
|
+
*
|
|
13731
|
+
* task-1773709288800-lam5hd11b
|
|
13732
|
+
*/
|
|
13733
|
+
app.get('/activation/ghost-signups', async (request) => {
|
|
13734
|
+
const query = request.query;
|
|
13735
|
+
const minAgeHours = query.minAgeHours ? parseFloat(query.minAgeHours) : 2;
|
|
13736
|
+
const minAgeMs = minAgeHours * 60 * 60 * 1000;
|
|
13737
|
+
const { getGhostSignupCandidates } = await import('./ghost-signup-nudge.js');
|
|
13738
|
+
const candidates = getGhostSignupCandidates(minAgeMs);
|
|
13739
|
+
return { success: true, candidates, count: candidates.length, minAgeHours };
|
|
13740
|
+
});
|
|
13741
|
+
/**
|
|
13742
|
+
* POST /activation/ghost-signup-nudge — Send re-engagement email to a ghost signup.
|
|
13743
|
+
* Cloud calls this with { userId, email, nudgeTier? } after finding candidates.
|
|
13744
|
+
* Node sends the email via cloud relay, tags the user, and returns result.
|
|
13745
|
+
*
|
|
13746
|
+
* Body: { userId: string, email: string, nudgeTier?: '2h' | '24h' }
|
|
13747
|
+
*
|
|
13748
|
+
* task-1773709288800-lam5hd11b
|
|
13749
|
+
*/
|
|
13750
|
+
app.post('/activation/ghost-signup-nudge', async (request, reply) => {
|
|
13751
|
+
const body = request.body;
|
|
13752
|
+
const userId = typeof body.userId === 'string' ? body.userId.trim() : '';
|
|
13753
|
+
const email = typeof body.email === 'string' ? body.email.trim() : '';
|
|
13754
|
+
const nudgeTier = (body.nudgeTier === '24h' ? '24h' : '2h');
|
|
13755
|
+
if (!userId)
|
|
13756
|
+
return reply.code(400).send({ success: false, error: 'userId is required' });
|
|
13757
|
+
if (!email || !email.includes('@'))
|
|
13758
|
+
return reply.code(400).send({ success: false, error: 'valid email is required' });
|
|
13759
|
+
const { sendGhostSignupNudge } = await import('./ghost-signup-nudge.js');
|
|
13760
|
+
// Email relay function — delegates to existing /email/send infrastructure
|
|
13761
|
+
const emailRelayFn = async (opts) => {
|
|
13762
|
+
const hostId = process.env.REFLECTT_HOST_ID;
|
|
13763
|
+
const relayPath = hostId ? `/api/hosts/${encodeURIComponent(hostId)}/relay/email` : '/api/hosts/relay/email';
|
|
13764
|
+
try {
|
|
13765
|
+
const relayResult = await cloudRelay(relayPath, {
|
|
13766
|
+
from: opts.from, to: opts.to, subject: opts.subject,
|
|
13767
|
+
html: opts.html, text: opts.text, tags: opts.tags,
|
|
13768
|
+
agent: 'funnel',
|
|
13769
|
+
idempotencyKey: `ghost-signup-nudge/${userId}/${nudgeTier}`,
|
|
13770
|
+
}, reply);
|
|
13771
|
+
const relayError = typeof relayResult?.error === 'string' ? relayResult.error : undefined;
|
|
13772
|
+
return { success: !relayError, error: relayError };
|
|
13773
|
+
}
|
|
13774
|
+
catch (err) {
|
|
13775
|
+
return { success: false, error: err?.message ?? 'relay error' };
|
|
13776
|
+
}
|
|
13777
|
+
};
|
|
13778
|
+
const result = await sendGhostSignupNudge(userId, email, nudgeTier, emailRelayFn);
|
|
13779
|
+
return { success: true, result };
|
|
13780
|
+
});
|
|
14862
13781
|
// Get task analytics
|
|
14863
13782
|
app.get('/tasks/analytics', async (request) => {
|
|
14864
13783
|
const query = request.query;
|
|
@@ -16119,6 +15038,10 @@ If your heartbeat shows **no active task** and **no next task**:
|
|
|
16119
15038
|
}).catch(err => {
|
|
16120
15039
|
console.error('[ActivationFunnel] Failed to load funnel data:', err);
|
|
16121
15040
|
});
|
|
15041
|
+
// ── Restart Drift Guard: reassert critical task ownership post-restart ──
|
|
15042
|
+
runRestartDriftGuard().catch(err => {
|
|
15043
|
+
console.error('[RestartDrift] Failed to run drift guard:', err);
|
|
15044
|
+
});
|
|
16122
15045
|
// GET /execution-health — sweeper status + current violations
|
|
16123
15046
|
app.get('/execution-health', async (_request, reply) => {
|
|
16124
15047
|
const status = getSweeperStatus();
|