@cleocode/core 2026.3.69 → 2026.3.70
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/agents/retry.d.ts.map +1 -1
- package/dist/agents/retry.js +23 -42
- package/dist/agents/retry.js.map +1 -1
- package/dist/cleo.js +2 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +30 -0
- package/dist/config.js.map +1 -1
- package/dist/hooks/handlers/file-hooks.d.ts +5 -2
- package/dist/hooks/handlers/file-hooks.d.ts.map +1 -1
- package/dist/hooks/handlers/index.d.ts +2 -0
- package/dist/hooks/handlers/index.d.ts.map +1 -1
- package/dist/hooks/handlers/mcp-hooks.d.ts +11 -7
- package/dist/hooks/handlers/mcp-hooks.d.ts.map +1 -1
- package/dist/hooks/handlers/memory-bridge-refresh.d.ts +20 -0
- package/dist/hooks/handlers/memory-bridge-refresh.d.ts.map +1 -0
- package/dist/hooks/handlers/memory-bridge-refresh.js +42 -0
- package/dist/hooks/handlers/memory-bridge-refresh.js.map +1 -0
- package/dist/hooks/handlers/session-hooks.d.ts +10 -0
- package/dist/hooks/handlers/session-hooks.d.ts.map +1 -1
- package/dist/hooks/handlers/session-hooks.js +36 -0
- package/dist/hooks/handlers/session-hooks.js.map +1 -1
- package/dist/hooks/handlers/task-hooks.d.ts +4 -0
- package/dist/hooks/handlers/task-hooks.d.ts.map +1 -1
- package/dist/hooks/handlers/task-hooks.js +7 -0
- package/dist/hooks/handlers/task-hooks.js.map +1 -1
- package/dist/hooks/handlers/work-capture-hooks.d.ts +40 -0
- package/dist/hooks/handlers/work-capture-hooks.d.ts.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5016 -4662
- package/dist/index.js.map +4 -4
- package/dist/internal.d.ts +10 -2
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +10 -3
- package/dist/internal.js.map +1 -1
- package/dist/memory/auto-extract.d.ts +13 -0
- package/dist/memory/auto-extract.d.ts.map +1 -1
- package/dist/memory/auto-extract.js +34 -0
- package/dist/memory/auto-extract.js.map +1 -1
- package/dist/memory/brain-embedding.d.ts +13 -0
- package/dist/memory/brain-embedding.d.ts.map +1 -1
- package/dist/memory/brain-embedding.js +17 -0
- package/dist/memory/brain-embedding.js.map +1 -1
- package/dist/memory/brain-maintenance.d.ts +110 -0
- package/dist/memory/brain-maintenance.d.ts.map +1 -0
- package/dist/memory/brain-maintenance.js +98 -0
- package/dist/memory/brain-maintenance.js.map +1 -0
- package/dist/memory/brain-retrieval.d.ts +31 -5
- package/dist/memory/brain-retrieval.d.ts.map +1 -1
- package/dist/memory/brain-retrieval.js +53 -6
- package/dist/memory/brain-retrieval.js.map +1 -1
- package/dist/memory/embedding-local.d.ts +55 -0
- package/dist/memory/embedding-local.d.ts.map +1 -0
- package/dist/memory/embedding-local.js +97 -0
- package/dist/memory/embedding-local.js.map +1 -0
- package/dist/memory/embedding-queue.d.ts +90 -0
- package/dist/memory/embedding-queue.d.ts.map +1 -0
- package/dist/memory/embedding-queue.js +271 -0
- package/dist/memory/embedding-queue.js.map +1 -0
- package/dist/memory/embedding-worker.d.ts +19 -0
- package/dist/memory/embedding-worker.d.ts.map +1 -0
- package/dist/memory/embedding-worker.js +58 -0
- package/dist/memory/embedding-worker.js.map +1 -0
- package/dist/memory/memory-bridge.d.ts +21 -1
- package/dist/memory/memory-bridge.d.ts.map +1 -1
- package/dist/memory/memory-bridge.js +83 -2
- package/dist/memory/memory-bridge.js.map +1 -1
- package/dist/memory/session-memory.d.ts +26 -0
- package/dist/memory/session-memory.d.ts.map +1 -1
- package/dist/memory/session-memory.js +105 -0
- package/dist/memory/session-memory.js.map +1 -1
- package/dist/pagination.js +3 -0
- package/dist/pagination.js.map +1 -1
- package/dist/sessions/index.d.ts.map +1 -1
- package/dist/sessions/index.js +2 -6
- package/dist/sessions/index.js.map +1 -1
- package/dist/store/brain-sqlite.js +13 -62
- package/dist/store/brain-sqlite.js.map +1 -1
- package/dist/store/migration-manager.js +151 -0
- package/dist/store/migration-manager.js.map +1 -0
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +16 -134
- package/dist/store/sqlite.js.map +1 -1
- package/dist/tasks/add.js +27 -22
- package/dist/tasks/add.js.map +1 -1
- package/dist/tasks/complete.d.ts.map +1 -1
- package/dist/tasks/complete.js +13 -40
- package/dist/tasks/complete.js.map +1 -1
- package/dist/tasks/enforcement.js +12 -15
- package/dist/tasks/enforcement.js.map +1 -1
- package/dist/upgrade.js +246 -3
- package/dist/upgrade.js.map +1 -1
- package/migrations/drizzle-tasks/20260320013731_wave0-schema-hardening/migration.sql +17 -17
- package/package.json +6 -5
- package/src/agents/retry.ts +30 -24
- package/src/config.ts +18 -0
- package/src/hooks/handlers/file-hooks.ts +29 -3
- package/src/hooks/handlers/index.ts +2 -0
- package/src/hooks/handlers/mcp-hooks.ts +32 -13
- package/src/hooks/handlers/memory-bridge-refresh.ts +47 -0
- package/src/hooks/handlers/session-hooks.ts +38 -0
- package/src/hooks/handlers/task-hooks.ts +8 -0
- package/src/hooks/handlers/work-capture-hooks.ts +184 -0
- package/src/index.ts +5 -0
- package/src/internal.ts +28 -2
- package/src/memory/__tests__/brain-automation.test.ts +941 -0
- package/src/memory/auto-extract.ts +40 -0
- package/src/memory/brain-embedding.ts +18 -0
- package/src/memory/brain-maintenance.ts +183 -0
- package/src/memory/brain-retrieval.ts +85 -7
- package/src/memory/embedding-local.ts +107 -0
- package/src/memory/embedding-queue.ts +304 -0
- package/src/memory/embedding-worker.ts +79 -0
- package/src/memory/memory-bridge.ts +101 -2
- package/src/memory/session-memory.ts +123 -0
- package/src/sessions/index.ts +2 -6
- package/src/store/__tests__/test-db-helper.js +14 -2
- package/src/store/__tests__/test-db-helper.ts +4 -1
- package/src/store/sqlite.ts +28 -0
- package/src/tasks/__tests__/complete-unblocks.test.ts +4 -1
- package/src/tasks/__tests__/complete.test.ts +18 -6
- package/src/tasks/__tests__/epic-enforcement.test.ts +4 -1
- package/src/tasks/__tests__/update.test.ts +4 -1
- package/src/tasks/complete.ts +8 -8
- package/templates/config.template.json +19 -0
- package/templates/global-config.template.json +19 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedding Queue Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages an async queue of embedding requests, processing them in batches
|
|
5
|
+
* via a worker thread to avoid blocking the main Node.js event loop.
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* - {@link EmbeddingQueue} is a singleton — one worker thread per process
|
|
9
|
+
* - {@link EmbeddingQueue.enqueue} adds items; the drain loop batches them
|
|
10
|
+
* - Batches up to {@link BATCH_SIZE} items per processing cycle
|
|
11
|
+
* - Falls back to `setImmediate` + direct embedding when worker threads
|
|
12
|
+
* are unavailable or the worker script cannot be resolved
|
|
13
|
+
* - Registers a `process.on('exit')` handler for graceful shutdown
|
|
14
|
+
*
|
|
15
|
+
* @epic T134
|
|
16
|
+
* @task T137
|
|
17
|
+
* @why Non-blocking embedding generation for observeBrain()
|
|
18
|
+
* @what Singleton queue + worker-thread batch processor
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync } from 'node:fs';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
/** Maximum items processed per worker message cycle. */
|
|
26
|
+
const BATCH_SIZE = 10;
|
|
27
|
+
|
|
28
|
+
/** How long to wait between drain cycles when the queue is non-empty (ms). */
|
|
29
|
+
const DRAIN_INTERVAL_MS = 50;
|
|
30
|
+
|
|
31
|
+
/** Pending queue item. */
|
|
32
|
+
interface QueueItem {
|
|
33
|
+
observationId: string;
|
|
34
|
+
text: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the absolute path to the compiled embedding-worker script.
|
|
39
|
+
*
|
|
40
|
+
* Strategy (cheap-first):
|
|
41
|
+
* 1. Adjacent to this file (tsc dev: `dist/memory/embedding-worker.js`)
|
|
42
|
+
* 2. Falls back to null when the file cannot be found (esbuild bundle context)
|
|
43
|
+
*/
|
|
44
|
+
function resolveWorkerPath(): string | null {
|
|
45
|
+
try {
|
|
46
|
+
// In ESM, __dirname is not available — use import.meta.url
|
|
47
|
+
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
const candidate = join(currentDir, 'embedding-worker.js');
|
|
49
|
+
if (existsSync(candidate)) {
|
|
50
|
+
return candidate;
|
|
51
|
+
}
|
|
52
|
+
// Also try ../ for cases where the queue is loaded from a subdirectory
|
|
53
|
+
const parentCandidate = join(currentDir, '..', 'memory', 'embedding-worker.js');
|
|
54
|
+
if (existsSync(parentCandidate)) {
|
|
55
|
+
return parentCandidate;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Singleton embedding queue.
|
|
65
|
+
*
|
|
66
|
+
* Batches embedding requests and processes them via a worker thread,
|
|
67
|
+
* keeping heavy model inference off the main event loop.
|
|
68
|
+
*
|
|
69
|
+
* Use {@link getEmbeddingQueue} to obtain the shared instance.
|
|
70
|
+
*/
|
|
71
|
+
export class EmbeddingQueue {
|
|
72
|
+
private readonly queue: QueueItem[] = [];
|
|
73
|
+
private worker: import('node:worker_threads').Worker | null = null;
|
|
74
|
+
private workerAvailable = false;
|
|
75
|
+
private draining = false;
|
|
76
|
+
private shutdownPromise: Promise<void> | null = null;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Store the observation ID → DB write callback so the worker result
|
|
80
|
+
* can be persisted back to brain_embeddings without coupling to SQLite here.
|
|
81
|
+
*/
|
|
82
|
+
private readonly callbacks = new Map<
|
|
83
|
+
string,
|
|
84
|
+
(observationId: string, embedding: Float32Array) => Promise<void>
|
|
85
|
+
>();
|
|
86
|
+
|
|
87
|
+
/** Initialise the worker thread if possible. Called lazily on first enqueue. */
|
|
88
|
+
private async initWorker(): Promise<void> {
|
|
89
|
+
if (this.workerAvailable || this.worker !== null) return;
|
|
90
|
+
|
|
91
|
+
const workerPath = resolveWorkerPath();
|
|
92
|
+
if (!workerPath) {
|
|
93
|
+
// esbuild bundle context — worker file not available, use fallback
|
|
94
|
+
this.workerAvailable = false;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const { Worker } = await import('node:worker_threads');
|
|
100
|
+
const worker = new Worker(workerPath);
|
|
101
|
+
|
|
102
|
+
worker.on('message', (msg: { id: string; embedding?: number[]; error?: string }) => {
|
|
103
|
+
const cb = this.callbacks.get(msg.id);
|
|
104
|
+
if (!cb) return;
|
|
105
|
+
this.callbacks.delete(msg.id);
|
|
106
|
+
|
|
107
|
+
if (msg.embedding) {
|
|
108
|
+
const vector = Float32Array.from(msg.embedding);
|
|
109
|
+
cb(msg.id, vector).catch(() => {
|
|
110
|
+
// Persistence is best-effort; observation already saved without vector
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
// On error, skip silently — observation exists without embedding
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
worker.on('error', () => {
|
|
117
|
+
// Worker crashed — disable worker path, drain remaining via fallback
|
|
118
|
+
this.worker = null;
|
|
119
|
+
this.workerAvailable = false;
|
|
120
|
+
this.callbacks.clear();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
worker.on('exit', () => {
|
|
124
|
+
this.worker = null;
|
|
125
|
+
this.workerAvailable = false;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
this.worker = worker;
|
|
129
|
+
this.workerAvailable = true;
|
|
130
|
+
} catch {
|
|
131
|
+
// worker_threads unavailable (rare)
|
|
132
|
+
this.workerAvailable = false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Add an observation to the embedding queue.
|
|
138
|
+
*
|
|
139
|
+
* The observation must already be persisted in brain_observations before
|
|
140
|
+
* calling this method. `onComplete` is called asynchronously with the
|
|
141
|
+
* generated vector once the worker finishes.
|
|
142
|
+
*
|
|
143
|
+
* @param observationId - The brain observation ID (e.g. `O-abc123-0`)
|
|
144
|
+
* @param text - Raw text to embed
|
|
145
|
+
* @param onComplete - Callback to persist the embedding vector
|
|
146
|
+
*/
|
|
147
|
+
enqueue(
|
|
148
|
+
observationId: string,
|
|
149
|
+
text: string,
|
|
150
|
+
onComplete: (observationId: string, embedding: Float32Array) => Promise<void>,
|
|
151
|
+
): void {
|
|
152
|
+
if (this.shutdownPromise) return; // queue is shutting down
|
|
153
|
+
this.callbacks.set(observationId, onComplete);
|
|
154
|
+
this.queue.push({ observationId, text });
|
|
155
|
+
if (!this.draining) {
|
|
156
|
+
this.scheduleDrain();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Schedule the next drain cycle via setImmediate. */
|
|
161
|
+
private scheduleDrain(): void {
|
|
162
|
+
setImmediate(() => {
|
|
163
|
+
this.drain().catch(() => {
|
|
164
|
+
// Drain errors are non-fatal
|
|
165
|
+
this.draining = false;
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Process up to {@link BATCH_SIZE} items from the queue.
|
|
172
|
+
* Uses the worker thread when available, falls back to inline setImmediate.
|
|
173
|
+
*/
|
|
174
|
+
private async drain(): Promise<void> {
|
|
175
|
+
this.draining = true;
|
|
176
|
+
|
|
177
|
+
// Ensure worker is initialised (no-op after first call)
|
|
178
|
+
await this.initWorker();
|
|
179
|
+
|
|
180
|
+
while (this.queue.length > 0) {
|
|
181
|
+
const batch = this.queue.splice(0, BATCH_SIZE);
|
|
182
|
+
|
|
183
|
+
if (this.workerAvailable && this.worker) {
|
|
184
|
+
// Dispatch to worker thread — results arrive via 'message' event
|
|
185
|
+
for (const item of batch) {
|
|
186
|
+
this.worker.postMessage({ id: item.observationId, text: item.text });
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
// Fallback: process inline via setImmediate to yield to event loop
|
|
190
|
+
for (const item of batch) {
|
|
191
|
+
setImmediate(() => {
|
|
192
|
+
this.fallbackEmbed(item).catch(() => {
|
|
193
|
+
// Silently skip — observation already persisted without embedding
|
|
194
|
+
this.callbacks.delete(item.observationId);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (this.queue.length > 0) {
|
|
201
|
+
// Yield to event loop between batches
|
|
202
|
+
await new Promise<void>((resolve) => setTimeout(resolve, DRAIN_INTERVAL_MS));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
this.draining = false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Inline fallback embedding — used when worker thread is unavailable.
|
|
211
|
+
* Runs directly on the main thread (but inside setImmediate to yield first).
|
|
212
|
+
*/
|
|
213
|
+
private async fallbackEmbed(item: QueueItem): Promise<void> {
|
|
214
|
+
const cb = this.callbacks.get(item.observationId);
|
|
215
|
+
if (!cb) return;
|
|
216
|
+
this.callbacks.delete(item.observationId);
|
|
217
|
+
|
|
218
|
+
const { getLocalEmbeddingProvider } = await import('./embedding-local.js');
|
|
219
|
+
const provider = getLocalEmbeddingProvider();
|
|
220
|
+
const vector = await provider.embed(item.text);
|
|
221
|
+
await cb(item.observationId, vector);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Flush the queue and terminate the worker thread.
|
|
226
|
+
*
|
|
227
|
+
* Waits for in-flight worker messages to drain, then terminates.
|
|
228
|
+
* Safe to call multiple times — subsequent calls return the same promise.
|
|
229
|
+
*/
|
|
230
|
+
shutdown(): Promise<void> {
|
|
231
|
+
if (this.shutdownPromise) return this.shutdownPromise;
|
|
232
|
+
this.shutdownPromise = this.doShutdown();
|
|
233
|
+
return this.shutdownPromise;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private async doShutdown(): Promise<void> {
|
|
237
|
+
// Drain remaining queue items
|
|
238
|
+
if (this.queue.length > 0) {
|
|
239
|
+
await this.drain();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Terminate the worker
|
|
243
|
+
if (this.worker) {
|
|
244
|
+
try {
|
|
245
|
+
await this.worker.terminate();
|
|
246
|
+
} catch {
|
|
247
|
+
// Worker may already be gone
|
|
248
|
+
}
|
|
249
|
+
this.worker = null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
this.callbacks.clear();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Module-level singleton. */
|
|
257
|
+
let _instance: EmbeddingQueue | null = null;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Get or create the shared EmbeddingQueue singleton.
|
|
261
|
+
*
|
|
262
|
+
* Registers a process `exit` handler on first call to flush and
|
|
263
|
+
* terminate the worker thread cleanly.
|
|
264
|
+
*
|
|
265
|
+
* @returns The shared EmbeddingQueue instance.
|
|
266
|
+
*/
|
|
267
|
+
export function getEmbeddingQueue(): EmbeddingQueue {
|
|
268
|
+
if (!_instance) {
|
|
269
|
+
_instance = new EmbeddingQueue();
|
|
270
|
+
// Best-effort flush on process exit (synchronous handlers only get ~50ms)
|
|
271
|
+
process.on('exit', () => {
|
|
272
|
+
_instance?.shutdown().catch(() => {
|
|
273
|
+
// exit handler — cannot await
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
// For SIGTERM/SIGINT give the queue time to flush
|
|
277
|
+
const gracefulShutdown = (): void => {
|
|
278
|
+
if (_instance) {
|
|
279
|
+
_instance
|
|
280
|
+
.shutdown()
|
|
281
|
+
.catch(() => {})
|
|
282
|
+
.finally(() => process.exit(0));
|
|
283
|
+
} else {
|
|
284
|
+
process.exit(0);
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
process.once('SIGTERM', gracefulShutdown);
|
|
288
|
+
process.once('SIGINT', gracefulShutdown);
|
|
289
|
+
}
|
|
290
|
+
return _instance;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Reset the singleton (for testing only).
|
|
295
|
+
* Shuts down the existing queue before clearing the reference.
|
|
296
|
+
*
|
|
297
|
+
* @internal
|
|
298
|
+
*/
|
|
299
|
+
export async function resetEmbeddingQueue(): Promise<void> {
|
|
300
|
+
if (_instance) {
|
|
301
|
+
await _instance.shutdown();
|
|
302
|
+
_instance = null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedding Worker Thread Script
|
|
3
|
+
*
|
|
4
|
+
* Runs inside a `worker_threads.Worker` to perform embedding generation
|
|
5
|
+
* off the main thread. Receives messages with text to embed, calls
|
|
6
|
+
* LocalEmbeddingProvider, and sends results back via parentPort.
|
|
7
|
+
*
|
|
8
|
+
* Message protocol:
|
|
9
|
+
* Inbound: { id: string; text: string }
|
|
10
|
+
* Outbound: { id: string; embedding: number[] } on success
|
|
11
|
+
* { id: string; error: string } on failure
|
|
12
|
+
*
|
|
13
|
+
* @epic T134
|
|
14
|
+
* @task T137
|
|
15
|
+
* @why Prevent @xenova/transformers model inference from blocking the main thread
|
|
16
|
+
* @what Worker thread script for async embedding generation
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { parentPort } from 'node:worker_threads';
|
|
20
|
+
|
|
21
|
+
/** Inbound message from the queue manager. */
|
|
22
|
+
interface WorkerRequest {
|
|
23
|
+
id: string;
|
|
24
|
+
text: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Successful embedding result sent to the queue manager. */
|
|
28
|
+
interface WorkerSuccess {
|
|
29
|
+
id: string;
|
|
30
|
+
embedding: number[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Error result sent to the queue manager. */
|
|
34
|
+
interface WorkerError {
|
|
35
|
+
id: string;
|
|
36
|
+
error: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!parentPort) {
|
|
40
|
+
throw new Error('embedding-worker.ts must be run as a worker thread');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const port = parentPort;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Handle a single embedding request from the queue manager.
|
|
47
|
+
* Imports LocalEmbeddingProvider lazily so the model loads once
|
|
48
|
+
* per worker lifetime.
|
|
49
|
+
*/
|
|
50
|
+
async function handleRequest(req: WorkerRequest): Promise<void> {
|
|
51
|
+
try {
|
|
52
|
+
const { getLocalEmbeddingProvider } = await import('./embedding-local.js');
|
|
53
|
+
const provider = getLocalEmbeddingProvider();
|
|
54
|
+
const vector = await provider.embed(req.text);
|
|
55
|
+
// Transfer as plain number[] — structured clone handles Float32Array
|
|
56
|
+
const response: WorkerSuccess = {
|
|
57
|
+
id: req.id,
|
|
58
|
+
embedding: Array.from(vector),
|
|
59
|
+
};
|
|
60
|
+
port.postMessage(response);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
const response: WorkerError = {
|
|
63
|
+
id: req.id,
|
|
64
|
+
error: err instanceof Error ? err.message : String(err),
|
|
65
|
+
};
|
|
66
|
+
port.postMessage(response);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
port.on('message', (req: WorkerRequest) => {
|
|
71
|
+
handleRequest(req).catch((err) => {
|
|
72
|
+
// Catch any unhandled rejection in handleRequest itself
|
|
73
|
+
const response: WorkerError = {
|
|
74
|
+
id: req.id,
|
|
75
|
+
error: err instanceof Error ? err.message : String(err),
|
|
76
|
+
};
|
|
77
|
+
port.postMessage(response);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -259,10 +259,25 @@ export async function writeMemoryBridge(
|
|
|
259
259
|
/**
|
|
260
260
|
* Best-effort refresh: call from session.end, tasks.complete, or memory.observe.
|
|
261
261
|
* Never throws.
|
|
262
|
+
*
|
|
263
|
+
* @param projectRoot - Absolute path to the project root.
|
|
264
|
+
* @param scope - Optional session scope for context-aware generation (T139).
|
|
265
|
+
* @param currentTaskId - Optional current task ID for scoped context (T139).
|
|
262
266
|
*/
|
|
263
|
-
export async function refreshMemoryBridge(
|
|
267
|
+
export async function refreshMemoryBridge(
|
|
268
|
+
projectRoot: string,
|
|
269
|
+
scope?: string,
|
|
270
|
+
currentTaskId?: string,
|
|
271
|
+
): Promise<void> {
|
|
264
272
|
try {
|
|
265
|
-
await
|
|
273
|
+
const { loadConfig } = await import('../config.js');
|
|
274
|
+
const config = await loadConfig(projectRoot);
|
|
275
|
+
|
|
276
|
+
if (config.brain?.memoryBridge?.contextAware && scope) {
|
|
277
|
+
await generateContextAwareContent(projectRoot, scope, currentTaskId);
|
|
278
|
+
} else {
|
|
279
|
+
await writeMemoryBridge(projectRoot);
|
|
280
|
+
}
|
|
266
281
|
} catch (err) {
|
|
267
282
|
console.error(
|
|
268
283
|
'[CLEO] Memory bridge refresh failed:',
|
|
@@ -271,6 +286,90 @@ export async function refreshMemoryBridge(projectRoot: string): Promise<void> {
|
|
|
271
286
|
}
|
|
272
287
|
}
|
|
273
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Generate context-aware memory bridge content and write to disk.
|
|
291
|
+
*
|
|
292
|
+
* When `brain.memoryBridge.contextAware` is true and a scope is available,
|
|
293
|
+
* uses hybridSearch() to surface memories relevant to the current scope,
|
|
294
|
+
* then enforces the `brain.memoryBridge.maxTokens` budget.
|
|
295
|
+
*
|
|
296
|
+
* Falls back to standard generation if hybrid search is unavailable.
|
|
297
|
+
* Never throws.
|
|
298
|
+
*
|
|
299
|
+
* @param projectRoot - Absolute path to the project root.
|
|
300
|
+
* @param scope - Session scope string (e.g. 'global', 'epic:T###').
|
|
301
|
+
* @param currentTaskId - Optional current task ID for narrower scoping.
|
|
302
|
+
* @task T139 @epic T134
|
|
303
|
+
*/
|
|
304
|
+
export async function generateContextAwareContent(
|
|
305
|
+
projectRoot: string,
|
|
306
|
+
scope: string,
|
|
307
|
+
currentTaskId?: string,
|
|
308
|
+
): Promise<void> {
|
|
309
|
+
try {
|
|
310
|
+
const { loadConfig } = await import('../config.js');
|
|
311
|
+
const config = await loadConfig(projectRoot);
|
|
312
|
+
const maxTokens = config.brain?.memoryBridge?.maxTokens ?? 2000;
|
|
313
|
+
|
|
314
|
+
// Build a search query from scope + currentTaskId
|
|
315
|
+
const query = currentTaskId ? `${scope} ${currentTaskId}` : scope;
|
|
316
|
+
|
|
317
|
+
let contextSections: string[] = [];
|
|
318
|
+
try {
|
|
319
|
+
const { hybridSearch } = await import('./brain-search.js');
|
|
320
|
+
const hits = await hybridSearch(query, projectRoot, { limit: 10 });
|
|
321
|
+
if (hits && hits.length > 0) {
|
|
322
|
+
contextSections = hits
|
|
323
|
+
.slice(0, 5)
|
|
324
|
+
.map((h) => `- [${h.id}] ${h.title ?? h.text?.slice(0, 120) ?? ''}`);
|
|
325
|
+
}
|
|
326
|
+
} catch {
|
|
327
|
+
// Hybrid search unavailable — fall back to standard generation
|
|
328
|
+
await writeMemoryBridge(projectRoot);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Build content with token budget enforcement (rough estimate: 4 chars/token)
|
|
333
|
+
const charsPerToken = 4;
|
|
334
|
+
const budgetChars = maxTokens * charsPerToken;
|
|
335
|
+
|
|
336
|
+
const cleoDir = join(projectRoot, '.cleo');
|
|
337
|
+
const bridgePath = join(cleoDir, 'memory-bridge.md');
|
|
338
|
+
|
|
339
|
+
const headerLines = [
|
|
340
|
+
'# CLEO Memory Bridge',
|
|
341
|
+
'',
|
|
342
|
+
`> Auto-generated at ${new Date().toISOString().slice(0, 19)} (context-aware: ${scope})`,
|
|
343
|
+
'> Do not edit manually. Regenerate with `cleo refresh-memory`.',
|
|
344
|
+
'',
|
|
345
|
+
];
|
|
346
|
+
|
|
347
|
+
const contextBlock =
|
|
348
|
+
contextSections.length > 0 ? ['## Relevant Context', '', ...contextSections, ''] : [];
|
|
349
|
+
|
|
350
|
+
// Append standard content but enforce token budget
|
|
351
|
+
const standardContent = await generateMemoryBridgeContent(projectRoot);
|
|
352
|
+
const combined = [...headerLines, ...contextBlock].join('\n');
|
|
353
|
+
const remainingChars = budgetChars - combined.length;
|
|
354
|
+
|
|
355
|
+
const finalContent =
|
|
356
|
+
remainingChars > 200 ? combined + '\n' + standardContent.slice(0, remainingChars) : combined;
|
|
357
|
+
|
|
358
|
+
if (!existsSync(cleoDir)) {
|
|
359
|
+
mkdirSync(cleoDir, { recursive: true });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
writeFileSync(bridgePath, finalContent, 'utf-8');
|
|
363
|
+
} catch {
|
|
364
|
+
// Best-effort — fall through to standard generation
|
|
365
|
+
try {
|
|
366
|
+
await writeMemoryBridge(projectRoot);
|
|
367
|
+
} catch {
|
|
368
|
+
// Ignore
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
274
373
|
// ============================================================================
|
|
275
374
|
// Query helpers
|
|
276
375
|
// ============================================================================
|
|
@@ -223,6 +223,129 @@ export async function persistSessionMemory(
|
|
|
223
223
|
return result;
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
// ============================================================================
|
|
227
|
+
// buildSummarizationPrompt (T140)
|
|
228
|
+
// ============================================================================
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Build a summarization prompt from debrief data.
|
|
232
|
+
*
|
|
233
|
+
* Returns a formatted prompt string that guides an LLM to produce a structured
|
|
234
|
+
* session summary (key learnings, decisions, patterns, next actions). The result
|
|
235
|
+
* can be passed to an LLM or stored directly as a `memoryPrompt` in the session
|
|
236
|
+
* end result.
|
|
237
|
+
*
|
|
238
|
+
* Returns null when debrief contains no meaningful content to summarize.
|
|
239
|
+
*
|
|
240
|
+
* @param sessionId - The session ID to summarize.
|
|
241
|
+
* @param debrief - Rich debrief data from sessionComputeDebrief().
|
|
242
|
+
* @task T140 @epic T134
|
|
243
|
+
*/
|
|
244
|
+
export function buildSummarizationPrompt(
|
|
245
|
+
sessionId: string,
|
|
246
|
+
debrief: DebriefData | null | undefined,
|
|
247
|
+
): string | null {
|
|
248
|
+
if (!debrief) return null;
|
|
249
|
+
|
|
250
|
+
const tasksCompleted = debrief.handoff?.tasksCompleted ?? [];
|
|
251
|
+
const decisions = (debrief.decisions ?? []) as DebriefDecision[];
|
|
252
|
+
const note = debrief.handoff?.note ?? '';
|
|
253
|
+
const nextSuggested = debrief.handoff?.nextSuggested ?? [];
|
|
254
|
+
|
|
255
|
+
if (tasksCompleted.length === 0 && decisions.length === 0 && !note) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const parts: string[] = [
|
|
260
|
+
`Summarize session ${sessionId} for brain memory storage.`,
|
|
261
|
+
'',
|
|
262
|
+
`Tasks completed: ${tasksCompleted.join(', ') || 'none'}`,
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
if (decisions.length > 0) {
|
|
266
|
+
parts.push('');
|
|
267
|
+
parts.push('Decisions made:');
|
|
268
|
+
for (const d of decisions) {
|
|
269
|
+
parts.push(`- ${d.decision ?? 'unknown'} (rationale: ${d.rationale ?? 'N/A'})`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (note) {
|
|
274
|
+
parts.push('');
|
|
275
|
+
parts.push(`Session note: ${note}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (nextSuggested.length > 0) {
|
|
279
|
+
parts.push('');
|
|
280
|
+
parts.push(`Suggested next: ${nextSuggested.join(', ')}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
parts.push('');
|
|
284
|
+
parts.push(
|
|
285
|
+
'Produce a JSON object with keys: keyLearnings (string[]), decisions (string[]), patterns (string[]), nextActions (string[]).',
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
return parts.join('\n');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Ingest a structured session summary directly into brain.db.
|
|
293
|
+
*
|
|
294
|
+
* Stores each field as a typed brain observation. Best-effort — never throws.
|
|
295
|
+
*
|
|
296
|
+
* @param projectRoot - Absolute path to project root.
|
|
297
|
+
* @param sessionId - The session ID the summary belongs to.
|
|
298
|
+
* @param summary - Structured summary with key learnings, decisions, patterns, next actions.
|
|
299
|
+
* @task T140 @epic T134
|
|
300
|
+
*/
|
|
301
|
+
export async function ingestStructuredSummary(
|
|
302
|
+
projectRoot: string,
|
|
303
|
+
sessionId: string,
|
|
304
|
+
summary: import('@cleocode/contracts').SessionSummaryInput,
|
|
305
|
+
): Promise<void> {
|
|
306
|
+
try {
|
|
307
|
+
const { observeBrain } = await import('./brain-retrieval.js');
|
|
308
|
+
|
|
309
|
+
// Ingest key learnings
|
|
310
|
+
for (const learning of summary.keyLearnings) {
|
|
311
|
+
if (!learning.trim()) continue;
|
|
312
|
+
await observeBrain(projectRoot, {
|
|
313
|
+
text: learning,
|
|
314
|
+
title: learning.slice(0, 120),
|
|
315
|
+
type: 'discovery',
|
|
316
|
+
sourceSessionId: sessionId,
|
|
317
|
+
sourceType: 'agent',
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Ingest decisions
|
|
322
|
+
for (const decision of summary.decisions) {
|
|
323
|
+
if (!decision.trim()) continue;
|
|
324
|
+
await observeBrain(projectRoot, {
|
|
325
|
+
text: decision,
|
|
326
|
+
title: decision.slice(0, 120),
|
|
327
|
+
type: 'decision',
|
|
328
|
+
sourceSessionId: sessionId,
|
|
329
|
+
sourceType: 'agent',
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Ingest patterns
|
|
334
|
+
for (const pattern of summary.patterns) {
|
|
335
|
+
if (!pattern.trim()) continue;
|
|
336
|
+
await observeBrain(projectRoot, {
|
|
337
|
+
text: pattern,
|
|
338
|
+
title: pattern.slice(0, 120),
|
|
339
|
+
type: 'discovery',
|
|
340
|
+
sourceSessionId: sessionId,
|
|
341
|
+
sourceType: 'agent',
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
} catch {
|
|
345
|
+
// Best-effort: must never throw
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
226
349
|
// ============================================================================
|
|
227
350
|
// getSessionMemoryContext
|
|
228
351
|
// ============================================================================
|
package/src/sessions/index.ts
CHANGED
|
@@ -262,12 +262,8 @@ export async function endSession(
|
|
|
262
262
|
/* Memory bridge is best-effort */
|
|
263
263
|
});
|
|
264
264
|
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
.then(({ refreshMemoryBridge }) => refreshMemoryBridge(cwd ?? process.cwd()))
|
|
268
|
-
.catch(() => {
|
|
269
|
-
/* Memory bridge refresh is best-effort */
|
|
270
|
-
});
|
|
265
|
+
// NOTE: Memory bridge refresh is now handled by the onSessionEnd hook
|
|
266
|
+
// via memory-bridge-refresh.ts (T138). No direct call needed here.
|
|
271
267
|
|
|
272
268
|
// NOTE: Do NOT clear grade mode env vars here — gradeSession() needs them
|
|
273
269
|
// to query audit entries after the session ends. The caller (admin.grade handler
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* @task T5244
|
|
9
9
|
*/
|
|
10
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
10
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { tmpdir } from 'node:os';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { resetDbState } from '../sqlite.js';
|
|
@@ -26,8 +26,20 @@ export async function createTestDb() {
|
|
|
26
26
|
const tempDir = mkdtempSync(join(tmpdir(), 'cleo-test-'));
|
|
27
27
|
// Reset singleton to avoid cross-test contamination
|
|
28
28
|
resetDbState();
|
|
29
|
-
|
|
29
|
+
// Write test config that disables session enforcement and lifecycle enforcement
|
|
30
|
+
// so unit tests don't require active sessions or pipeline stage validation.
|
|
30
31
|
const cleoDir = join(tempDir, '.cleo');
|
|
32
|
+
mkdirSync(cleoDir, { recursive: true });
|
|
33
|
+
const configContent = JSON.stringify({
|
|
34
|
+
enforcement: {
|
|
35
|
+
session: { requiredForMutate: false },
|
|
36
|
+
acceptance: { mode: 'off' },
|
|
37
|
+
},
|
|
38
|
+
lifecycle: { mode: 'off' },
|
|
39
|
+
verification: { enabled: false },
|
|
40
|
+
});
|
|
41
|
+
writeFileSync(join(cleoDir, 'config.json'), configContent);
|
|
42
|
+
const accessor = await createSqliteDataAccessor(tempDir);
|
|
31
43
|
return {
|
|
32
44
|
tempDir,
|
|
33
45
|
cleoDir,
|
|
@@ -48,7 +48,10 @@ export async function createTestDb(): Promise<TestDbEnv> {
|
|
|
48
48
|
const cleoDir = join(tempDir, '.cleo');
|
|
49
49
|
mkdirSync(cleoDir, { recursive: true });
|
|
50
50
|
const configContent = JSON.stringify({
|
|
51
|
-
enforcement: {
|
|
51
|
+
enforcement: {
|
|
52
|
+
session: { requiredForMutate: false },
|
|
53
|
+
acceptance: { mode: 'off' },
|
|
54
|
+
},
|
|
52
55
|
lifecycle: { mode: 'off' },
|
|
53
56
|
verification: { enabled: false },
|
|
54
57
|
});
|