@cleocode/core 2026.4.35 → 2026.4.37
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/config.d.ts.map +1 -1
- package/dist/config.js +7 -0
- package/dist/config.js.map +1 -1
- package/dist/hooks/handlers/conduit-hooks.d.ts +72 -0
- package/dist/hooks/handlers/conduit-hooks.d.ts.map +1 -0
- package/dist/hooks/handlers/conduit-hooks.js +229 -0
- package/dist/hooks/handlers/conduit-hooks.js.map +1 -0
- package/dist/hooks/handlers/index.d.ts +2 -0
- package/dist/hooks/handlers/index.d.ts.map +1 -1
- package/dist/hooks/handlers/index.js +3 -0
- package/dist/hooks/handlers/index.js.map +1 -1
- package/dist/hooks/handlers/session-hooks.d.ts +14 -0
- package/dist/hooks/handlers/session-hooks.d.ts.map +1 -1
- package/dist/hooks/handlers/session-hooks.js +33 -0
- package/dist/hooks/handlers/session-hooks.js.map +1 -1
- package/dist/hooks/handlers/task-hooks.d.ts +2 -0
- package/dist/hooks/handlers/task-hooks.d.ts.map +1 -1
- package/dist/hooks/handlers/task-hooks.js +14 -0
- package/dist/hooks/handlers/task-hooks.js.map +1 -1
- package/dist/index.js +54928 -46853
- package/dist/index.js.map +4 -4
- package/dist/internal.d.ts +2 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- package/dist/internal.js.map +1 -1
- package/dist/memory/anthropic-key-resolver.d.ts +35 -0
- package/dist/memory/anthropic-key-resolver.d.ts.map +1 -0
- package/dist/memory/anthropic-key-resolver.js +105 -0
- package/dist/memory/anthropic-key-resolver.js.map +1 -0
- package/dist/memory/auto-extract.d.ts +38 -42
- package/dist/memory/auto-extract.d.ts.map +1 -1
- package/dist/memory/auto-extract.js +38 -57
- package/dist/memory/auto-extract.js.map +1 -1
- package/dist/memory/brain-retrieval.d.ts +6 -0
- package/dist/memory/brain-retrieval.d.ts.map +1 -1
- package/dist/memory/brain-retrieval.js +145 -13
- package/dist/memory/brain-retrieval.js.map +1 -1
- package/dist/memory/brain-search.d.ts +82 -15
- package/dist/memory/brain-search.d.ts.map +1 -1
- package/dist/memory/brain-search.js +178 -93
- package/dist/memory/brain-search.js.map +1 -1
- package/dist/memory/engine-compat.d.ts +16 -1
- package/dist/memory/engine-compat.d.ts.map +1 -1
- package/dist/memory/engine-compat.js +0 -3
- package/dist/memory/engine-compat.js.map +1 -1
- package/dist/memory/learnings.d.ts.map +1 -1
- package/dist/memory/learnings.js +4 -3
- package/dist/memory/learnings.js.map +1 -1
- package/dist/memory/llm-extraction.d.ts +107 -0
- package/dist/memory/llm-extraction.d.ts.map +1 -0
- package/dist/memory/llm-extraction.js +425 -0
- package/dist/memory/llm-extraction.js.map +1 -0
- package/dist/memory/memory-bridge.js +23 -11
- package/dist/memory/memory-bridge.js.map +1 -1
- package/dist/memory/observer-reflector.d.ts +157 -0
- package/dist/memory/observer-reflector.d.ts.map +1 -0
- package/dist/memory/observer-reflector.js +626 -0
- package/dist/memory/observer-reflector.js.map +1 -0
- package/dist/store/brain-schema.d.ts +131 -0
- package/dist/store/brain-schema.d.ts.map +1 -1
- package/dist/store/brain-schema.js +30 -0
- package/dist/store/brain-schema.js.map +1 -1
- package/dist/store/brain-sqlite.js +41 -1
- package/dist/store/brain-sqlite.js.map +1 -1
- package/dist/tasks/complete.d.ts.map +1 -1
- package/dist/tasks/complete.js +7 -8
- package/dist/tasks/complete.js.map +1 -1
- package/package.json +13 -12
- package/src/config.ts +7 -0
- package/src/hooks/handlers/__tests__/conduit-hooks.test.ts +356 -0
- package/src/hooks/handlers/conduit-hooks.ts +258 -0
- package/src/hooks/handlers/index.ts +7 -0
- package/src/hooks/handlers/session-hooks.ts +37 -0
- package/src/hooks/handlers/task-hooks.ts +14 -0
- package/src/internal.ts +8 -0
- package/src/memory/__tests__/auto-extract.test.ts +43 -114
- package/src/memory/__tests__/brain-automation.test.ts +16 -39
- package/src/memory/__tests__/brain-rrf.test.ts +431 -0
- package/src/memory/__tests__/llm-extraction.test.ts +342 -0
- package/src/memory/__tests__/observer-reflector.test.ts +475 -0
- package/src/memory/anthropic-key-resolver.ts +113 -0
- package/src/memory/auto-extract.ts +40 -72
- package/src/memory/brain-retrieval.ts +187 -18
- package/src/memory/brain-search.ts +196 -128
- package/src/memory/engine-compat.ts +16 -4
- package/src/memory/learnings.ts +4 -3
- package/src/memory/llm-extraction.ts +524 -0
- package/src/memory/memory-bridge.ts +29 -12
- package/src/memory/observer-reflector.ts +829 -0
- package/src/store/brain-schema.ts +44 -0
- package/src/tasks/complete.ts +7 -10
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for conduit-hooks.ts
|
|
3
|
+
*
|
|
4
|
+
* Verifies that SubagentStart, SubagentStop, and SessionEnd handlers
|
|
5
|
+
* write the correct structured messages to conduit via LocalTransport.
|
|
6
|
+
*
|
|
7
|
+
* Mocks the LocalTransport module so no real conduit.db is required.
|
|
8
|
+
*
|
|
9
|
+
* @task T268
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Hoisted mock state — established before any module is imported
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const transportMocks = vi.hoisted(() => {
|
|
19
|
+
const pushFn = vi.fn().mockResolvedValue({ messageId: 'msg-1' });
|
|
20
|
+
const disconnectFn = vi.fn().mockResolvedValue(undefined);
|
|
21
|
+
const connectFn = vi.fn().mockResolvedValue(undefined);
|
|
22
|
+
const isAvailableFn = vi.fn().mockReturnValue(true);
|
|
23
|
+
const transportInstance = { connect: connectFn, push: pushFn, disconnect: disconnectFn };
|
|
24
|
+
|
|
25
|
+
return { pushFn, disconnectFn, connectFn, isAvailableFn, transportInstance };
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
vi.mock('../../../conduit/local-transport.js', () => {
|
|
29
|
+
class MockLocalTransport {
|
|
30
|
+
connect = transportMocks.connectFn;
|
|
31
|
+
push = transportMocks.pushFn;
|
|
32
|
+
disconnect = transportMocks.disconnectFn;
|
|
33
|
+
static isAvailable = transportMocks.isAvailableFn;
|
|
34
|
+
}
|
|
35
|
+
return { LocalTransport: MockLocalTransport };
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Subject under test — imported AFTER mocks are registered
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
import {
|
|
43
|
+
handleConduitSessionEnd,
|
|
44
|
+
handleConduitSubagentStart,
|
|
45
|
+
handleConduitSubagentStop,
|
|
46
|
+
tryGetLocalTransport,
|
|
47
|
+
} from '../conduit-hooks.js';
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Helpers
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/** Parse JSON content from the first push call argument at position `callIndex`. */
|
|
54
|
+
function parsePush(callIndex = 0): Record<string, unknown> {
|
|
55
|
+
const rawContent = transportMocks.pushFn.mock.calls[callIndex][1] as string;
|
|
56
|
+
return JSON.parse(rawContent) as Record<string, unknown>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Reset all transport mocks to fresh state. */
|
|
60
|
+
function resetMocks() {
|
|
61
|
+
transportMocks.pushFn.mockReset().mockResolvedValue({ messageId: 'msg-1' });
|
|
62
|
+
transportMocks.disconnectFn.mockReset().mockResolvedValue(undefined);
|
|
63
|
+
transportMocks.connectFn.mockReset().mockResolvedValue(undefined);
|
|
64
|
+
transportMocks.isAvailableFn.mockReturnValue(true);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// tryGetLocalTransport
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
describe('tryGetLocalTransport', () => {
|
|
72
|
+
beforeEach(resetMocks);
|
|
73
|
+
|
|
74
|
+
it('returns null when conduit.db is unavailable', async () => {
|
|
75
|
+
transportMocks.isAvailableFn.mockReturnValue(false);
|
|
76
|
+
|
|
77
|
+
const result = await tryGetLocalTransport('/tmp/project');
|
|
78
|
+
|
|
79
|
+
expect(result).toBeNull();
|
|
80
|
+
expect(transportMocks.connectFn).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('returns a connected transport instance when available', async () => {
|
|
84
|
+
const result = await tryGetLocalTransport('/tmp/project');
|
|
85
|
+
|
|
86
|
+
expect(result).not.toBeNull();
|
|
87
|
+
expect(transportMocks.connectFn).toHaveBeenCalledTimes(1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('returns null when connect throws', async () => {
|
|
91
|
+
transportMocks.connectFn.mockRejectedValue(new Error('connection refused'));
|
|
92
|
+
|
|
93
|
+
const result = await tryGetLocalTransport('/tmp/project');
|
|
94
|
+
|
|
95
|
+
expect(result).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// handleConduitSubagentStart
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
describe('handleConduitSubagentStart', () => {
|
|
104
|
+
beforeEach(resetMocks);
|
|
105
|
+
|
|
106
|
+
it('pushes a subagent.spawn message when conduit.db is available', async () => {
|
|
107
|
+
await handleConduitSubagentStart('/tmp/project', {
|
|
108
|
+
timestamp: '2026-04-13T10:00:00.000Z',
|
|
109
|
+
agentId: 'worker-42',
|
|
110
|
+
role: 'researcher',
|
|
111
|
+
taskId: 'T999',
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(transportMocks.pushFn).toHaveBeenCalledTimes(1);
|
|
115
|
+
const msg = parsePush();
|
|
116
|
+
expect(msg.type).toBe('subagent.spawn');
|
|
117
|
+
expect(msg.from).toBe('cleo-orchestrator');
|
|
118
|
+
expect(msg.to).toBe('worker-42');
|
|
119
|
+
expect(msg.taskId).toBe('T999');
|
|
120
|
+
expect(typeof msg.timestamp).toBe('string');
|
|
121
|
+
expect((msg.content as string).includes('worker-42')).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('includes role in content when provided', async () => {
|
|
125
|
+
await handleConduitSubagentStart('/tmp/project', {
|
|
126
|
+
timestamp: '2026-04-13T10:00:00.000Z',
|
|
127
|
+
agentId: 'worker-99',
|
|
128
|
+
role: 'implementer',
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const msg = parsePush();
|
|
132
|
+
expect((msg.content as string).includes('implementer')).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('sets taskId to null when not provided', async () => {
|
|
136
|
+
await handleConduitSubagentStart('/tmp/project', {
|
|
137
|
+
timestamp: '2026-04-13T10:00:00.000Z',
|
|
138
|
+
agentId: 'worker-no-task',
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const msg = parsePush();
|
|
142
|
+
expect(msg.taskId).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('does nothing when conduit.db is unavailable', async () => {
|
|
146
|
+
transportMocks.isAvailableFn.mockReturnValue(false);
|
|
147
|
+
|
|
148
|
+
await handleConduitSubagentStart('/tmp/project', {
|
|
149
|
+
timestamp: '2026-04-13T10:00:00.000Z',
|
|
150
|
+
agentId: 'worker-offline',
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(transportMocks.pushFn).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('swallows push errors so orchestration is never blocked', async () => {
|
|
157
|
+
transportMocks.pushFn.mockRejectedValue(new Error('SQLITE_ERROR: disk full'));
|
|
158
|
+
|
|
159
|
+
await expect(
|
|
160
|
+
handleConduitSubagentStart('/tmp/project', {
|
|
161
|
+
timestamp: '2026-04-13T10:00:00.000Z',
|
|
162
|
+
agentId: 'worker-crash',
|
|
163
|
+
taskId: 'T888',
|
|
164
|
+
}),
|
|
165
|
+
).resolves.toBeUndefined();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('always disconnects the transport after a successful push', async () => {
|
|
169
|
+
await handleConduitSubagentStart('/tmp/project', {
|
|
170
|
+
timestamp: '2026-04-13T10:00:00.000Z',
|
|
171
|
+
agentId: 'worker-cleanup',
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(transportMocks.disconnectFn).toHaveBeenCalledTimes(1);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('still disconnects when push throws', async () => {
|
|
178
|
+
transportMocks.pushFn.mockRejectedValue(new Error('push failed'));
|
|
179
|
+
|
|
180
|
+
await handleConduitSubagentStart('/tmp/project', {
|
|
181
|
+
timestamp: '2026-04-13T10:00:00.000Z',
|
|
182
|
+
agentId: 'worker-cleanup-on-error',
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(transportMocks.disconnectFn).toHaveBeenCalledTimes(1);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// handleConduitSubagentStop
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
describe('handleConduitSubagentStop', () => {
|
|
194
|
+
beforeEach(resetMocks);
|
|
195
|
+
|
|
196
|
+
it('pushes a subagent.complete message when conduit.db is available', async () => {
|
|
197
|
+
await handleConduitSubagentStop('/tmp/project', {
|
|
198
|
+
timestamp: '2026-04-13T11:00:00.000Z',
|
|
199
|
+
agentId: 'worker-42',
|
|
200
|
+
status: 'complete',
|
|
201
|
+
taskId: 'T999',
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(transportMocks.pushFn).toHaveBeenCalledTimes(1);
|
|
205
|
+
const msg = parsePush();
|
|
206
|
+
expect(msg.type).toBe('subagent.complete');
|
|
207
|
+
expect(msg.from).toBe('worker-42');
|
|
208
|
+
expect(msg.to).toBe('cleo-system');
|
|
209
|
+
expect(msg.taskId).toBe('T999');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('includes status in content', async () => {
|
|
213
|
+
await handleConduitSubagentStop('/tmp/project', {
|
|
214
|
+
timestamp: '2026-04-13T11:00:00.000Z',
|
|
215
|
+
agentId: 'worker-partial',
|
|
216
|
+
status: 'partial',
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const msg = parsePush();
|
|
220
|
+
expect((msg.content as string).includes('partial')).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('uses "unknown" when status is absent', async () => {
|
|
224
|
+
await handleConduitSubagentStop('/tmp/project', {
|
|
225
|
+
timestamp: '2026-04-13T11:00:00.000Z',
|
|
226
|
+
agentId: 'worker-no-status',
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const msg = parsePush();
|
|
230
|
+
expect((msg.content as string).includes('unknown')).toBe(true);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('does nothing when conduit.db is unavailable', async () => {
|
|
234
|
+
transportMocks.isAvailableFn.mockReturnValue(false);
|
|
235
|
+
|
|
236
|
+
await handleConduitSubagentStop('/tmp/project', {
|
|
237
|
+
timestamp: '2026-04-13T11:00:00.000Z',
|
|
238
|
+
agentId: 'worker-offline',
|
|
239
|
+
status: 'complete',
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
expect(transportMocks.pushFn).not.toHaveBeenCalled();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('swallows push errors so orchestration is never blocked', async () => {
|
|
246
|
+
transportMocks.pushFn.mockRejectedValue(new Error('conduit.db locked'));
|
|
247
|
+
|
|
248
|
+
await expect(
|
|
249
|
+
handleConduitSubagentStop('/tmp/project', {
|
|
250
|
+
timestamp: '2026-04-13T11:00:00.000Z',
|
|
251
|
+
agentId: 'worker-crash',
|
|
252
|
+
status: 'failed',
|
|
253
|
+
}),
|
|
254
|
+
).resolves.toBeUndefined();
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// handleConduitSessionEnd
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
describe('handleConduitSessionEnd', () => {
|
|
263
|
+
beforeEach(resetMocks);
|
|
264
|
+
|
|
265
|
+
it('pushes a session.handoff message when conduit.db is available', async () => {
|
|
266
|
+
await handleConduitSessionEnd('/tmp/project', {
|
|
267
|
+
timestamp: '2026-04-13T12:00:00.000Z',
|
|
268
|
+
sessionId: 'ses-test-1',
|
|
269
|
+
duration: 3600,
|
|
270
|
+
tasksCompleted: ['T100', 'T101'],
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
expect(transportMocks.pushFn).toHaveBeenCalledTimes(1);
|
|
274
|
+
const msg = parsePush();
|
|
275
|
+
expect(msg.type).toBe('session.handoff');
|
|
276
|
+
expect(msg.from).toBe('cleo-orchestrator');
|
|
277
|
+
expect(msg.to).toBe('cleo-system');
|
|
278
|
+
expect((msg.content as string).includes('ses-test-1')).toBe(true);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('includes nextTask in content and taskId when metadata.nextTask is set', async () => {
|
|
282
|
+
await handleConduitSessionEnd('/tmp/project', {
|
|
283
|
+
timestamp: '2026-04-13T12:00:00.000Z',
|
|
284
|
+
sessionId: 'ses-handoff',
|
|
285
|
+
duration: 1800,
|
|
286
|
+
tasksCompleted: ['T200'],
|
|
287
|
+
metadata: { nextTask: 'T201' },
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const msg = parsePush();
|
|
291
|
+
expect(msg.taskId).toBe('T201');
|
|
292
|
+
expect((msg.content as string).includes('T201')).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('sets taskId to null when no nextTask metadata', async () => {
|
|
296
|
+
await handleConduitSessionEnd('/tmp/project', {
|
|
297
|
+
timestamp: '2026-04-13T12:00:00.000Z',
|
|
298
|
+
sessionId: 'ses-no-next',
|
|
299
|
+
duration: 900,
|
|
300
|
+
tasksCompleted: [],
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const msg = parsePush();
|
|
304
|
+
expect(msg.taskId).toBeNull();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('ignores non-string nextTask metadata', async () => {
|
|
308
|
+
await handleConduitSessionEnd('/tmp/project', {
|
|
309
|
+
timestamp: '2026-04-13T12:00:00.000Z',
|
|
310
|
+
sessionId: 'ses-bad-meta',
|
|
311
|
+
duration: 100,
|
|
312
|
+
tasksCompleted: [],
|
|
313
|
+
metadata: { nextTask: 42 },
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const msg = parsePush();
|
|
317
|
+
expect(msg.taskId).toBeNull();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('does nothing when conduit.db is unavailable', async () => {
|
|
321
|
+
transportMocks.isAvailableFn.mockReturnValue(false);
|
|
322
|
+
|
|
323
|
+
await handleConduitSessionEnd('/tmp/project', {
|
|
324
|
+
timestamp: '2026-04-13T12:00:00.000Z',
|
|
325
|
+
sessionId: 'ses-offline',
|
|
326
|
+
duration: 0,
|
|
327
|
+
tasksCompleted: [],
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(transportMocks.pushFn).not.toHaveBeenCalled();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('swallows push errors so session end is never blocked', async () => {
|
|
334
|
+
transportMocks.pushFn.mockRejectedValue(new Error('conduit write timeout'));
|
|
335
|
+
|
|
336
|
+
await expect(
|
|
337
|
+
handleConduitSessionEnd('/tmp/project', {
|
|
338
|
+
timestamp: '2026-04-13T12:00:00.000Z',
|
|
339
|
+
sessionId: 'ses-crash',
|
|
340
|
+
duration: 500,
|
|
341
|
+
tasksCompleted: ['T300'],
|
|
342
|
+
}),
|
|
343
|
+
).resolves.toBeUndefined();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('always disconnects the transport after a successful push', async () => {
|
|
347
|
+
await handleConduitSessionEnd('/tmp/project', {
|
|
348
|
+
timestamp: '2026-04-13T12:00:00.000Z',
|
|
349
|
+
sessionId: 'ses-cleanup',
|
|
350
|
+
duration: 600,
|
|
351
|
+
tasksCompleted: [],
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
expect(transportMocks.disconnectFn).toHaveBeenCalledTimes(1);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conduit Messaging Hook Handlers
|
|
3
|
+
*
|
|
4
|
+
* Captures orchestration lifecycle events (SubagentStart, SubagentStop,
|
|
5
|
+
* SessionEnd) and writes structured messages to conduit.db via LocalTransport.
|
|
6
|
+
* This is the DECOUPLED approach: hooks observe orchestration events without
|
|
7
|
+
* any changes to the orchestrate engine itself.
|
|
8
|
+
*
|
|
9
|
+
* Message format (JSON string stored as conduit message content):
|
|
10
|
+
* { type, from, to, content, taskId, timestamp }
|
|
11
|
+
*
|
|
12
|
+
* All handlers are best-effort — failures are silently swallowed so that
|
|
13
|
+
* conduit writes NEVER crash or block agent orchestration.
|
|
14
|
+
*
|
|
15
|
+
* Auto-registers on module load.
|
|
16
|
+
*
|
|
17
|
+
* @task T268
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { LocalTransport } from '../../conduit/local-transport.js';
|
|
21
|
+
import { getLogger } from '../../logger.js';
|
|
22
|
+
import { hooks } from '../registry.js';
|
|
23
|
+
import type { SessionEndPayload, SubagentStartPayload, SubagentStopPayload } from '../types.js';
|
|
24
|
+
|
|
25
|
+
/** Well-known system agent ID used as the "from" sender for lifecycle messages. */
|
|
26
|
+
const SYSTEM_AGENT_ID = 'cleo-orchestrator';
|
|
27
|
+
|
|
28
|
+
/** Well-known broadcast recipient for lifecycle events. */
|
|
29
|
+
const BROADCAST_RECIPIENT = 'cleo-system';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Internal helpers
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build the content string for a SubagentStart conduit message.
|
|
37
|
+
*
|
|
38
|
+
* @param payload - SubagentStart event payload.
|
|
39
|
+
* @returns JSON-serialised message content string.
|
|
40
|
+
*/
|
|
41
|
+
function buildSpawnMessageContent(payload: SubagentStartPayload): string {
|
|
42
|
+
return JSON.stringify({
|
|
43
|
+
type: 'subagent.spawn',
|
|
44
|
+
from: SYSTEM_AGENT_ID,
|
|
45
|
+
to: payload.agentId,
|
|
46
|
+
content: `Subagent spawned: ${payload.agentId}${payload.role ? ` (${payload.role})` : ''}`,
|
|
47
|
+
taskId: payload.taskId ?? null,
|
|
48
|
+
timestamp: payload.timestamp,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the content string for a SubagentStop conduit message.
|
|
54
|
+
*
|
|
55
|
+
* @param payload - SubagentStop event payload.
|
|
56
|
+
* @returns JSON-serialised message content string.
|
|
57
|
+
*/
|
|
58
|
+
function buildCompletionMessageContent(payload: SubagentStopPayload): string {
|
|
59
|
+
return JSON.stringify({
|
|
60
|
+
type: 'subagent.complete',
|
|
61
|
+
from: payload.agentId,
|
|
62
|
+
to: BROADCAST_RECIPIENT,
|
|
63
|
+
content: `Subagent completed: ${payload.agentId} status=${payload.status ?? 'unknown'}${payload.taskId ? ` task=${payload.taskId}` : ''}`,
|
|
64
|
+
taskId: payload.taskId ?? null,
|
|
65
|
+
timestamp: payload.timestamp,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build the content string for a SessionEnd handoff conduit message.
|
|
71
|
+
*
|
|
72
|
+
* @param payload - SessionEnd event payload.
|
|
73
|
+
* @param nextTask - Optional next suggested task ID from the session.
|
|
74
|
+
* @returns JSON-serialised message content string.
|
|
75
|
+
*/
|
|
76
|
+
function buildHandoffMessageContent(payload: SessionEndPayload, nextTask?: string): string {
|
|
77
|
+
return JSON.stringify({
|
|
78
|
+
type: 'session.handoff',
|
|
79
|
+
from: SYSTEM_AGENT_ID,
|
|
80
|
+
to: BROADCAST_RECIPIENT,
|
|
81
|
+
content: `Session ended: ${payload.sessionId}${nextTask ? ` \u2014 next task: ${nextTask}` : ''}`,
|
|
82
|
+
taskId: nextTask ?? null,
|
|
83
|
+
timestamp: payload.timestamp,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Attempt to create and connect a LocalTransport for conduit.db.
|
|
89
|
+
*
|
|
90
|
+
* Returns null when conduit.db is unavailable (not yet initialised), or when
|
|
91
|
+
* the connect call fails, so callers can bail out gracefully without throwing.
|
|
92
|
+
*
|
|
93
|
+
* The `transportFactory` parameter exists for testing: callers can inject a
|
|
94
|
+
* mock constructor so no real conduit.db is required in unit tests.
|
|
95
|
+
*
|
|
96
|
+
* @param projectRoot - Absolute path to the project root directory.
|
|
97
|
+
* @param transportFactory - Optional factory used to construct the transport.
|
|
98
|
+
* Defaults to the `LocalTransport` class.
|
|
99
|
+
*/
|
|
100
|
+
export async function tryGetLocalTransport(
|
|
101
|
+
projectRoot: string,
|
|
102
|
+
transportFactory: typeof LocalTransport = LocalTransport,
|
|
103
|
+
): Promise<InstanceType<typeof LocalTransport> | null> {
|
|
104
|
+
try {
|
|
105
|
+
if (!transportFactory.isAvailable(projectRoot)) return null;
|
|
106
|
+
|
|
107
|
+
const transport = new transportFactory();
|
|
108
|
+
await transport.connect({
|
|
109
|
+
agentId: SYSTEM_AGENT_ID,
|
|
110
|
+
apiKey: '',
|
|
111
|
+
apiBaseUrl: 'local',
|
|
112
|
+
});
|
|
113
|
+
return transport;
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Handlers
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Handle SubagentStart — send a spawn message to conduit.db.
|
|
125
|
+
*
|
|
126
|
+
* Writes a `subagent.spawn` message from `cleo-orchestrator` to the
|
|
127
|
+
* spawned agent ID so orchestrators and watchers can observe the event.
|
|
128
|
+
*
|
|
129
|
+
* Best-effort: failures are swallowed and logged at debug level.
|
|
130
|
+
*
|
|
131
|
+
* @param projectRoot - Absolute path to the project root directory.
|
|
132
|
+
* @param payload - SubagentStart event payload.
|
|
133
|
+
*/
|
|
134
|
+
export async function handleConduitSubagentStart(
|
|
135
|
+
projectRoot: string,
|
|
136
|
+
payload: SubagentStartPayload,
|
|
137
|
+
): Promise<void> {
|
|
138
|
+
const transport = await tryGetLocalTransport(projectRoot);
|
|
139
|
+
if (!transport) return;
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const content = buildSpawnMessageContent(payload);
|
|
143
|
+
await transport.push(payload.agentId, content);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
getLogger('conduit-hooks').debug(
|
|
146
|
+
{ err, agentId: payload.agentId },
|
|
147
|
+
'conduit spawn write failed',
|
|
148
|
+
);
|
|
149
|
+
} finally {
|
|
150
|
+
try {
|
|
151
|
+
await transport.disconnect();
|
|
152
|
+
} catch {
|
|
153
|
+
// Disconnect errors are ignored
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Handle SubagentStop — send a completion message to conduit.db.
|
|
160
|
+
*
|
|
161
|
+
* Writes a `subagent.complete` message from the stopped agent to
|
|
162
|
+
* `cleo-system` so orchestrators can audit completion outcomes.
|
|
163
|
+
*
|
|
164
|
+
* Best-effort: failures are swallowed and logged at debug level.
|
|
165
|
+
*
|
|
166
|
+
* @param projectRoot - Absolute path to the project root directory.
|
|
167
|
+
* @param payload - SubagentStop event payload.
|
|
168
|
+
*/
|
|
169
|
+
export async function handleConduitSubagentStop(
|
|
170
|
+
projectRoot: string,
|
|
171
|
+
payload: SubagentStopPayload,
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
const transport = await tryGetLocalTransport(projectRoot);
|
|
174
|
+
if (!transport) return;
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const content = buildCompletionMessageContent(payload);
|
|
178
|
+
await transport.push(BROADCAST_RECIPIENT, content);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
getLogger('conduit-hooks').debug(
|
|
181
|
+
{ err, agentId: payload.agentId },
|
|
182
|
+
'conduit complete write failed',
|
|
183
|
+
);
|
|
184
|
+
} finally {
|
|
185
|
+
try {
|
|
186
|
+
await transport.disconnect();
|
|
187
|
+
} catch {
|
|
188
|
+
// Disconnect errors are ignored
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Handle SessionEnd — send a handoff message if a next task is suggested.
|
|
195
|
+
*
|
|
196
|
+
* Writes a `session.handoff` message to `cleo-system` when the session
|
|
197
|
+
* metadata includes a `nextTask` suggestion (stored in `metadata.nextTask`).
|
|
198
|
+
* The message lets waiting agents pick up where the session left off.
|
|
199
|
+
*
|
|
200
|
+
* Best-effort: failures are swallowed and logged at debug level.
|
|
201
|
+
*
|
|
202
|
+
* @param projectRoot - Absolute path to the project root directory.
|
|
203
|
+
* @param payload - SessionEnd event payload.
|
|
204
|
+
*/
|
|
205
|
+
export async function handleConduitSessionEnd(
|
|
206
|
+
projectRoot: string,
|
|
207
|
+
payload: SessionEndPayload,
|
|
208
|
+
): Promise<void> {
|
|
209
|
+
const transport = await tryGetLocalTransport(projectRoot);
|
|
210
|
+
if (!transport) return;
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
// Extract optional next-task hint from metadata
|
|
214
|
+
const nextTask =
|
|
215
|
+
typeof payload.metadata?.nextTask === 'string' ? payload.metadata.nextTask : undefined;
|
|
216
|
+
|
|
217
|
+
const content = buildHandoffMessageContent(payload, nextTask);
|
|
218
|
+
await transport.push(BROADCAST_RECIPIENT, content);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
getLogger('conduit-hooks').debug(
|
|
221
|
+
{ err, sessionId: payload.sessionId },
|
|
222
|
+
'conduit handoff write failed',
|
|
223
|
+
);
|
|
224
|
+
} finally {
|
|
225
|
+
try {
|
|
226
|
+
await transport.disconnect();
|
|
227
|
+
} catch {
|
|
228
|
+
// Disconnect errors are ignored
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Auto-registration
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
hooks.register({
|
|
238
|
+
id: 'conduit-subagent-start',
|
|
239
|
+
event: 'SubagentStart',
|
|
240
|
+
handler: handleConduitSubagentStart,
|
|
241
|
+
// Priority 50: runs after brain capture (100) but before low-priority bookkeeping
|
|
242
|
+
priority: 50,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
hooks.register({
|
|
246
|
+
id: 'conduit-subagent-stop',
|
|
247
|
+
event: 'SubagentStop',
|
|
248
|
+
handler: handleConduitSubagentStop,
|
|
249
|
+
priority: 50,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
hooks.register({
|
|
253
|
+
id: 'conduit-session-end',
|
|
254
|
+
event: 'SessionEnd',
|
|
255
|
+
handler: handleConduitSessionEnd,
|
|
256
|
+
// Priority 8: runs after brain (100), backup (10), but before consolidation (5)
|
|
257
|
+
priority: 8,
|
|
258
|
+
});
|
|
@@ -20,8 +20,15 @@ import './context-hooks.js';
|
|
|
20
20
|
import './watchdog-hooks.js';
|
|
21
21
|
// T549 Wave 5-D/E: Intelligence hooks (best-effort risk detection on task start)
|
|
22
22
|
import './intelligence-hooks.js';
|
|
23
|
+
// Conduit messaging hooks — write orchestration events to conduit.db
|
|
24
|
+
import './conduit-hooks.js';
|
|
23
25
|
|
|
24
26
|
export { handleSubagentStart, handleSubagentStop } from './agent-hooks.js';
|
|
27
|
+
export {
|
|
28
|
+
handleConduitSessionEnd,
|
|
29
|
+
handleConduitSubagentStart,
|
|
30
|
+
handleConduitSubagentStop,
|
|
31
|
+
} from './conduit-hooks.js';
|
|
25
32
|
export { handlePostCompact, handlePreCompact } from './context-hooks.js';
|
|
26
33
|
export { handleError } from './error-hooks.js';
|
|
27
34
|
export { handleFileChange } from './file-hooks.js';
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* was pure noise.
|
|
16
16
|
* T549 Wave 3-E: Fire-and-forget sleep-time consolidation on session end.
|
|
17
17
|
* Runs after backup (priority 5) so brain.db snapshot is captured first.
|
|
18
|
+
* T554: Fire-and-forget LLM reflector on session end. Runs at priority 4
|
|
19
|
+
* (after consolidation at priority 5) to synthesize final session knowledge.
|
|
18
20
|
*/
|
|
19
21
|
|
|
20
22
|
import { hooks } from '../registry.js';
|
|
@@ -141,6 +143,31 @@ export async function handleSessionEndConsolidation(
|
|
|
141
143
|
});
|
|
142
144
|
}
|
|
143
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Handle SessionEnd — fire-and-forget LLM reflector synthesis.
|
|
148
|
+
*
|
|
149
|
+
* T554: Runs the Reflector after the consolidation pass (priority 5) to
|
|
150
|
+
* synthesize session observations into durable patterns and learnings.
|
|
151
|
+
*
|
|
152
|
+
* Uses setImmediate to yield control before the LLM call. Errors are caught
|
|
153
|
+
* and logged — they MUST NOT block session end or throw to callers.
|
|
154
|
+
*
|
|
155
|
+
* Priority 4 ensures this runs after consolidation (priority 5).
|
|
156
|
+
*/
|
|
157
|
+
export async function handleSessionEndReflector(
|
|
158
|
+
projectRoot: string,
|
|
159
|
+
payload: SessionEndPayload,
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
setImmediate(async () => {
|
|
162
|
+
try {
|
|
163
|
+
const { runReflector } = await import('../../memory/observer-reflector.js');
|
|
164
|
+
await runReflector(projectRoot, payload.sessionId);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
console.warn('[reflector] Session-end reflector failed:', err);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
144
171
|
// Register handlers on module load
|
|
145
172
|
hooks.register({
|
|
146
173
|
id: 'brain-session-start',
|
|
@@ -174,3 +201,13 @@ hooks.register({
|
|
|
174
201
|
handler: handleSessionEndConsolidation,
|
|
175
202
|
priority: 5,
|
|
176
203
|
});
|
|
204
|
+
|
|
205
|
+
// Priority 4 runs AFTER consolidation (priority 5) — reflector synthesizes
|
|
206
|
+
// the final session knowledge using observations that consolidation may have
|
|
207
|
+
// updated (tier promotions, dedup).
|
|
208
|
+
hooks.register({
|
|
209
|
+
id: 'reflector-session-end',
|
|
210
|
+
event: 'SessionEnd',
|
|
211
|
+
handler: handleSessionEndReflector,
|
|
212
|
+
priority: 4,
|
|
213
|
+
});
|