agent-relay-server 0.58.0 → 0.59.0
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/docs/openapi.json +1 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-CHKzD4RY.js → MessageBubble-BrEKymoV.js} +2 -2
- package/public/assets/{MessageBubble-CHKzD4RY.js.map → MessageBubble-BrEKymoV.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BFVTJa0q.js → PlanGraphPanel-CXix1C-H.js} +2 -2
- package/public/assets/{PlanGraphPanel-BFVTJa0q.js.map → PlanGraphPanel-CXix1C-H.js.map} +1 -1
- package/public/assets/{activity-M-dFLU_O.js → activity-CRafQ1Je.js} +2 -2
- package/public/assets/{activity-M-dFLU_O.js.map → activity-CRafQ1Je.js.map} +1 -1
- package/public/assets/{agent-profiles-CvHSoyCX.js → agent-profiles-DW4o5-ND.js} +2 -2
- package/public/assets/{agent-profiles-CvHSoyCX.js.map → agent-profiles-DW4o5-ND.js.map} +1 -1
- package/public/assets/agents-Cbegv0Hm.js +2 -0
- package/public/assets/{agents-DZBM5dp_.js.map → agents-Cbegv0Hm.js.map} +1 -1
- package/public/assets/analytics-DX6T7JX3.js +910 -0
- package/public/assets/{analytics-bvjb3Urs.js.map → analytics-DX6T7JX3.js.map} +1 -1
- package/public/assets/{automation-CDSn9Uq-.js → automation-CzSBv4gg.js} +2 -2
- package/public/assets/{automation-CDSn9Uq-.js.map → automation-CzSBv4gg.js.map} +1 -1
- package/public/assets/{badge-Dj6PREuI.js → badge-Cy6qO_Jz.js} +2 -2
- package/public/assets/{badge-Dj6PREuI.js.map → badge-Cy6qO_Jz.js.map} +1 -1
- package/public/assets/{branch-state-badge-CdYg2HYc.js → branch-state-badge-CRA50r81.js} +2 -2
- package/public/assets/{branch-state-badge-CdYg2HYc.js.map → branch-state-badge-CRA50r81.js.map} +1 -1
- package/public/assets/{button-Cqpufqam.js → button-73wpRw65.js} +2 -2
- package/public/assets/{button-Cqpufqam.js.map → button-73wpRw65.js.map} +1 -1
- package/public/assets/{card-OmDF73CE.js → card-BU6PvVHv.js} +2 -2
- package/public/assets/{card-OmDF73CE.js.map → card-BU6PvVHv.js.map} +1 -1
- package/public/assets/{channels-BkxrdOFZ.js → channels-B1MQCPCT.js} +2 -2
- package/public/assets/{channels-BkxrdOFZ.js.map → channels-B1MQCPCT.js.map} +1 -1
- package/public/assets/chat-BqIaFYGJ.js +2 -0
- package/public/assets/{chat-YzX1z76l.js.map → chat-BqIaFYGJ.js.map} +1 -1
- package/public/assets/{connectors-Bg3rHgl_.js → connectors-C9aFJIrh.js} +2 -2
- package/public/assets/{connectors-Bg3rHgl_.js.map → connectors-C9aFJIrh.js.map} +1 -1
- package/public/assets/{copy-button-DI7b8A7_.js → copy-button-HRopUwui.js} +2 -2
- package/public/assets/{copy-button-DI7b8A7_.js.map → copy-button-HRopUwui.js.map} +1 -1
- package/public/assets/display-CketawEF.js.map +1 -1
- package/public/assets/{dist-CNjnTZEI.js → dist-CC5f7oYw.js} +2 -2
- package/public/assets/{dist-CNjnTZEI.js.map → dist-CC5f7oYw.js.map} +1 -1
- package/public/assets/{dist-DkeTrbTA.js → dist-CLHMeXYi.js} +2 -2
- package/public/assets/{dist-DkeTrbTA.js.map → dist-CLHMeXYi.js.map} +1 -1
- package/public/assets/{dist-C5t_OxI0.js → dist-Ci1MM51P.js} +2 -2
- package/public/assets/{dist-C5t_OxI0.js.map → dist-Ci1MM51P.js.map} +1 -1
- package/public/assets/{dist-CV3sgFiv.js → dist-Cko2KPW8.js} +2 -2
- package/public/assets/{dist-CV3sgFiv.js.map → dist-Cko2KPW8.js.map} +1 -1
- package/public/assets/dist-D41tzJYg.js +2 -0
- package/public/assets/{dist-yYauqYKR.js.map → dist-D41tzJYg.js.map} +1 -1
- package/public/assets/{dist-CoUvwLTy.js → dist-DFLciw0h.js} +2 -2
- package/public/assets/{dist-CoUvwLTy.js.map → dist-DFLciw0h.js.map} +1 -1
- package/public/assets/dist-DUoZnlB2.js +2 -0
- package/public/assets/{dist-DmhyU0Na.js.map → dist-DUoZnlB2.js.map} +1 -1
- package/public/assets/dist-De2oguBm.js +2 -0
- package/public/assets/{dist-DxkMwkrr.js.map → dist-De2oguBm.js.map} +1 -1
- package/public/assets/{dist-1x-LE9Uk.js → dist-DmN94rc8.js} +2 -2
- package/public/assets/{dist-1x-LE9Uk.js.map → dist-DmN94rc8.js.map} +1 -1
- package/public/assets/{dist-DV2tZ2fq.js → dist-Kkpnjx_M.js} +2 -2
- package/public/assets/{dist-DV2tZ2fq.js.map → dist-Kkpnjx_M.js.map} +1 -1
- package/public/assets/{dist-Ca3ujqG_.js → dist-T7u7IZGJ.js} +2 -2
- package/public/assets/{dist-Ca3ujqG_.js.map → dist-T7u7IZGJ.js.map} +1 -1
- package/public/assets/{es2015-DcavLcQ0.js → es2015-Df08Opjx.js} +2 -2
- package/public/assets/{es2015-DcavLcQ0.js.map → es2015-Df08Opjx.js.map} +1 -1
- package/public/assets/{formatted-body-impl-D8i9qqL1.js → formatted-body-impl-DOXH7oDF.js} +2 -2
- package/public/assets/{formatted-body-impl-D8i9qqL1.js.map → formatted-body-impl-DOXH7oDF.js.map} +1 -1
- package/public/assets/index-DaNOQJci.css +2 -0
- package/public/assets/index-br_Y6Cvt.js +21 -0
- package/public/assets/{index-Ba2RDJaA.js.map → index-br_Y6Cvt.js.map} +1 -1
- package/public/assets/{input-C_dl_9fG.js → input-B2EDz-BE.js} +2 -2
- package/public/assets/{input-C_dl_9fG.js.map → input-B2EDz-BE.js.map} +1 -1
- package/public/assets/{insights-D1aANwZ2.js → insights-B1edq5lk.js} +2 -2
- package/public/assets/{insights-D1aANwZ2.js.map → insights-B1edq5lk.js.map} +1 -1
- package/public/assets/{integrations-BrAr9bap.js → integrations-Cm4fjp7_.js} +2 -2
- package/public/assets/{integrations-BrAr9bap.js.map → integrations-Cm4fjp7_.js.map} +1 -1
- package/public/assets/{lib-B3iMVsf-.js → lib-BZrjIp-o.js} +3 -3
- package/public/assets/{lib-B3iMVsf-.js.map → lib-BZrjIp-o.js.map} +1 -1
- package/public/assets/lucide-react-H_-8G79A.js +9 -0
- package/public/assets/{lucide-react-CMhBract.js.map → lucide-react-H_-8G79A.js.map} +1 -1
- package/public/assets/{maintenance-C-xspsMG.js → maintenance-D6zvw0fU.js} +2 -2
- package/public/assets/{maintenance-C-xspsMG.js.map → maintenance-D6zvw0fU.js.map} +1 -1
- package/public/assets/{managed-agents-Bf3ni0vo.js → managed-agents-CzfEznM1.js} +2 -2
- package/public/assets/{managed-agents-Bf3ni0vo.js.map → managed-agents-CzfEznM1.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DKbUBTf_.js → markdown-preview-impl-DZlCaTHQ.js} +2 -2
- package/public/assets/{markdown-preview-impl-DKbUBTf_.js.map → markdown-preview-impl-DZlCaTHQ.js.map} +1 -1
- package/public/assets/memory-CUUAzJDc.js +2 -0
- package/public/assets/{memory-DeLwJFwH.js.map → memory-CUUAzJDc.js.map} +1 -1
- package/public/assets/messages-6QZH46LG.js +2 -0
- package/public/assets/{messages-Cww6dZnM.js.map → messages-6QZH46LG.js.map} +1 -1
- package/public/assets/{orchestrators-DRjwROXw.js → orchestrators-CrcqkPuc.js} +2 -2
- package/public/assets/{orchestrators-DRjwROXw.js.map → orchestrators-CrcqkPuc.js.map} +1 -1
- package/public/assets/overview-418xXZO_.js +2 -0
- package/public/assets/{overview-DjcRomU_.js.map → overview-418xXZO_.js.map} +1 -1
- package/public/assets/pairs-CnedATgT.js +2 -0
- package/public/assets/{pairs-DT0u2fnW.js.map → pairs-CnedATgT.js.map} +1 -1
- package/public/assets/{react-dom-Dtl9-x_1.js → react-dom-WI6TjVe7.js} +2 -2
- package/public/assets/{react-dom-Dtl9-x_1.js.map → react-dom-WI6TjVe7.js.map} +1 -1
- package/public/assets/{security-Bwg3oFI8.js → security-DS13pbag.js} +2 -2
- package/public/assets/{security-Bwg3oFI8.js.map → security-DS13pbag.js.map} +1 -1
- package/public/assets/{select-Cariwv69.js → select-BOprGZDS.js} +2 -2
- package/public/assets/{select-Cariwv69.js.map → select-BOprGZDS.js.map} +1 -1
- package/public/assets/{settings-Bd6Ug3DO.js → settings-BgInn_f3.js} +4 -4
- package/public/assets/{settings-Bd6Ug3DO.js.map → settings-BgInn_f3.js.map} +1 -1
- package/public/assets/{store-BgJq_lN9.js → store-DHO_a8BD.js} +5 -5
- package/public/assets/{store-BgJq_lN9.js.map → store-DHO_a8BD.js.map} +1 -1
- package/public/assets/{tasks-DVz1zhOU.js → tasks-B-4oCPuv.js} +2 -2
- package/public/assets/{tasks-DVz1zhOU.js.map → tasks-B-4oCPuv.js.map} +1 -1
- package/public/assets/teams-sCQAs_QA.js +2 -0
- package/public/assets/{teams-TqFntFB_.js.map → teams-sCQAs_QA.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-OsJ5crlx.js → terminal-viewer-impl-BhqS2XpS.js} +3 -3
- package/public/assets/{terminal-viewer-impl-OsJ5crlx.js.map → terminal-viewer-impl-BhqS2XpS.js.map} +1 -1
- package/public/assets/{use-keyboard-viewport-BYHcL7sP.js → use-keyboard-viewport-fOL_3RHB.js} +2 -2
- package/public/assets/{use-keyboard-viewport-BYHcL7sP.js.map → use-keyboard-viewport-fOL_3RHB.js.map} +1 -1
- package/public/assets/{work-queue-B4b2jPLR.js → work-queue-BDhjlwUH.js} +2 -2
- package/public/assets/{work-queue-B4b2jPLR.js.map → work-queue-BDhjlwUH.js.map} +1 -1
- package/public/assets/workspaces-6I-C69nf.js +3 -0
- package/public/assets/{workspaces-m97LP4Eu.js.map → workspaces-6I-C69nf.js.map} +1 -1
- package/public/index.html +21 -21
- package/src/db/stats.ts +209 -38
- package/src/maintenance.ts +9 -2
- package/src/mcp.ts +3 -0
- package/src/project-instructions.ts +176 -0
- package/src/routes/agents-spawn.ts +2 -0
- package/src/routes/commands.ts +11 -5
- package/src/services/spawn-agent.ts +26 -1
- package/src/workspace-merge.ts +11 -0
- package/src/workspace-pr-completion.ts +57 -1
- package/public/assets/agents-DZBM5dp_.js +0 -2
- package/public/assets/analytics-bvjb3Urs.js +0 -910
- package/public/assets/chat-YzX1z76l.js +0 -2
- package/public/assets/dist-DmhyU0Na.js +0 -2
- package/public/assets/dist-DxkMwkrr.js +0 -2
- package/public/assets/dist-yYauqYKR.js +0 -2
- package/public/assets/index-Ba2RDJaA.js +0 -21
- package/public/assets/index-CtFXWxBJ.css +0 -2
- package/public/assets/lucide-react-CMhBract.js +0 -9
- package/public/assets/memory-DeLwJFwH.js +0 -2
- package/public/assets/messages-Cww6dZnM.js +0 -2
- package/public/assets/overview-DjcRomU_.js +0 -2
- package/public/assets/pairs-DT0u2fnW.js +0 -2
- package/public/assets/teams-TqFntFB_.js +0 -2
- package/public/assets/workspaces-m97LP4Eu.js +0 -3
package/src/db/stats.ts
CHANGED
|
@@ -135,20 +135,35 @@ export const ANALYTICS_PERIODS = {
|
|
|
135
135
|
|
|
136
136
|
export type AnalyticsPeriod = keyof typeof ANALYTICS_PERIODS;
|
|
137
137
|
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
138
|
+
// ─── Analytics taxonomy ─────────────────────────────────────────────────────
|
|
139
|
+
// The relay carries three distinct streams of activity, and analytics keeps them
|
|
140
|
+
// SEPARATE rather than collapsing into one "messages" count (which is ~94%
|
|
141
|
+
// session-mirror noise on a busy relay — see the work layer below):
|
|
142
|
+
// 1. Conversation — real inter-agent / human messaging (chat, replies, channel, pair, work items)
|
|
143
|
+
// 2. Work — session-mirror cognition: tool calls, reasoning, narration, responses
|
|
144
|
+
// 3. Fleet — agent registrations/spawns + per-agent activity (NOT online/offline:
|
|
145
|
+
// agents start, work, and shut down constantly — point-in-time status is noise here)
|
|
146
|
+
|
|
147
|
+
// Conversation categories. Order is significant: a claimable/channel/pair message
|
|
148
|
+
// is classified as such even when it is also a reply; only an otherwise-plain reply
|
|
149
|
+
// counts as "Replies". Session/control/system rows are EXCLUDED (they're streams 2/mechanical).
|
|
150
|
+
export const CONVERSATION_CATEGORIES = ["Chat", "Replies", "Channel", "Pair", "Work items"] as const;
|
|
151
|
+
export type ConversationCategory = (typeof CONVERSATION_CATEGORIES)[number];
|
|
152
|
+
const CONVERSATION_CATEGORY_SQL = `
|
|
144
153
|
CASE
|
|
145
154
|
WHEN claimable = 1 THEN 'Work items'
|
|
146
|
-
WHEN kind = 'system' OR from_agent = 'system' THEN 'System'
|
|
147
|
-
WHEN kind = 'pair' THEN 'Pair'
|
|
148
155
|
WHEN kind = 'channel.event' THEN 'Channel'
|
|
156
|
+
WHEN kind = 'pair' THEN 'Pair'
|
|
149
157
|
WHEN reply_to IS NOT NULL THEN 'Replies'
|
|
150
|
-
ELSE '
|
|
158
|
+
ELSE 'Chat'
|
|
151
159
|
END`;
|
|
160
|
+
// Only real messaging — excludes the session-mirror lane and mechanical/system rows.
|
|
161
|
+
const CONVERSATION_FILTER = `kind NOT IN ('session','control','system') AND from_agent != 'system'`;
|
|
162
|
+
|
|
163
|
+
// Session-mirror event sub-types we surface as the "agent work" composition. (prompt
|
|
164
|
+
// is counted in the user-interaction stream; notice is mechanical chatter.)
|
|
165
|
+
export const WORK_TYPES = ["tool", "reasoning", "narration", "response"] as const;
|
|
166
|
+
export type WorkType = (typeof WORK_TYPES)[number];
|
|
152
167
|
|
|
153
168
|
export interface AnalyticsData {
|
|
154
169
|
period: AnalyticsPeriod;
|
|
@@ -156,52 +171,196 @@ export interface AnalyticsData {
|
|
|
156
171
|
now: number;
|
|
157
172
|
bucketMs: number;
|
|
158
173
|
bucketCount: number;
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
174
|
+
// 1. Conversation — real messaging volume, per-bucket (oldest→newest) + totals.
|
|
175
|
+
conversation: {
|
|
176
|
+
buckets: Array<{ start: number; counts: Record<ConversationCategory, number> }>;
|
|
177
|
+
totals: Record<ConversationCategory, number>;
|
|
178
|
+
total: number;
|
|
179
|
+
};
|
|
180
|
+
// 2. Work — session-mirror cognition: per-bucket type counts, totals, tool leaderboard, turn count.
|
|
181
|
+
work: {
|
|
182
|
+
buckets: Array<{ start: number; counts: Record<WorkType, number> }>;
|
|
183
|
+
totals: Record<WorkType, number>;
|
|
184
|
+
topTools: Array<{ name: string; count: number }>;
|
|
185
|
+
turns: number;
|
|
186
|
+
toolCalls: number;
|
|
187
|
+
};
|
|
188
|
+
// 3. User interaction — human prompts, reply rate, and agent reply latency (median is
|
|
189
|
+
// the headline; avg is tail-inflated by agents that were away mid-conversation).
|
|
190
|
+
user: {
|
|
191
|
+
prompts: number;
|
|
192
|
+
replied: number;
|
|
193
|
+
replyRate: number; // 0..1
|
|
194
|
+
latencyMs: { median: number; avg: number; p90: number } | null;
|
|
195
|
+
buckets: Array<{ start: number; prompts: number }>;
|
|
196
|
+
};
|
|
197
|
+
// 4. Fleet — activity-based, never online/offline. Registrations (spawns) over time,
|
|
198
|
+
// how many were agent-spawned, distinct active agents, and a per-agent leaderboard.
|
|
199
|
+
fleet: {
|
|
200
|
+
activeAgents: number;
|
|
201
|
+
registrations: number;
|
|
202
|
+
spawnedByAgent: number;
|
|
203
|
+
buckets: Array<{ start: number; registrations: number }>;
|
|
204
|
+
leaderboard: Array<{ id: string; label: string | null; turns: number; toolCalls: number; messages: number }>;
|
|
205
|
+
};
|
|
206
|
+
// Busiest-hours grid: heatmap[dayOfWeek 0=Sun][hour 0-23], server-local time, all activity.
|
|
164
207
|
heatmap: number[][];
|
|
165
|
-
totalMessages: number;
|
|
166
208
|
totalReactions: number;
|
|
167
209
|
}
|
|
168
210
|
|
|
169
|
-
|
|
170
|
-
return {
|
|
211
|
+
function emptyConversationCounts(): Record<ConversationCategory, number> {
|
|
212
|
+
return { Chat: 0, Replies: 0, Channel: 0, Pair: 0, "Work items": 0 };
|
|
213
|
+
}
|
|
214
|
+
function emptyWorkCounts(): Record<WorkType, number> {
|
|
215
|
+
return { tool: 0, reasoning: 0, narration: 0, response: 0 };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Percentile over an unsorted list of numbers (linear interpolation-free nearest-rank).
|
|
219
|
+
function percentile(sorted: number[], p: number): number {
|
|
220
|
+
if (sorted.length === 0) return 0;
|
|
221
|
+
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
|
|
222
|
+
return sorted[idx] ?? 0;
|
|
171
223
|
}
|
|
172
224
|
|
|
173
225
|
export function getAnalytics(period: AnalyticsPeriod, now: number = Date.now()): AnalyticsData {
|
|
174
226
|
const { ms, buckets: bucketCount } = ANALYTICS_PERIODS[period] ?? ANALYTICS_PERIODS["24h"];
|
|
175
227
|
const start = now - ms;
|
|
176
228
|
const bucketMs = ms / bucketCount;
|
|
229
|
+
const db = getDb();
|
|
230
|
+
// created_at in (start, now] keeps the floored bucket index in [0, bucketCount).
|
|
231
|
+
const clampIdx = (bucket: number) => Math.min(Math.max(bucket, 0), bucketCount - 1);
|
|
232
|
+
const bucketStarts = Array.from({ length: bucketCount }, (_, i) => start + i * bucketMs);
|
|
177
233
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const categories = emptyCategoryCounts();
|
|
183
|
-
|
|
184
|
-
// One pass: bucket index × category counts. CAST floors toward zero; created_at
|
|
185
|
-
// in (start, now] keeps the index in [0, bucketCount).
|
|
186
|
-
const volumeRows = getDb()
|
|
234
|
+
// ── 1. Conversation: real messaging volume ──
|
|
235
|
+
const conversationBuckets = bucketStarts.map((s) => ({ start: s, counts: emptyConversationCounts() }));
|
|
236
|
+
const conversationTotals = emptyConversationCounts();
|
|
237
|
+
const convRows = db
|
|
187
238
|
.query(
|
|
188
|
-
`SELECT CAST((created_at - ?) / ? AS INTEGER) AS bucket, ${
|
|
189
|
-
FROM messages WHERE created_at > ? AND created_at <= ? GROUP BY bucket, category`,
|
|
239
|
+
`SELECT CAST((created_at - ?) / ? AS INTEGER) AS bucket, ${CONVERSATION_CATEGORY_SQL} AS category, COUNT(*) AS c
|
|
240
|
+
FROM messages WHERE created_at > ? AND created_at <= ? AND ${CONVERSATION_FILTER} GROUP BY bucket, category`,
|
|
190
241
|
)
|
|
191
|
-
.all(start, bucketMs, start, now) as Array<{ bucket: number; category:
|
|
192
|
-
for (const row of
|
|
193
|
-
const
|
|
194
|
-
const bucket = buckets[idx];
|
|
242
|
+
.all(start, bucketMs, start, now) as Array<{ bucket: number; category: ConversationCategory; c: number }>;
|
|
243
|
+
for (const row of convRows) {
|
|
244
|
+
const bucket = conversationBuckets[clampIdx(row.bucket)];
|
|
195
245
|
if (bucket && row.category in bucket.counts) {
|
|
196
246
|
bucket.counts[row.category] += row.c;
|
|
197
|
-
|
|
247
|
+
conversationTotals[row.category] += row.c;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const conversationTotal = Object.values(conversationTotals).reduce((sum, v) => sum + v, 0);
|
|
251
|
+
|
|
252
|
+
// ── 2. Work: session-mirror cognition ──
|
|
253
|
+
const workBuckets = bucketStarts.map((s) => ({ start: s, counts: emptyWorkCounts() }));
|
|
254
|
+
const workTotals = emptyWorkCounts();
|
|
255
|
+
const workRows = db
|
|
256
|
+
.query(
|
|
257
|
+
`SELECT CAST((created_at - ?) / ? AS INTEGER) AS bucket,
|
|
258
|
+
json_extract(payload, '$.session.type') AS wtype, COUNT(*) AS c
|
|
259
|
+
FROM messages WHERE created_at > ? AND created_at <= ? AND kind = 'session'
|
|
260
|
+
AND json_extract(payload, '$.session.type') IN ('tool','reasoning','narration','response')
|
|
261
|
+
GROUP BY bucket, wtype`,
|
|
262
|
+
)
|
|
263
|
+
.all(start, bucketMs, start, now) as Array<{ bucket: number; wtype: WorkType; c: number }>;
|
|
264
|
+
for (const row of workRows) {
|
|
265
|
+
const bucket = workBuckets[clampIdx(row.bucket)];
|
|
266
|
+
if (bucket && row.wtype in bucket.counts) {
|
|
267
|
+
bucket.counts[row.wtype] += row.c;
|
|
268
|
+
workTotals[row.wtype] += row.c;
|
|
198
269
|
}
|
|
199
270
|
}
|
|
200
|
-
const
|
|
271
|
+
const topTools = db
|
|
272
|
+
.query(
|
|
273
|
+
`SELECT json_extract(payload, '$.session.label') AS name, COUNT(*) AS count
|
|
274
|
+
FROM messages WHERE created_at > ? AND created_at <= ? AND kind = 'session'
|
|
275
|
+
AND json_extract(payload, '$.session.type') = 'tool'
|
|
276
|
+
AND json_extract(payload, '$.session.label') IS NOT NULL
|
|
277
|
+
GROUP BY name ORDER BY count DESC LIMIT 12`,
|
|
278
|
+
)
|
|
279
|
+
.all(start, now) as Array<{ name: string; count: number }>;
|
|
280
|
+
const turns = (
|
|
281
|
+
db
|
|
282
|
+
.query(
|
|
283
|
+
`SELECT COUNT(DISTINCT json_extract(payload, '$.session.turnId')) AS c
|
|
284
|
+
FROM messages WHERE created_at > ? AND created_at <= ? AND kind = 'session'
|
|
285
|
+
AND json_extract(payload, '$.session.turnId') IS NOT NULL`,
|
|
286
|
+
)
|
|
287
|
+
.get(start, now) as { c: number }
|
|
288
|
+
).c;
|
|
289
|
+
|
|
290
|
+
// ── 3. User interaction: human prompts + agent reply latency ──
|
|
291
|
+
const userBuckets = bucketStarts.map((s) => ({ start: s, prompts: 0 }));
|
|
292
|
+
const promptRows = db
|
|
293
|
+
.query(
|
|
294
|
+
`SELECT CAST((created_at - ?) / ? AS INTEGER) AS bucket, COUNT(*) AS c
|
|
295
|
+
FROM messages WHERE created_at > ? AND created_at <= ? AND from_agent = 'user' GROUP BY bucket`,
|
|
296
|
+
)
|
|
297
|
+
.all(start, bucketMs, start, now) as Array<{ bucket: number; c: number }>;
|
|
298
|
+
let prompts = 0;
|
|
299
|
+
for (const row of promptRows) {
|
|
300
|
+
const bucket = userBuckets[clampIdx(row.bucket)];
|
|
301
|
+
if (bucket) bucket.prompts += row.c;
|
|
302
|
+
prompts += row.c;
|
|
303
|
+
}
|
|
304
|
+
// Latency = first threaded reply from a non-user agent, in (start, now] by the prompt's time.
|
|
305
|
+
const latencyRows = db
|
|
306
|
+
.query(
|
|
307
|
+
`SELECT (r.created_at - u.created_at) AS delta
|
|
308
|
+
FROM messages u JOIN messages r ON r.reply_to = u.id
|
|
309
|
+
WHERE u.from_agent = 'user' AND r.from_agent != 'user'
|
|
310
|
+
AND u.created_at > ? AND u.created_at <= ? AND r.created_at >= u.created_at`,
|
|
311
|
+
)
|
|
312
|
+
.all(start, now) as Array<{ delta: number }>;
|
|
313
|
+
const deltas = latencyRows.map((r) => r.delta).sort((a, b) => a - b);
|
|
314
|
+
const replied = deltas.length;
|
|
315
|
+
const latencyMs =
|
|
316
|
+
replied > 0
|
|
317
|
+
? {
|
|
318
|
+
median: percentile(deltas, 50),
|
|
319
|
+
avg: Math.round(deltas.reduce((sum, d) => sum + d, 0) / replied),
|
|
320
|
+
p90: percentile(deltas, 90),
|
|
321
|
+
}
|
|
322
|
+
: null;
|
|
323
|
+
|
|
324
|
+
// ── 4. Fleet: registrations (spawns) over time + per-agent leaderboard ──
|
|
325
|
+
const fleetBuckets = bucketStarts.map((s) => ({ start: s, registrations: 0 }));
|
|
326
|
+
const regRows = db
|
|
327
|
+
.query(
|
|
328
|
+
`SELECT CAST((created_at - ?) / ? AS INTEGER) AS bucket, COUNT(*) AS c,
|
|
329
|
+
SUM(CASE WHEN spawned_by IS NOT NULL THEN 1 ELSE 0 END) AS spawned
|
|
330
|
+
FROM agents WHERE created_at > ? AND created_at <= ? GROUP BY bucket`,
|
|
331
|
+
)
|
|
332
|
+
.all(start, bucketMs, start, now) as Array<{ bucket: number; c: number; spawned: number }>;
|
|
333
|
+
let registrations = 0;
|
|
334
|
+
let spawnedByAgent = 0;
|
|
335
|
+
for (const row of regRows) {
|
|
336
|
+
const bucket = fleetBuckets[clampIdx(row.bucket)];
|
|
337
|
+
if (bucket) bucket.registrations += row.c;
|
|
338
|
+
registrations += row.c;
|
|
339
|
+
spawnedByAgent += row.spawned ?? 0;
|
|
340
|
+
}
|
|
341
|
+
const activeAgents = (
|
|
342
|
+
db
|
|
343
|
+
.query(
|
|
344
|
+
`SELECT COUNT(DISTINCT from_agent) AS c FROM messages
|
|
345
|
+
WHERE created_at > ? AND created_at <= ? AND from_agent NOT IN ('system','user')`,
|
|
346
|
+
)
|
|
347
|
+
.get(start, now) as { c: number }
|
|
348
|
+
).c;
|
|
349
|
+
const leaderboard = db
|
|
350
|
+
.query(
|
|
351
|
+
`SELECT m.from_agent AS id, a.label AS label,
|
|
352
|
+
COUNT(DISTINCT CASE WHEN m.kind = 'session' THEN json_extract(m.payload, '$.session.turnId') END) AS turns,
|
|
353
|
+
SUM(CASE WHEN m.kind = 'session' AND json_extract(m.payload, '$.session.type') = 'tool' THEN 1 ELSE 0 END) AS toolCalls,
|
|
354
|
+
SUM(CASE WHEN m.kind != 'session' THEN 1 ELSE 0 END) AS messages
|
|
355
|
+
FROM messages m LEFT JOIN agents a ON a.id = m.from_agent
|
|
356
|
+
WHERE m.created_at > ? AND m.created_at <= ? AND m.from_agent NOT IN ('system','user')
|
|
357
|
+
GROUP BY m.from_agent ORDER BY (toolCalls + messages) DESC LIMIT 10`,
|
|
358
|
+
)
|
|
359
|
+
.all(start, now) as Array<{ id: string; label: string | null; turns: number; toolCalls: number; messages: number }>;
|
|
201
360
|
|
|
202
|
-
// Busiest-hours heatmap (day-of-week × hour
|
|
361
|
+
// ── Busiest-hours heatmap (all activity, day-of-week × hour, server-local time) ──
|
|
203
362
|
const heatmap: number[][] = Array.from({ length: 7 }, () => new Array(24).fill(0));
|
|
204
|
-
const heatRows =
|
|
363
|
+
const heatRows = db
|
|
205
364
|
.query(
|
|
206
365
|
`SELECT CAST(strftime('%w', created_at / 1000, 'unixepoch', 'localtime') AS INTEGER) AS dow,
|
|
207
366
|
CAST(strftime('%H', created_at / 1000, 'unixepoch', 'localtime') AS INTEGER) AS hour, COUNT(*) AS c
|
|
@@ -214,7 +373,7 @@ export function getAnalytics(period: AnalyticsPeriod, now: number = Date.now()):
|
|
|
214
373
|
}
|
|
215
374
|
|
|
216
375
|
const totalReactions = (
|
|
217
|
-
|
|
376
|
+
db
|
|
218
377
|
.query(
|
|
219
378
|
`SELECT COUNT(*) AS c FROM message_reactions r
|
|
220
379
|
JOIN messages m ON m.id = r.message_id WHERE m.created_at > ? AND m.created_at <= ?`,
|
|
@@ -222,7 +381,19 @@ export function getAnalytics(period: AnalyticsPeriod, now: number = Date.now()):
|
|
|
222
381
|
.get(start, now) as { c: number }
|
|
223
382
|
).c;
|
|
224
383
|
|
|
225
|
-
return {
|
|
384
|
+
return {
|
|
385
|
+
period,
|
|
386
|
+
start,
|
|
387
|
+
now,
|
|
388
|
+
bucketMs,
|
|
389
|
+
bucketCount,
|
|
390
|
+
conversation: { buckets: conversationBuckets, totals: conversationTotals, total: conversationTotal },
|
|
391
|
+
work: { buckets: workBuckets, totals: workTotals, topTools, turns, toolCalls: workTotals.tool },
|
|
392
|
+
user: { prompts, replied, replyRate: prompts > 0 ? replied / prompts : 0, latencyMs, buckets: userBuckets },
|
|
393
|
+
fleet: { activeAgents, registrations, spawnedByAgent, buckets: fleetBuckets, leaderboard },
|
|
394
|
+
heatmap,
|
|
395
|
+
totalReactions,
|
|
396
|
+
};
|
|
226
397
|
}
|
|
227
398
|
|
|
228
399
|
export function getHealth(now: number = Date.now()): HealthReport {
|
package/src/maintenance.ts
CHANGED
|
@@ -39,7 +39,7 @@ import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "./
|
|
|
39
39
|
import { requestWorkspaceMerge } from "./workspace-merge";
|
|
40
40
|
import { reconcileLandedWorkspace } from "./branch-landed";
|
|
41
41
|
import { isAwaitingReviewerApproval } from "./reviewer-pipeline";
|
|
42
|
-
import { preparePrCompletionScan } from "./workspace-pr-completion";
|
|
42
|
+
import { preparePrCompletionScan, reconcileLandedPr } from "./workspace-pr-completion";
|
|
43
43
|
import { notifyAgentOffline } from "./agent-lifecycle-events";
|
|
44
44
|
import { workspaceActiveClaim } from "./workspace-claim";
|
|
45
45
|
import { reapOrphanedWorktrees } from "./workspace-orphans";
|
|
@@ -655,6 +655,7 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
|
|
|
655
655
|
);
|
|
656
656
|
const flagged: string[] = [], cleared: string[] = [], merged: string[] = [], notifiedStewards: string[] = [];
|
|
657
657
|
const reviewerArmed: string[] = [], reviewerChangesRequested: string[] = [], relayPrMerged: string[] = [], relayPrWaiting: string[] = [];
|
|
658
|
+
const prRecycled: string[] = [];
|
|
658
659
|
|
|
659
660
|
for (const ws of candidates) {
|
|
660
661
|
const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
|
|
@@ -687,6 +688,12 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
|
|
|
687
688
|
// branch-landed.ts (#304).
|
|
688
689
|
const landed = p.landed === true || p.prMerged === true;
|
|
689
690
|
if (landed && LANDED_RECONCILE_STATUSES.has(ws.status)) {
|
|
691
|
+
// A PR-land merges asynchronously on the remote, outside the relay's land path. For a
|
|
692
|
+
// LIVE owner that means recycle (land-and-continue, #423); for a gone owner, finalize
|
|
693
|
+
// terminal `merged` (#304). `reconcileLandedPr` owns that split — see workspace-pr-completion.
|
|
694
|
+
const outcome = reconcileLandedPr(ws, p);
|
|
695
|
+
if (outcome.kind === "recycle") { emitCommand(outcome.command); prRecycled.push(ws.id); continue; }
|
|
696
|
+
if (outcome.kind === "wait") { relayPrWaiting.push(`${ws.id}:${outcome.reason}`); continue; }
|
|
690
697
|
reconcileLandedWorkspace(ws, p);
|
|
691
698
|
merged.push(ws.id);
|
|
692
699
|
continue;
|
|
@@ -752,7 +759,7 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
|
|
|
752
759
|
}
|
|
753
760
|
}
|
|
754
761
|
|
|
755
|
-
return { scanned: candidates.length, flagged, cleared, merged, notifiedStewards, reviewerArmed, reviewerChangesRequested, relayPrMerged, relayPrWaiting };
|
|
762
|
+
return { scanned: candidates.length, flagged, cleared, merged, prRecycled, notifiedStewards, reviewerArmed, reviewerChangesRequested, relayPrMerged, relayPrWaiting };
|
|
756
763
|
}
|
|
757
764
|
|
|
758
765
|
// Deterministic auto-land (Layer 0, #167/#207/#242): land conflict-free ready
|
package/src/mcp.ts
CHANGED
|
@@ -50,6 +50,7 @@ import { PLAN_TOOLS, relayPlanTool } from "./mcp-plan-tools";
|
|
|
50
50
|
import { TEAM_TOOLS, relayTeamTool } from "./mcp-team-tools";
|
|
51
51
|
import { BLACKBOARD_TOOLS, relayBlackboardTool } from "./mcp-blackboard-tools";
|
|
52
52
|
import { PROJECT_TOOLS, relayProjectTool } from "./mcp-project-tools";
|
|
53
|
+
import { PROJECT_INSTRUCTION_POLICY_NAMES, parseProjectInstructions } from "./project-instructions";
|
|
53
54
|
import { AGENT_ACTION_TOOL, relayAgentAction } from "./mcp-agent-action";
|
|
54
55
|
import type { ActivityKind, AgentLifecycle, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
|
|
55
56
|
import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
|
|
@@ -310,6 +311,7 @@ const TOOLS: ToolDefinition[] = [
|
|
|
310
311
|
prompt: { type: "string", description: "Initial task/message delivered to the agent on launch — spawn and hand it its first instruction in one call (no separate follow-up message needed)." },
|
|
311
312
|
expectReply: { type: "boolean", description: "Require the worker to report back: it gets a pending reply obligation to you and CANNOT go idle until it delivers its result (which routes to your inbox and wakes you). Defaults true when you pass a prompt — set false for a true fire-and-forget worker that needn't report (e.g. one that only lands a branch)." },
|
|
312
313
|
systemPromptAppend: { type: "string" },
|
|
314
|
+
projectInstructions: { type: "object", description: "#409 — project instruction projection (default OFF). When set, relay composes a knowledge preamble from the spawn cwd's resolved project (own + inherited entries) and prepends it to systemPromptAppend, instead of relying on host CLAUDE.md. `policy: \"all\"` includes ingested host content; `\"vanilla\"` excludes it (manual+promoted only). Omit to keep current behavior.", properties: { policy: { type: "string", enum: PROJECT_INSTRUCTION_POLICY_NAMES } }, additionalProperties: false },
|
|
313
315
|
profile: { type: "string", description: "Agent profile name to apply (env, instructions, permissions, MCP/skills, spawn quota)." },
|
|
314
316
|
tags: { type: "array", items: { type: "string" } },
|
|
315
317
|
capabilities: { type: "array", items: { type: "string" } },
|
|
@@ -874,6 +876,7 @@ async function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknow
|
|
|
874
876
|
prompt: optionalString(args.prompt, "prompt", 16_000),
|
|
875
877
|
expectReply: typeof args.expectReply === "boolean" ? args.expectReply : undefined,
|
|
876
878
|
systemPromptAppend: optionalString(args.systemPromptAppend, "systemPromptAppend", 64_000),
|
|
879
|
+
projectInstructions: parseProjectInstructions(args.projectInstructions, (m) => new ValidationError(m)),
|
|
877
880
|
tags: optionalStringArray(args.tags, "tags"),
|
|
878
881
|
capabilities: optionalStringArray(args.capabilities, "capabilities"),
|
|
879
882
|
providerArgs: optionalStringArray(args.providerArgs, "providerArgs"),
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Project instruction projection (#409) — the relay-owned replacement for host
|
|
2
|
+
// CLAUDE.md bleed-in. Given a resolved project, render its `rule`/`fact` entries
|
|
3
|
+
// (own + inherited `contains`-ancestor entries from #406's cascade) into a compact,
|
|
4
|
+
// DETERMINISTIC, size-bounded instruction block that can be prepended to a spawned
|
|
5
|
+
// agent's systemPromptAppend.
|
|
6
|
+
//
|
|
7
|
+
// ADDITIVE-FROM-EMPTY (#403 decision 4): this composer builds the instruction world
|
|
8
|
+
// from NOTHING and ADDS project knowledge. It NEVER reads host CLAUDE.md or subtracts
|
|
9
|
+
// from it. Going fully vanilla later = simply not including host-derived (`ingested`)
|
|
10
|
+
// content — a policy switch here, zero schema/composer change. `project_entries.source`
|
|
11
|
+
// is that lever: the included-source set is a policy PARAMETER, and the `vanilla`
|
|
12
|
+
// policy includes `manual`+`promoted` while excluding `ingested`.
|
|
13
|
+
import { resolveProjectEntries } from "./db";
|
|
14
|
+
import { PROJECT_ENTRY_SOURCES, type Project, type ProjectEntryKind, type ProjectEntrySource, type ResolvedProjectEntry } from "./types";
|
|
15
|
+
|
|
16
|
+
/** Named source policies — THE additive-from-empty lever (#403 decision 4).
|
|
17
|
+
* `all` — every source (manual + promoted + ingested host-CLAUDE.md content).
|
|
18
|
+
* `vanilla` — manual + promoted only; EXCLUDES `ingested`, so a vanilla agent gets
|
|
19
|
+
* zero host-CLAUDE.md bleed-in while still receiving relay-native knowledge. */
|
|
20
|
+
export const PROJECT_INSTRUCTION_SOURCE_SETS = {
|
|
21
|
+
all: PROJECT_ENTRY_SOURCES,
|
|
22
|
+
vanilla: ["manual", "promoted"],
|
|
23
|
+
} as const satisfies Record<string, readonly ProjectEntrySource[]>;
|
|
24
|
+
|
|
25
|
+
export const PROJECT_INSTRUCTION_POLICY_NAMES = ["all", "vanilla"] as const;
|
|
26
|
+
export type ProjectInstructionPolicyName = (typeof PROJECT_INSTRUCTION_POLICY_NAMES)[number];
|
|
27
|
+
|
|
28
|
+
/** Per-spawn projection request. Presence (a non-undefined value on the spawn input)
|
|
29
|
+
* means "project the preamble"; absence means OFF. `policy` selects the source set. */
|
|
30
|
+
export interface ProjectInstructionPolicy {
|
|
31
|
+
policy?: ProjectInstructionPolicyName;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ComposeProjectInstructionsOptions {
|
|
35
|
+
/** Named policy selecting the included source set. Default `all`. */
|
|
36
|
+
policy?: ProjectInstructionPolicyName;
|
|
37
|
+
/** Explicit source set; overrides `policy` when given (the low-level lever). */
|
|
38
|
+
includeSources?: readonly ProjectEntrySource[];
|
|
39
|
+
/** Entry kinds rendered, in this order. Default `["rule", "fact"]` (the directive +
|
|
40
|
+
* truth kinds; `decision`/`memory` are log/scratch, not preamble material). */
|
|
41
|
+
includeKinds?: readonly ProjectEntryKind[];
|
|
42
|
+
/** Merge transitive `contains`-ancestor entries (#406 cascade). Default true. */
|
|
43
|
+
includeInherited?: boolean;
|
|
44
|
+
/** Hard byte cap on the rendered block (UTF-8). Default 8192. The block is truncated
|
|
45
|
+
* deterministically (lowest-priority entries dropped first) to stay within it. */
|
|
46
|
+
maxBytes?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DEFAULT_KINDS: readonly ProjectEntryKind[] = ["rule", "fact"];
|
|
50
|
+
const DEFAULT_MAX_BYTES = 8192;
|
|
51
|
+
const TRUNCATION_NOTICE = "_(… additional project entries omitted to fit the instruction budget.)_";
|
|
52
|
+
|
|
53
|
+
const KIND_HEADINGS: Record<ProjectEntryKind, string> = {
|
|
54
|
+
rule: "Rules",
|
|
55
|
+
fact: "Facts",
|
|
56
|
+
decision: "Decisions",
|
|
57
|
+
memory: "Memory",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function byteLen(value: string): number {
|
|
61
|
+
return Buffer.byteLength(value, "utf8");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Stable dedup key — same kind + same (trimmed) title + body collapses to one entry.
|
|
65
|
+
* Uses NUL separators so distinct fields can't collide by concatenation. */
|
|
66
|
+
function dedupKey(entry: ResolvedProjectEntry): string {
|
|
67
|
+
return `${entry.kind}\u0000${entry.title.trim()}\u0000${(entry.body ?? "").trim()}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function entryLine(entry: ResolvedProjectEntry): string {
|
|
71
|
+
const body = entry.body?.trim();
|
|
72
|
+
const tag = entry.inherited ? " _(inherited)_" : "";
|
|
73
|
+
return body ? `- ${entry.title.trim()}: ${body}${tag}` : `- ${entry.title.trim()}${tag}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Render a project's instruction block. Deterministic: ordering is by kind (in
|
|
78
|
+
* `includeKinds` order), then own-before-inherited, then title (code-point), then id —
|
|
79
|
+
* fully independent of DB row timestamps. Output is deduped and hard-bounded to
|
|
80
|
+
* `maxBytes`. Returns "" when no entry survives the source/kind filter (the caller then
|
|
81
|
+
* leaves systemPromptAppend untouched, so the spawn stays byte-identical).
|
|
82
|
+
*/
|
|
83
|
+
export function composeProjectInstructions(
|
|
84
|
+
project: Pick<Project, "id" | "slug" | "title">,
|
|
85
|
+
opts: ComposeProjectInstructionsOptions = {},
|
|
86
|
+
): string {
|
|
87
|
+
const kinds = opts.includeKinds ?? DEFAULT_KINDS;
|
|
88
|
+
const kindRank = new Map<ProjectEntryKind, number>(kinds.map((kind, index) => [kind, index]));
|
|
89
|
+
const sources = new Set<ProjectEntrySource>(
|
|
90
|
+
opts.includeSources ?? PROJECT_INSTRUCTION_SOURCE_SETS[opts.policy ?? "all"],
|
|
91
|
+
);
|
|
92
|
+
const includeInherited = opts.includeInherited !== false;
|
|
93
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
94
|
+
|
|
95
|
+
const resolved = resolveProjectEntries(project.id, { includeInherited });
|
|
96
|
+
const filtered = resolved.filter((entry) => kindRank.has(entry.kind) && sources.has(entry.source));
|
|
97
|
+
|
|
98
|
+
// Deterministic ordering, independent of DB row order/timestamps.
|
|
99
|
+
filtered.sort((a, b) => {
|
|
100
|
+
const byKind = (kindRank.get(a.kind) ?? 0) - (kindRank.get(b.kind) ?? 0);
|
|
101
|
+
if (byKind !== 0) return byKind;
|
|
102
|
+
const byInherited = Number(a.inherited) - Number(b.inherited); // own (false) first
|
|
103
|
+
if (byInherited !== 0) return byInherited;
|
|
104
|
+
const ta = a.title.trim();
|
|
105
|
+
const tb = b.title.trim();
|
|
106
|
+
if (ta !== tb) return ta < tb ? -1 : 1;
|
|
107
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Dedup, own-wins (own sorts before inherited above).
|
|
111
|
+
const seen = new Set<string>();
|
|
112
|
+
const deduped: ResolvedProjectEntry[] = [];
|
|
113
|
+
for (const entry of filtered) {
|
|
114
|
+
const key = dedupKey(entry);
|
|
115
|
+
if (seen.has(key)) continue;
|
|
116
|
+
seen.add(key);
|
|
117
|
+
deduped.push(entry);
|
|
118
|
+
}
|
|
119
|
+
if (deduped.length === 0) return "";
|
|
120
|
+
|
|
121
|
+
// Greedy size-bounded render. Entries are contiguous by kind (primary sort key), so a
|
|
122
|
+
// per-kind `## heading` is emitted once when the first entry of that kind is included.
|
|
123
|
+
const header = `# Project knowledge: ${project.title?.trim() || project.slug}`;
|
|
124
|
+
const budget = maxBytes - byteLen(`\n\n${TRUNCATION_NOTICE}`);
|
|
125
|
+
const lines: string[] = [header];
|
|
126
|
+
let bytes = byteLen(header);
|
|
127
|
+
let lastKind: ProjectEntryKind | null = null;
|
|
128
|
+
let truncated = false;
|
|
129
|
+
|
|
130
|
+
for (const entry of deduped) {
|
|
131
|
+
const segments: string[] = [];
|
|
132
|
+
if (entry.kind !== lastKind) segments.push("", `## ${KIND_HEADINGS[entry.kind]}`);
|
|
133
|
+
segments.push(entryLine(entry));
|
|
134
|
+
const addition = `\n${segments.join("\n")}`;
|
|
135
|
+
if (bytes + byteLen(addition) > budget) {
|
|
136
|
+
truncated = true;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
lines.push(...segments);
|
|
140
|
+
bytes += byteLen(addition);
|
|
141
|
+
lastKind = entry.kind;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (truncated) lines.push("", TRUNCATION_NOTICE);
|
|
145
|
+
let out = lines.join("\n");
|
|
146
|
+
// Safety net: guarantee the hard byte cap even if the header + first entry overshoot.
|
|
147
|
+
if (byteLen(out) > maxBytes) {
|
|
148
|
+
out = Buffer.from(out, "utf8").subarray(0, maxBytes).toString("utf8");
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Parse a spawn-transport's `projectInstructions` wire value into the normalized policy
|
|
155
|
+
* (or undefined = OFF). Shared by the MCP and HTTP spawn paths so they cannot drift.
|
|
156
|
+
* - undefined / null / false → undefined (OFF)
|
|
157
|
+
* - true → {} (ON, default policy)
|
|
158
|
+
* - { policy } → validated policy
|
|
159
|
+
* Throws `onInvalid(message)` for a malformed shape or unknown policy name.
|
|
160
|
+
*/
|
|
161
|
+
export function parseProjectInstructions(
|
|
162
|
+
value: unknown,
|
|
163
|
+
onInvalid: (message: string) => Error,
|
|
164
|
+
): ProjectInstructionPolicy | undefined {
|
|
165
|
+
if (value === undefined || value === null || value === false) return undefined;
|
|
166
|
+
if (value === true) return {};
|
|
167
|
+
if (typeof value !== "object") {
|
|
168
|
+
throw onInvalid("projectInstructions must be a boolean or { policy } object");
|
|
169
|
+
}
|
|
170
|
+
const policy = (value as Record<string, unknown>).policy;
|
|
171
|
+
if (policy === undefined) return {};
|
|
172
|
+
if (typeof policy !== "string" || !PROJECT_INSTRUCTION_POLICY_NAMES.includes(policy as ProjectInstructionPolicyName)) {
|
|
173
|
+
throw onInvalid(`projectInstructions.policy must be one of: ${PROJECT_INSTRUCTION_POLICY_NAMES.join(", ")}`);
|
|
174
|
+
}
|
|
175
|
+
return { policy: policy as ProjectInstructionPolicyName };
|
|
176
|
+
}
|
|
@@ -6,6 +6,7 @@ import { ValidationError, listAgents } from "../db";
|
|
|
6
6
|
import { cleanMeta, cleanNullableString, cleanString, cleanStringArray, optionalEnum } from "../validation";
|
|
7
7
|
import { registerAgent } from "../services/register-agent";
|
|
8
8
|
import { spawnAgent, type SpawnAgentInput } from "../services/spawn-agent";
|
|
9
|
+
import { parseProjectInstructions } from "../project-instructions";
|
|
9
10
|
import { authContextFromRequest } from "../services/auth-context";
|
|
10
11
|
import { listHostDirectories } from "../agent-spawn";
|
|
11
12
|
import { rankRouteCandidates, type RouteAdvisorInput } from "../context-router";
|
|
@@ -116,6 +117,7 @@ export const postAgentSpawn: Handler = async (req) => {
|
|
|
116
117
|
profile: cleanString(parsed.body.profile, "profile", { max: 120 }),
|
|
117
118
|
prompt: cleanString(parsed.body.prompt, "prompt", { max: 16000 }),
|
|
118
119
|
systemPromptAppend: cleanString(parsed.body.systemPromptAppend, "systemPromptAppend", { max: 64_000 }),
|
|
120
|
+
projectInstructions: parseProjectInstructions(parsed.body.projectInstructions, (m) => new ValidationError(m)),
|
|
119
121
|
tags: cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }),
|
|
120
122
|
capabilities: cleanStringArray(parsed.body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 }),
|
|
121
123
|
providerArgs: cleanStringArray(parsed.body.providerArgs, "providerArgs", { itemMax: 80, maxItems: 50 }),
|
package/src/routes/commands.ts
CHANGED
|
@@ -229,15 +229,21 @@ export const patchCommand: Handler = async (req, params) => {
|
|
|
229
229
|
if (newBranch) {
|
|
230
230
|
setWorkspaceBranch(workspaceId, newBranch, baseSha ?? mergedSha);
|
|
231
231
|
}
|
|
232
|
-
// #239 — push the author a "your branch landed" notice (no polling).
|
|
232
|
+
// #239 — push the author a "your branch landed" notice (no polling). Fires on a
|
|
233
233
|
// real land; a no-op resolution (#230) merged nothing, so it earns no notice.
|
|
234
|
-
|
|
234
|
+
// #423 — a PR-land RECYCLE is a no-op merge (`merged:false`) but a land DID happen
|
|
235
|
+
// on the remote: the relay carries that ground truth in `params.prLanded`, so the
|
|
236
|
+
// notice fires from it with the recycled `newBranch` — full rebase-ff parity.
|
|
237
|
+
const prLanded = isRecord(command.params.prLanded) ? command.params.prLanded : undefined;
|
|
238
|
+
if ((command.result.merged === true || prLanded) && landedWorkspace) {
|
|
235
239
|
notifyBranchLanded({
|
|
236
240
|
workspace: landedWorkspace,
|
|
237
|
-
mergedSha,
|
|
238
|
-
subject:
|
|
241
|
+
mergedSha: prLanded ? cleanString(prLanded.sha, "params.prLanded.sha", { max: 64 }) ?? mergedSha : mergedSha,
|
|
242
|
+
subject: prLanded
|
|
243
|
+
? cleanString(prLanded.subject, "params.prLanded.subject", { max: 200 })
|
|
244
|
+
: cleanString(command.result.subject, "result.subject", { max: 200 }),
|
|
239
245
|
newBranch,
|
|
240
|
-
pushed: typeof command.result.pushed === "boolean" ? command.result.pushed : undefined,
|
|
246
|
+
pushed: prLanded ? true : typeof command.result.pushed === "boolean" ? command.result.pushed : undefined,
|
|
241
247
|
});
|
|
242
248
|
}
|
|
243
249
|
}
|
|
@@ -32,6 +32,7 @@ import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } fr
|
|
|
32
32
|
import { runnerRuntimeTokenEnv } from "../runtime-tokens";
|
|
33
33
|
import { selectSpawnOrchestrator } from "../spawn-targets";
|
|
34
34
|
import { getAgentProfile } from "../config-store";
|
|
35
|
+
import { composeProjectInstructions, type ProjectInstructionPolicy } from "../project-instructions";
|
|
35
36
|
import { hasComponentScope, isComponentAuthorizedFor } from "../security";
|
|
36
37
|
import { isPathWithinBase } from "../utils";
|
|
37
38
|
import { McpAuthError, McpNotFoundError } from "../mcp-errors";
|
|
@@ -62,6 +63,16 @@ export interface SpawnAgentInput {
|
|
|
62
63
|
profile?: string;
|
|
63
64
|
prompt?: string;
|
|
64
65
|
systemPromptAppend?: string;
|
|
66
|
+
/**
|
|
67
|
+
* #409 — project instruction projection. When set, relay composes an instruction
|
|
68
|
+
* preamble from the spawn's resolved project (own entries + inherited `contains`-
|
|
69
|
+
* ancestors) and PREPENDS it to systemPromptAppend. Additive-from-empty (#403
|
|
70
|
+
* decision 4): the block is built only from `project_entries`, never from host
|
|
71
|
+
* CLAUDE.md. Absent (undefined) = OFF → the spawn command is byte-identical to today.
|
|
72
|
+
* `policy` selects the included source set: `all` (manual+promoted+ingested) or
|
|
73
|
+
* `vanilla` (manual+promoted; excludes `ingested` host-CLAUDE.md content).
|
|
74
|
+
*/
|
|
75
|
+
projectInstructions?: ProjectInstructionPolicy;
|
|
65
76
|
/**
|
|
66
77
|
* #413 — does this spawn carry a report-back contract? When true, relay seeds a pending
|
|
67
78
|
* reply obligation from the spawner to the child at registration, so the child can't go
|
|
@@ -197,6 +208,20 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
197
208
|
// resolves it. Absent (graceful) when the cwd is inside no registered root.
|
|
198
209
|
const callerProject = resolveProjectForCwd(resolvedCwd);
|
|
199
210
|
|
|
211
|
+
// #409 — instruction projection (default OFF). Only when the caller opts in AND the
|
|
212
|
+
// spawn cwd resolves to a project do we compose a knowledge preamble and PREPEND it to
|
|
213
|
+
// systemPromptAppend. Absent flag / no project / empty block ⇒ systemPromptAppend is
|
|
214
|
+
// left exactly as the caller passed it, so the spawn command stays byte-identical.
|
|
215
|
+
let systemPromptAppend = input.systemPromptAppend;
|
|
216
|
+
if (input.projectInstructions && callerProject) {
|
|
217
|
+
const block = composeProjectInstructions(callerProject, {
|
|
218
|
+
...(input.projectInstructions.policy ? { policy: input.projectInstructions.policy } : {}),
|
|
219
|
+
});
|
|
220
|
+
if (block) {
|
|
221
|
+
systemPromptAppend = systemPromptAppend ? `${block}\n\n${systemPromptAppend}` : block;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
200
225
|
// Child runner token. `agentInitiated` (set whenever an agent is the caller) forces a
|
|
201
226
|
// non-spawn-capable child (no grandchildren); `spawnedBy` stamps authoritative lineage the
|
|
202
227
|
// child can't forge (it registers with spawnedBy = caller, read from the signed token).
|
|
@@ -244,7 +269,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
244
269
|
permissionMode: approvalMode,
|
|
245
270
|
providerArgs: input.providerArgs ?? [],
|
|
246
271
|
prompt: input.prompt,
|
|
247
|
-
systemPromptAppend
|
|
272
|
+
systemPromptAppend,
|
|
248
273
|
...(expectReply ? { expectReply } : {}),
|
|
249
274
|
policyName: input.policyName,
|
|
250
275
|
spawnRequestId,
|