@chorus-aidlc/chorus-openclaw-plugin 0.5.3 → 0.11.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/dist/connection-state.d.ts +35 -0
- package/dist/connection-state.d.ts.map +1 -0
- package/dist/connection-state.js +52 -0
- package/dist/connection-state.js.map +1 -0
- package/dist/control-handler.d.ts +73 -0
- package/dist/control-handler.d.ts.map +1 -0
- package/dist/control-handler.js +135 -0
- package/dist/control-handler.js.map +1 -0
- package/dist/daemon-client.d.ts +203 -0
- package/dist/daemon-client.d.ts.map +1 -0
- package/dist/daemon-client.js +469 -0
- package/dist/daemon-client.js.map +1 -0
- package/dist/daemon-rest-client.d.ts +86 -0
- package/dist/daemon-rest-client.d.ts.map +1 -0
- package/dist/daemon-rest-client.js +196 -0
- package/dist/daemon-rest-client.js.map +1 -0
- package/dist/event-router.d.ts +31 -6
- package/dist/event-router.d.ts.map +1 -1
- package/dist/event-router.js +58 -27
- package/dist/event-router.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +106 -7
- package/dist/index.js.map +1 -1
- package/dist/lineage.d.ts +44 -0
- package/dist/lineage.d.ts.map +1 -0
- package/dist/lineage.js +116 -0
- package/dist/lineage.js.map +1 -0
- package/dist/mcp-registration.d.ts.map +1 -1
- package/dist/mcp-registration.js +5 -4
- package/dist/mcp-registration.js.map +1 -1
- package/dist/sse-listener.d.ts +34 -0
- package/dist/sse-listener.d.ts.map +1 -1
- package/dist/sse-listener.js +78 -4
- package/dist/sse-listener.js.map +1 -1
- package/dist/wake.d.ts +20 -0
- package/dist/wake.d.ts.map +1 -1
- package/dist/wake.js +56 -0
- package/dist/wake.js.map +1 -1
- package/package.json +1 -1
- package/skills/brainstorm/SKILL.md +1 -1
- package/skills/chorus/SKILL.md +37 -6
- package/skills/develop/SKILL.md +1 -1
- package/skills/idea/SKILL.md +18 -3
- package/skills/openspec-aware/SKILL.md +1 -1
- package/skills/proposal/SKILL.md +1 -1
- package/skills/proposal-reviewer/SKILL.md +1 -1
- package/skills/quick-dev/SKILL.md +1 -1
- package/skills/review/SKILL.md +1 -1
- package/skills/task-reviewer/SKILL.md +1 -1
- package/skills/yolo/SKILL.md +1 -1
- package/src/connection-state.ts +66 -0
- package/src/control-handler.ts +219 -0
- package/src/daemon-client.ts +622 -0
- package/src/daemon-rest-client.ts +312 -0
- package/src/event-router.ts +103 -33
- package/src/index.ts +113 -8
- package/src/lineage.ts +157 -0
- package/src/mcp-registration.ts +6 -19
- package/src/openclaw-sdk.d.ts +232 -1
- package/src/sse-listener.ts +117 -5
- package/src/wake.ts +69 -26
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// packages/openclaw-plugin/src/daemon-rest-client.ts
|
|
2
|
+
// TypeScript mirror of the shared, host-agnostic pure-REST client for the Chorus
|
|
3
|
+
// daemon → server reporting surface (`/api/daemon/*`).
|
|
4
|
+
//
|
|
5
|
+
// WHY A MIRROR (not an import): the single-source-of-truth implementation is
|
|
6
|
+
// `cli/daemon-rest-client.mjs`, consumed verbatim by the chorus CLI daemon. The
|
|
7
|
+
// OpenClaw plugin, however, is a SEPARATELY-PUBLISHED npm package
|
|
8
|
+
// (`@chorus-aidlc/chorus-openclaw-plugin`, `files: ["src", "dist", ...]`) whose
|
|
9
|
+
// TS build has `rootDir: "src"`. Importing a file under `cli/` (outside both the
|
|
10
|
+
// package boundary AND rootDir) is rejected by `tsc` and would not be present in
|
|
11
|
+
// the published tarball. So we mirror the EXACT same factory + payload shapes here
|
|
12
|
+
// — this is NOT a fork of the wire contract: every payload below is byte-for-byte
|
|
13
|
+
// the shape `cli/daemon-rest-client.mjs` sends (and the server already accepts).
|
|
14
|
+
// The two files are kept in lock-step by the spec's "single source of truth for
|
|
15
|
+
// the payload shapes" requirement; a drift would be caught by T5 (live e2e).
|
|
16
|
+
//
|
|
17
|
+
// The five operations and their EXACT server payload shapes (verified against
|
|
18
|
+
// cli/daemon-rest-client.mjs + src/app/api/daemon/*/route.ts — server unchanged):
|
|
19
|
+
// turnAdvance → POST /api/daemon/turn-advance
|
|
20
|
+
// { connectionUuid, sessionId, status, entityType?, entityUuid? }
|
|
21
|
+
// transcript → POST /api/daemon/transcript
|
|
22
|
+
// { sessionId, messages: [{ role, text }] }
|
|
23
|
+
// executionState → POST /api/daemon/execution-state
|
|
24
|
+
// { connectionUuid, executions: [{ entityType, entityUuid,
|
|
25
|
+
// rootIdeaUuid|null, status,
|
|
26
|
+
// startedAt|null }] }
|
|
27
|
+
// reportInterrupt → POST /api/daemon/report-interrupt
|
|
28
|
+
// { connectionUuid, entityType, entityUuid, reason }
|
|
29
|
+
// readPendingTurns → GET /api/daemon/pending-turns?connectionUuid=…
|
|
30
|
+
// → { turns: [{ turnUuid, sessionId, directIdeaUuid, trigger,
|
|
31
|
+
// promptText }] }
|
|
32
|
+
//
|
|
33
|
+
// HARD CONSTRAINTS (identical to the CLI client):
|
|
34
|
+
// • ZERO daemon-host coupling — no child_process, no OpenClaw SDK import. Its only
|
|
35
|
+
// outbound effect is HTTP via the injected `fetchImpl` (global fetch on Node 18+).
|
|
36
|
+
// Adds NO new npm dependency (CLAUDE.md pitfall #9).
|
|
37
|
+
// • Bearer-only auth: every request carries `Authorization: Bearer <apiKey>`.
|
|
38
|
+
// • NO SILENT ERRORS (project policy): a network error, a non-2xx response, or a
|
|
39
|
+
// bad/empty body is LOGGED WITH ITS CAUSE and SURFACED via a structured result —
|
|
40
|
+
// never swallowed into a silent success.
|
|
41
|
+
// • A failed report NEVER rejects: every method RESOLVES with `{ ok: false, ... }`
|
|
42
|
+
// so a fire-and-forget caller can `await` it safely.
|
|
43
|
+
|
|
44
|
+
export interface DaemonRestLogger {
|
|
45
|
+
info: (msg: string) => void;
|
|
46
|
+
warn: (msg: string) => void;
|
|
47
|
+
error: (msg: string) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const NOOP_LOGGER: DaemonRestLogger = { info() {}, warn() {}, error() {} };
|
|
51
|
+
|
|
52
|
+
/** A single transcript message — only `role` + visible `text` (no internals). */
|
|
53
|
+
export interface DaemonTranscriptMessage {
|
|
54
|
+
role: "user" | "assistant";
|
|
55
|
+
text: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One execution-snapshot row, in the server's exact `execution-state` shape. */
|
|
59
|
+
export interface DaemonExecutionRow {
|
|
60
|
+
entityType: string;
|
|
61
|
+
entityUuid: string;
|
|
62
|
+
rootIdeaUuid: string | null;
|
|
63
|
+
status: "running" | "queued";
|
|
64
|
+
startedAt: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One unstarted (pending) turn read back from the turn table. */
|
|
68
|
+
export interface DaemonPendingTurn {
|
|
69
|
+
turnUuid: string;
|
|
70
|
+
sessionId: string;
|
|
71
|
+
directIdeaUuid: string | null;
|
|
72
|
+
trigger: string;
|
|
73
|
+
promptText: string | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Structured result of every client call. Mirrors the CLI client's
|
|
78
|
+
* `DaemonRestResult`: `ok` is true only on a 2xx (and, for reads, a well-formed
|
|
79
|
+
* body); `error` carries the (also-logged) failure cause; `skipped` marks an
|
|
80
|
+
* intentional non-call (e.g. no connection uuid yet); `data` holds parsed read
|
|
81
|
+
* payloads.
|
|
82
|
+
*/
|
|
83
|
+
export interface DaemonRestResult<TData = unknown> {
|
|
84
|
+
ok: boolean;
|
|
85
|
+
status: number | null;
|
|
86
|
+
error?: string;
|
|
87
|
+
skipped?: boolean;
|
|
88
|
+
data?: TData;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface CreateDaemonRestClientOptions {
|
|
92
|
+
/** Chorus base URL (a trailing slash is normalized away). */
|
|
93
|
+
url: string;
|
|
94
|
+
/** `cho_` agent API key for Bearer auth. */
|
|
95
|
+
apiKey: string;
|
|
96
|
+
/**
|
|
97
|
+
* The daemon's registered connection uuid (learned from the SSE handshake),
|
|
98
|
+
* read LAZILY on every call so construction order does not matter. The
|
|
99
|
+
* connection-scoped operations (turnAdvance, executionState, reportInterrupt,
|
|
100
|
+
* readPendingTurns) require it; a null value skips the call (logged where the
|
|
101
|
+
* skip is unexpected, silent where it is a normal early state).
|
|
102
|
+
*/
|
|
103
|
+
getConnectionUuid?: () => string | null;
|
|
104
|
+
/** Injectable for tests (defaults to global fetch). */
|
|
105
|
+
fetchImpl?: typeof fetch;
|
|
106
|
+
logger?: DaemonRestLogger;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface DaemonRestClient {
|
|
110
|
+
turnAdvance(p: {
|
|
111
|
+
sessionId: string;
|
|
112
|
+
status: "running" | "ended";
|
|
113
|
+
entityType?: string | null;
|
|
114
|
+
entityUuid?: string | null;
|
|
115
|
+
}): Promise<DaemonRestResult>;
|
|
116
|
+
transcript(p: {
|
|
117
|
+
sessionId: string;
|
|
118
|
+
messages: DaemonTranscriptMessage[];
|
|
119
|
+
}): Promise<DaemonRestResult>;
|
|
120
|
+
executionState(p: { executions: DaemonExecutionRow[] }): Promise<DaemonRestResult>;
|
|
121
|
+
reportInterrupt(p: {
|
|
122
|
+
entityType: string;
|
|
123
|
+
entityUuid: string;
|
|
124
|
+
reason: "user" | "crash";
|
|
125
|
+
}): Promise<DaemonRestResult>;
|
|
126
|
+
readPendingTurns(): Promise<DaemonRestResult<{ turns: DaemonPendingTurn[] }>>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build the shared daemon REST client. Inputs are entirely host-agnostic, which is
|
|
131
|
+
* exactly why the same surface serves both daemon hosts.
|
|
132
|
+
*/
|
|
133
|
+
export function createDaemonRestClient(opts: CreateDaemonRestClientOptions): DaemonRestClient {
|
|
134
|
+
const url = opts.url.replace(/\/$/, "");
|
|
135
|
+
const apiKey = opts.apiKey;
|
|
136
|
+
const getConnectionUuid = opts.getConnectionUuid ?? (() => null);
|
|
137
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
138
|
+
const logger = opts.logger ?? NOOP_LOGGER;
|
|
139
|
+
|
|
140
|
+
const jsonHeaders = {
|
|
141
|
+
Authorization: `Bearer ${apiKey}`,
|
|
142
|
+
"Content-Type": "application/json",
|
|
143
|
+
Accept: "application/json",
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Issue one daemon report. Owns the transport + the no-silent-errors contract
|
|
148
|
+
* IDENTICAL across all four POST endpoints; only the `op` label and the path
|
|
149
|
+
* differ. Never throws — returns a structured {@link DaemonRestResult}.
|
|
150
|
+
*/
|
|
151
|
+
async function post(
|
|
152
|
+
op: string,
|
|
153
|
+
path: string,
|
|
154
|
+
body: unknown,
|
|
155
|
+
successLog?: string,
|
|
156
|
+
context = "",
|
|
157
|
+
): Promise<DaemonRestResult> {
|
|
158
|
+
let response: Response;
|
|
159
|
+
try {
|
|
160
|
+
response = await fetchImpl(`${url}${path}`, {
|
|
161
|
+
method: "POST",
|
|
162
|
+
headers: jsonHeaders,
|
|
163
|
+
body: JSON.stringify(body),
|
|
164
|
+
});
|
|
165
|
+
} catch (err) {
|
|
166
|
+
// Network-level failure (DNS, connection refused, abort, …). Surface WITH cause.
|
|
167
|
+
const error = `${op} request failed${context}: ${err}`;
|
|
168
|
+
logger.warn(`[Chorus] ${error}`);
|
|
169
|
+
return { ok: false, status: null, error };
|
|
170
|
+
}
|
|
171
|
+
if (!response.ok) {
|
|
172
|
+
// Non-2xx. Surface WITH the status so a 4xx/5xx is debuggable.
|
|
173
|
+
const error = `${op} returned ${response.status}${context}`;
|
|
174
|
+
logger.warn(`[Chorus] ${error}`);
|
|
175
|
+
return { ok: false, status: response.status, error };
|
|
176
|
+
}
|
|
177
|
+
if (successLog) logger.info(`[Chorus] ${successLog}`);
|
|
178
|
+
return { ok: true, status: response.status };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
/**
|
|
183
|
+
* POST /api/daemon/turn-advance — advance a wake's DaemonSessionTurn lifecycle.
|
|
184
|
+
* The server resolves the turn by the session BUSINESS KEY (`sessionId`); the
|
|
185
|
+
* optional `entityType`/`entityUuid` stamp the weak executionUuid link. Requires
|
|
186
|
+
* the connectionUuid.
|
|
187
|
+
*/
|
|
188
|
+
async turnAdvance({ sessionId, status, entityType, entityUuid }) {
|
|
189
|
+
const connectionUuid = getConnectionUuid();
|
|
190
|
+
if (!connectionUuid) {
|
|
191
|
+
const error = `cannot advance turn for session ${sessionId} → ${status} — no connection uuid yet`;
|
|
192
|
+
logger.warn(`[Chorus] ${error}`);
|
|
193
|
+
return { ok: false, status: null, error, skipped: true };
|
|
194
|
+
}
|
|
195
|
+
const body = {
|
|
196
|
+
connectionUuid,
|
|
197
|
+
sessionId,
|
|
198
|
+
status,
|
|
199
|
+
// Only sent when BOTH are present, so the server never gets a partial linkage.
|
|
200
|
+
...(entityType && entityUuid ? { entityType, entityUuid } : {}),
|
|
201
|
+
};
|
|
202
|
+
return post(
|
|
203
|
+
"turn-advance",
|
|
204
|
+
"/api/daemon/turn-advance",
|
|
205
|
+
body,
|
|
206
|
+
`advanced turn for session ${sessionId} → ${status}`,
|
|
207
|
+
);
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* POST /api/daemon/transcript — append finalized user/assistant text to the
|
|
212
|
+
* current turn, targeted by the session BUSINESS KEY. The caller owns the content
|
|
213
|
+
* filter (only `{ role, text }`) and any batching. No connectionUuid needed.
|
|
214
|
+
*/
|
|
215
|
+
async transcript({ sessionId, messages }) {
|
|
216
|
+
return post(
|
|
217
|
+
"transcript upload",
|
|
218
|
+
"/api/daemon/transcript",
|
|
219
|
+
{ sessionId, messages },
|
|
220
|
+
`transcript uploaded (${messages.length} msg) for session ${sessionId}`,
|
|
221
|
+
);
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* POST /api/daemon/execution-state — publish the connection's running/queued
|
|
226
|
+
* execution snapshot (caller supplies the already-built `executions` array).
|
|
227
|
+
* Requires the connectionUuid; a null uuid is a normal early state (silent skip).
|
|
228
|
+
*/
|
|
229
|
+
async executionState({ executions }) {
|
|
230
|
+
const connectionUuid = getConnectionUuid();
|
|
231
|
+
if (!connectionUuid) {
|
|
232
|
+
return { ok: false, status: null, skipped: true };
|
|
233
|
+
}
|
|
234
|
+
return post(
|
|
235
|
+
"execution-state upload",
|
|
236
|
+
"/api/daemon/execution-state",
|
|
237
|
+
{ connectionUuid, executions },
|
|
238
|
+
`execution-state uploaded (${executions.length} active)`,
|
|
239
|
+
);
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* POST /api/daemon/report-interrupt — record a wake's `interrupted` outcome
|
|
244
|
+
* (reason = "user" | "crash") on the execution row keyed by connection + entity.
|
|
245
|
+
*/
|
|
246
|
+
async reportInterrupt({ entityType, entityUuid, reason }) {
|
|
247
|
+
const connectionUuid = getConnectionUuid();
|
|
248
|
+
if (!connectionUuid) {
|
|
249
|
+
const error = `cannot report interrupt for ${entityType}:${entityUuid} — no connection uuid yet`;
|
|
250
|
+
logger.warn(`[Chorus] ${error}`);
|
|
251
|
+
return { ok: false, status: null, error, skipped: true };
|
|
252
|
+
}
|
|
253
|
+
return post(
|
|
254
|
+
"report-interrupt",
|
|
255
|
+
"/api/daemon/report-interrupt",
|
|
256
|
+
{ connectionUuid, entityType, entityUuid, reason },
|
|
257
|
+
`reported ${entityType}:${entityUuid} interrupted (reason=${reason})`,
|
|
258
|
+
` for ${entityType}:${entityUuid}`,
|
|
259
|
+
);
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* GET /api/daemon/pending-turns?connectionUuid=… — read this connection's
|
|
264
|
+
* unstarted (pending) turns. Returns the parsed `{ turns: [...] }` data on
|
|
265
|
+
* success; a network error / non-2xx / bad body / missing array is logged with
|
|
266
|
+
* cause and surfaced as a failure result — never a silent empty success.
|
|
267
|
+
*/
|
|
268
|
+
async readPendingTurns() {
|
|
269
|
+
const connectionUuid = getConnectionUuid();
|
|
270
|
+
if (!connectionUuid) {
|
|
271
|
+
return { ok: false, status: null, skipped: true };
|
|
272
|
+
}
|
|
273
|
+
const endpoint = `${url}/api/daemon/pending-turns?connectionUuid=${encodeURIComponent(connectionUuid)}`;
|
|
274
|
+
let response: Response;
|
|
275
|
+
try {
|
|
276
|
+
response = await fetchImpl(endpoint, {
|
|
277
|
+
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
278
|
+
});
|
|
279
|
+
} catch (err) {
|
|
280
|
+
const error = `pending-turns backfill request failed: ${err}`;
|
|
281
|
+
logger.warn(`[Chorus] ${error}`);
|
|
282
|
+
return { ok: false, status: null, error };
|
|
283
|
+
}
|
|
284
|
+
if (!response.ok) {
|
|
285
|
+
const error = `pending-turns backfill returned ${response.status}`;
|
|
286
|
+
logger.warn(`[Chorus] ${error}`);
|
|
287
|
+
return { ok: false, status: response.status, error };
|
|
288
|
+
}
|
|
289
|
+
let parsed: unknown;
|
|
290
|
+
try {
|
|
291
|
+
parsed = await response.json();
|
|
292
|
+
} catch (err) {
|
|
293
|
+
const error = `pending-turns backfill: bad JSON: ${err}`;
|
|
294
|
+
logger.warn(`[Chorus] ${error}`);
|
|
295
|
+
return { ok: false, status: response.status, error };
|
|
296
|
+
}
|
|
297
|
+
// API envelope: { success: true, data: { turns: [...] } }.
|
|
298
|
+
const data =
|
|
299
|
+
parsed && typeof parsed === "object"
|
|
300
|
+
? (parsed as { data?: unknown }).data
|
|
301
|
+
: undefined;
|
|
302
|
+
const turns =
|
|
303
|
+
data && typeof data === "object" ? (data as { turns?: unknown }).turns : undefined;
|
|
304
|
+
if (!Array.isArray(turns)) {
|
|
305
|
+
const error = "pending-turns backfill: no turns array in response";
|
|
306
|
+
logger.warn(`[Chorus] ${error}`);
|
|
307
|
+
return { ok: false, status: response.status, error };
|
|
308
|
+
}
|
|
309
|
+
return { ok: true, status: response.status, data: { turns: turns as DaemonPendingTurn[] } };
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
package/src/event-router.ts
CHANGED
|
@@ -1,21 +1,50 @@
|
|
|
1
1
|
import type { ChorusMcpClient } from "./mcp-client.js";
|
|
2
2
|
import type { SseNotificationEvent } from "./sse-listener.js";
|
|
3
|
+
import type { LineageResolver } from "./lineage.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Per-wake attribution the router resolves and threads to the wake. Carries the
|
|
7
|
+
* reportable resource (`entityType`/`entityUuid`) and the lineage ids (the two-id
|
|
8
|
+
* contract: `directIdeaUuid` = session anchor, `rootIdeaUuid` = snapshot attribution).
|
|
9
|
+
* All optional so a host wired WITHOUT the daemon client (no lineage) still wakes —
|
|
10
|
+
* the daemon-reporting fields are simply absent and the run is a plain wake.
|
|
11
|
+
*/
|
|
12
|
+
export interface WakeAttribution {
|
|
13
|
+
entityType?: string | null;
|
|
14
|
+
entityUuid?: string | null;
|
|
15
|
+
directIdeaUuid?: string | null;
|
|
16
|
+
rootIdeaUuid?: string | null;
|
|
17
|
+
}
|
|
3
18
|
|
|
4
19
|
/**
|
|
5
20
|
* Wake callback injected by the entry. Runs an embedded agent turn on the main
|
|
6
|
-
* agent's session with `message` as the prompt (see `wake.ts`
|
|
7
|
-
* which
|
|
21
|
+
* agent's session with `message` as the prompt (see `wake.ts` / daemon-client.ts,
|
|
22
|
+
* which call `api.runtime.agent.runEmbeddedAgent`).
|
|
8
23
|
*
|
|
9
24
|
* `contextKey` identifies the originating Chorus action+entity (e.g.
|
|
10
|
-
* `chorus:mentioned:<uuid>`); it is used for the run id / logging.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
25
|
+
* `chorus:mentioned:<uuid>`); it is used for the run id / logging. `attribution`
|
|
26
|
+
* (when present) lets the daemon client report turn-advance / execution-state /
|
|
27
|
+
* interrupt for the wake and anchor the session on the business key. The wake
|
|
28
|
+
* resolves the main agent session + model and DROPS (logs + returns) when it cannot
|
|
29
|
+
* run — it never throws, so the SSE service stays alive.
|
|
13
30
|
*/
|
|
14
|
-
export type ChorusWakeFn = (
|
|
31
|
+
export type ChorusWakeFn = (
|
|
32
|
+
message: string,
|
|
33
|
+
contextKey: string,
|
|
34
|
+
attribution?: WakeAttribution,
|
|
35
|
+
) => void;
|
|
15
36
|
|
|
16
37
|
export interface ChorusEventRouterOptions {
|
|
17
38
|
mcpClient: ChorusMcpClient;
|
|
18
39
|
wake: ChorusWakeFn;
|
|
40
|
+
/**
|
|
41
|
+
* Optional lineage resolver (daemon parity). When present, the router resolves each
|
|
42
|
+
* notification's `{ rootIdeaUuid, directIdeaUuid }` via the root-idea REST endpoint
|
|
43
|
+
* before waking, so the daemon client can anchor the session on the direct idea and
|
|
44
|
+
* report the root idea in its execution snapshot. When absent (a host with no daemon
|
|
45
|
+
* reporting), wakes carry only the entity fields the notification already provides.
|
|
46
|
+
*/
|
|
47
|
+
lineage?: LineageResolver;
|
|
19
48
|
logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
|
|
20
49
|
}
|
|
21
50
|
|
|
@@ -39,11 +68,13 @@ interface NotificationDetail {
|
|
|
39
68
|
export class ChorusEventRouter {
|
|
40
69
|
private readonly mcpClient: ChorusMcpClient;
|
|
41
70
|
private readonly wake: ChorusWakeFn;
|
|
71
|
+
private readonly lineage?: LineageResolver;
|
|
42
72
|
private readonly logger: ChorusEventRouterOptions["logger"];
|
|
43
73
|
|
|
44
74
|
constructor(opts: ChorusEventRouterOptions) {
|
|
45
75
|
this.mcpClient = opts.mcpClient;
|
|
46
76
|
this.wake = opts.wake;
|
|
77
|
+
this.lineage = opts.lineage;
|
|
47
78
|
this.logger = opts.logger;
|
|
48
79
|
}
|
|
49
80
|
|
|
@@ -52,6 +83,16 @@ export class ChorusEventRouter {
|
|
|
52
83
|
* Never throws — all errors are caught and logged internally.
|
|
53
84
|
*/
|
|
54
85
|
dispatch(event: SseNotificationEvent): void {
|
|
86
|
+
// The bidirectional daemon events `connection_registered` and `control` are
|
|
87
|
+
// forked upstream by the SSE listener (onConnectionId / onControl) and never
|
|
88
|
+
// reach the wake path. If one ever does arrive here (defense-in-depth), drop
|
|
89
|
+
// it SILENTLY — it is a known, handled-elsewhere event, NOT an unhandled one,
|
|
90
|
+
// so logging it as "ignored" would be misleading noise (and the spec requires
|
|
91
|
+
// connection_registered never be logged as ignored).
|
|
92
|
+
if (event.type === "connection_registered" || event.type === "control") {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
55
96
|
// Only handle new_notification events (ignore count_update, etc.)
|
|
56
97
|
if (event.type !== "new_notification") {
|
|
57
98
|
this.logger.info(`SSE event type "${event.type}" ignored`);
|
|
@@ -103,35 +144,55 @@ export class ChorusEventRouter {
|
|
|
103
144
|
return;
|
|
104
145
|
}
|
|
105
146
|
|
|
147
|
+
// Resolve the wake attribution ONCE per notification (daemon parity). The lineage
|
|
148
|
+
// resolver returns { rootIdeaUuid, directIdeaUuid } via the root-idea REST endpoint;
|
|
149
|
+
// both null when there's no idea ancestor or no resolver is wired. The entity
|
|
150
|
+
// fields always come straight off the notification. This threads through to the
|
|
151
|
+
// daemon client so it reports/anchors against the right session + resource.
|
|
152
|
+
let attribution: WakeAttribution = {
|
|
153
|
+
entityType: notification.entityType,
|
|
154
|
+
entityUuid: notification.entityUuid,
|
|
155
|
+
};
|
|
156
|
+
if (this.lineage) {
|
|
157
|
+
try {
|
|
158
|
+
const { rootIdeaUuid, directIdeaUuid } = await this.lineage.resolve(notification);
|
|
159
|
+
attribution = { ...attribution, rootIdeaUuid, directIdeaUuid };
|
|
160
|
+
} catch (err) {
|
|
161
|
+
// A lineage failure must not lose the wake — fall back to the entity-only
|
|
162
|
+
// attribution (the daemon client then anchors on the entity uuid). Logged.
|
|
163
|
+
this.logger.warn(`Lineage resolve failed for ${notification.entityType}:${notification.entityUuid}: ${err}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
106
167
|
// Route based on action (which corresponds to notificationType)
|
|
107
168
|
try {
|
|
108
169
|
switch (notification.action) {
|
|
109
170
|
case "task_assigned":
|
|
110
|
-
this.handleTaskAssigned(notification);
|
|
171
|
+
this.handleTaskAssigned(notification, attribution);
|
|
111
172
|
break;
|
|
112
173
|
case "mentioned":
|
|
113
|
-
this.handleMentioned(notification);
|
|
174
|
+
this.handleMentioned(notification, attribution);
|
|
114
175
|
break;
|
|
115
176
|
case "elaboration_requested":
|
|
116
|
-
this.handleElaborationRequested(notification);
|
|
177
|
+
this.handleElaborationRequested(notification, attribution);
|
|
117
178
|
break;
|
|
118
179
|
case "elaboration_answered":
|
|
119
|
-
this.handleElaborationAnswered(notification);
|
|
180
|
+
this.handleElaborationAnswered(notification, attribution);
|
|
120
181
|
break;
|
|
121
182
|
case "proposal_rejected":
|
|
122
|
-
this.handleProposalRejected(notification);
|
|
183
|
+
this.handleProposalRejected(notification, attribution);
|
|
123
184
|
break;
|
|
124
185
|
case "proposal_approved":
|
|
125
|
-
this.handleProposalApproved(notification);
|
|
186
|
+
this.handleProposalApproved(notification, attribution);
|
|
126
187
|
break;
|
|
127
188
|
case "idea_claimed":
|
|
128
|
-
this.handleIdeaClaimed(notification);
|
|
189
|
+
this.handleIdeaClaimed(notification, attribution);
|
|
129
190
|
break;
|
|
130
191
|
case "task_verified":
|
|
131
|
-
this.handleTaskVerified(notification);
|
|
192
|
+
this.handleTaskVerified(notification, attribution);
|
|
132
193
|
break;
|
|
133
194
|
case "task_reopened":
|
|
134
|
-
this.handleTaskReopened(notification);
|
|
195
|
+
this.handleTaskReopened(notification, attribution);
|
|
135
196
|
break;
|
|
136
197
|
default:
|
|
137
198
|
this.logger.info(`Unhandled notification action: "${notification.action}"`);
|
|
@@ -153,34 +214,37 @@ export class ChorusEventRouter {
|
|
|
153
214
|
);
|
|
154
215
|
}
|
|
155
216
|
|
|
156
|
-
private handleTaskAssigned(n: NotificationDetail): void {
|
|
217
|
+
private handleTaskAssigned(n: NotificationDetail, attr: WakeAttribution): void {
|
|
157
218
|
const mentionGuidance = this.buildMentionGuidance(n, "task");
|
|
158
219
|
|
|
159
220
|
this.wake(
|
|
160
221
|
`[Chorus] Task assigned: ${n.entityTitle}. Task UUID: ${n.entityUuid}, Project UUID: ${n.projectUuid}. Use chorus_get_task to review the task, then chorus_claim_task to start work.\n${mentionGuidance}`,
|
|
161
|
-
this.contextKeyFor("task_assigned", n.entityUuid)
|
|
222
|
+
this.contextKeyFor("task_assigned", n.entityUuid),
|
|
223
|
+
attr
|
|
162
224
|
);
|
|
163
225
|
}
|
|
164
226
|
|
|
165
|
-
private handleMentioned(n: NotificationDetail): void {
|
|
227
|
+
private handleMentioned(n: NotificationDetail, attr: WakeAttribution): void {
|
|
166
228
|
const mentionGuidance = this.buildMentionGuidance(n, n.entityType);
|
|
167
229
|
|
|
168
230
|
this.wake(
|
|
169
231
|
`[Chorus] You were @mentioned in ${n.entityType} '${n.entityTitle}' (entityType: ${n.entityType}, entityUuid: ${n.entityUuid}, projectUuid: ${n.projectUuid}): ${n.message}\n` +
|
|
170
232
|
`Review the ${n.entityType} content and use chorus_get_comments (targetType: "${n.entityType}", targetUuid: "${n.entityUuid}") to see the full conversation, then respond.\n` +
|
|
171
233
|
mentionGuidance,
|
|
172
|
-
this.contextKeyFor("mentioned", n.entityUuid)
|
|
234
|
+
this.contextKeyFor("mentioned", n.entityUuid),
|
|
235
|
+
attr
|
|
173
236
|
);
|
|
174
237
|
}
|
|
175
238
|
|
|
176
|
-
private handleElaborationRequested(n: NotificationDetail): void {
|
|
239
|
+
private handleElaborationRequested(n: NotificationDetail, attr: WakeAttribution): void {
|
|
177
240
|
this.wake(
|
|
178
241
|
`[Chorus] Elaboration requested for idea '${n.entityTitle}' (ideaUuid: ${n.entityUuid}, projectUuid: ${n.projectUuid}). Use chorus_get_elaboration to review questions.`,
|
|
179
|
-
this.contextKeyFor("elaboration_requested", n.entityUuid)
|
|
242
|
+
this.contextKeyFor("elaboration_requested", n.entityUuid),
|
|
243
|
+
attr
|
|
180
244
|
);
|
|
181
245
|
}
|
|
182
246
|
|
|
183
|
-
private handleProposalRejected(n: NotificationDetail): void {
|
|
247
|
+
private handleProposalRejected(n: NotificationDetail, attr: WakeAttribution): void {
|
|
184
248
|
const mentionGuidance = this.buildMentionGuidance(n, "proposal");
|
|
185
249
|
|
|
186
250
|
this.wake(
|
|
@@ -188,11 +252,12 @@ export class ChorusEventRouter {
|
|
|
188
252
|
`Use chorus_get_proposal to review the proposal, then fix issues with chorus_update_task_draft / chorus_update_document_draft. ` +
|
|
189
253
|
`After fixing, call chorus_validate_proposal then chorus_submit_proposal to resubmit.\n` +
|
|
190
254
|
mentionGuidance,
|
|
191
|
-
this.contextKeyFor("proposal_rejected", n.entityUuid)
|
|
255
|
+
this.contextKeyFor("proposal_rejected", n.entityUuid),
|
|
256
|
+
attr
|
|
192
257
|
);
|
|
193
258
|
}
|
|
194
259
|
|
|
195
|
-
private handleProposalApproved(n: NotificationDetail): void {
|
|
260
|
+
private handleProposalApproved(n: NotificationDetail, attr: WakeAttribution): void {
|
|
196
261
|
const mentionGuidance = this.buildMentionGuidance(n, "proposal");
|
|
197
262
|
|
|
198
263
|
const reviewInfo = n.message.includes("Note: ") ? ` Review note: "${n.message.split("Note: ").pop()}"` : "";
|
|
@@ -200,40 +265,44 @@ export class ChorusEventRouter {
|
|
|
200
265
|
`[Chorus] Proposal '${n.entityTitle}' was APPROVED (proposalUuid: ${n.entityUuid}, projectUuid: ${n.projectUuid})!${reviewInfo} Documents and tasks have been created. ` +
|
|
201
266
|
`Use chorus_get_available_tasks with projectUuid: "${n.projectUuid}" to see the new tasks ready for work.\n` +
|
|
202
267
|
mentionGuidance,
|
|
203
|
-
this.contextKeyFor("proposal_approved", n.entityUuid)
|
|
268
|
+
this.contextKeyFor("proposal_approved", n.entityUuid),
|
|
269
|
+
attr
|
|
204
270
|
);
|
|
205
271
|
}
|
|
206
272
|
|
|
207
|
-
private handleIdeaClaimed(n: NotificationDetail): void {
|
|
273
|
+
private handleIdeaClaimed(n: NotificationDetail, attr: WakeAttribution): void {
|
|
208
274
|
const mentionGuidance = this.buildMentionGuidance(n, "idea");
|
|
209
275
|
|
|
210
276
|
this.wake(
|
|
211
277
|
`[Chorus] Idea '${n.entityTitle}' has been assigned to you (ideaUuid: ${n.entityUuid}, projectUuid: ${n.projectUuid}). ` +
|
|
212
278
|
`Use chorus_get_idea to review the idea, then chorus_claim_idea to start elaboration.\n` +
|
|
213
279
|
mentionGuidance,
|
|
214
|
-
this.contextKeyFor("idea_claimed", n.entityUuid)
|
|
280
|
+
this.contextKeyFor("idea_claimed", n.entityUuid),
|
|
281
|
+
attr
|
|
215
282
|
);
|
|
216
283
|
}
|
|
217
284
|
|
|
218
|
-
private handleTaskVerified(n: NotificationDetail): void {
|
|
285
|
+
private handleTaskVerified(n: NotificationDetail, attr: WakeAttribution): void {
|
|
219
286
|
this.wake(
|
|
220
287
|
`[Chorus] Task '${n.entityTitle}' has been verified and is now done (taskUuid: ${n.entityUuid}, projectUuid: ${n.projectUuid}). ` +
|
|
221
288
|
`Check if this unblocks other tasks: use chorus_get_unblocked_tasks with projectUuid "${n.projectUuid}" to find tasks that are now ready to start.`,
|
|
222
|
-
this.contextKeyFor("task_verified", n.entityUuid)
|
|
289
|
+
this.contextKeyFor("task_verified", n.entityUuid),
|
|
290
|
+
attr
|
|
223
291
|
);
|
|
224
292
|
}
|
|
225
293
|
|
|
226
|
-
private handleTaskReopened(n: NotificationDetail): void {
|
|
294
|
+
private handleTaskReopened(n: NotificationDetail, attr: WakeAttribution): void {
|
|
227
295
|
const mentionGuidance = this.buildMentionGuidance(n, "task");
|
|
228
296
|
|
|
229
297
|
this.wake(
|
|
230
298
|
`[Chorus] Task '${n.entityTitle}' has been reopened and needs rework (taskUuid: ${n.entityUuid}, projectUuid: ${n.projectUuid}). ` +
|
|
231
299
|
`Use chorus_get_task to review the task and chorus_get_comments to see verification feedback, then fix the issues.\n${mentionGuidance}`,
|
|
232
|
-
this.contextKeyFor("task_reopened", n.entityUuid)
|
|
300
|
+
this.contextKeyFor("task_reopened", n.entityUuid),
|
|
301
|
+
attr
|
|
233
302
|
);
|
|
234
303
|
}
|
|
235
304
|
|
|
236
|
-
private handleElaborationAnswered(n: NotificationDetail): void {
|
|
305
|
+
private handleElaborationAnswered(n: NotificationDetail, attr: WakeAttribution): void {
|
|
237
306
|
const mentionGuidance = this.buildMentionGuidance(n, "idea");
|
|
238
307
|
|
|
239
308
|
this.wake(
|
|
@@ -243,7 +312,8 @@ export class ChorusEventRouter {
|
|
|
243
312
|
`- Call chorus_validate_elaboration with issues + followUpQuestions for another round\n\n` +
|
|
244
313
|
`After reviewing, @mention the answerer to ask if they have any further questions before you proceed.\n` +
|
|
245
314
|
mentionGuidance,
|
|
246
|
-
this.contextKeyFor("elaboration_answered", n.entityUuid)
|
|
315
|
+
this.contextKeyFor("elaboration_answered", n.entityUuid),
|
|
316
|
+
attr
|
|
247
317
|
);
|
|
248
318
|
}
|
|
249
319
|
}
|