@cleocode/core 2026.3.69 → 2026.3.71

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.
Files changed (129) hide show
  1. package/dist/agents/retry.d.ts.map +1 -1
  2. package/dist/agents/retry.js +23 -42
  3. package/dist/agents/retry.js.map +1 -1
  4. package/dist/cleo.d.ts +2 -300
  5. package/dist/cleo.d.ts.map +1 -1
  6. package/dist/cleo.js +2 -2
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +30 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/hooks/handlers/file-hooks.d.ts +5 -2
  11. package/dist/hooks/handlers/file-hooks.d.ts.map +1 -1
  12. package/dist/hooks/handlers/index.d.ts +2 -0
  13. package/dist/hooks/handlers/index.d.ts.map +1 -1
  14. package/dist/hooks/handlers/mcp-hooks.d.ts +11 -7
  15. package/dist/hooks/handlers/mcp-hooks.d.ts.map +1 -1
  16. package/dist/hooks/handlers/memory-bridge-refresh.d.ts +20 -0
  17. package/dist/hooks/handlers/memory-bridge-refresh.d.ts.map +1 -0
  18. package/dist/hooks/handlers/memory-bridge-refresh.js +42 -0
  19. package/dist/hooks/handlers/memory-bridge-refresh.js.map +1 -0
  20. package/dist/hooks/handlers/session-hooks.d.ts +10 -0
  21. package/dist/hooks/handlers/session-hooks.d.ts.map +1 -1
  22. package/dist/hooks/handlers/session-hooks.js +36 -0
  23. package/dist/hooks/handlers/session-hooks.js.map +1 -1
  24. package/dist/hooks/handlers/task-hooks.d.ts +4 -0
  25. package/dist/hooks/handlers/task-hooks.d.ts.map +1 -1
  26. package/dist/hooks/handlers/task-hooks.js +7 -0
  27. package/dist/hooks/handlers/task-hooks.js.map +1 -1
  28. package/dist/hooks/handlers/work-capture-hooks.d.ts +40 -0
  29. package/dist/hooks/handlers/work-capture-hooks.d.ts.map +1 -0
  30. package/dist/index.d.ts +2 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +5069 -4678
  33. package/dist/index.js.map +4 -4
  34. package/dist/internal.d.ts +10 -2
  35. package/dist/internal.d.ts.map +1 -1
  36. package/dist/internal.js +10 -3
  37. package/dist/internal.js.map +1 -1
  38. package/dist/memory/auto-extract.d.ts +13 -0
  39. package/dist/memory/auto-extract.d.ts.map +1 -1
  40. package/dist/memory/auto-extract.js +34 -0
  41. package/dist/memory/auto-extract.js.map +1 -1
  42. package/dist/memory/brain-embedding.d.ts +13 -0
  43. package/dist/memory/brain-embedding.d.ts.map +1 -1
  44. package/dist/memory/brain-embedding.js +17 -0
  45. package/dist/memory/brain-embedding.js.map +1 -1
  46. package/dist/memory/brain-maintenance.d.ts +110 -0
  47. package/dist/memory/brain-maintenance.d.ts.map +1 -0
  48. package/dist/memory/brain-maintenance.js +98 -0
  49. package/dist/memory/brain-maintenance.js.map +1 -0
  50. package/dist/memory/brain-retrieval.d.ts +31 -5
  51. package/dist/memory/brain-retrieval.d.ts.map +1 -1
  52. package/dist/memory/brain-retrieval.js +53 -6
  53. package/dist/memory/brain-retrieval.js.map +1 -1
  54. package/dist/memory/embedding-local.d.ts +55 -0
  55. package/dist/memory/embedding-local.d.ts.map +1 -0
  56. package/dist/memory/embedding-local.js +97 -0
  57. package/dist/memory/embedding-local.js.map +1 -0
  58. package/dist/memory/embedding-queue.d.ts +90 -0
  59. package/dist/memory/embedding-queue.d.ts.map +1 -0
  60. package/dist/memory/embedding-queue.js +271 -0
  61. package/dist/memory/embedding-queue.js.map +1 -0
  62. package/dist/memory/embedding-worker.d.ts +19 -0
  63. package/dist/memory/embedding-worker.d.ts.map +1 -0
  64. package/dist/memory/embedding-worker.js +58 -0
  65. package/dist/memory/embedding-worker.js.map +1 -0
  66. package/dist/memory/memory-bridge.d.ts +21 -1
  67. package/dist/memory/memory-bridge.d.ts.map +1 -1
  68. package/dist/memory/memory-bridge.js +83 -2
  69. package/dist/memory/memory-bridge.js.map +1 -1
  70. package/dist/memory/session-memory.d.ts +26 -0
  71. package/dist/memory/session-memory.d.ts.map +1 -1
  72. package/dist/memory/session-memory.js +105 -0
  73. package/dist/memory/session-memory.js.map +1 -1
  74. package/dist/pagination.js +3 -0
  75. package/dist/pagination.js.map +1 -1
  76. package/dist/sessions/index.d.ts.map +1 -1
  77. package/dist/sessions/index.js +2 -6
  78. package/dist/sessions/index.js.map +1 -1
  79. package/dist/store/brain-sqlite.js +13 -62
  80. package/dist/store/brain-sqlite.js.map +1 -1
  81. package/dist/store/migration-manager.js +151 -0
  82. package/dist/store/migration-manager.js.map +1 -0
  83. package/dist/store/sqlite.d.ts.map +1 -1
  84. package/dist/store/sqlite.js +16 -134
  85. package/dist/store/sqlite.js.map +1 -1
  86. package/dist/tasks/add.js +27 -22
  87. package/dist/tasks/add.js.map +1 -1
  88. package/dist/tasks/complete.d.ts.map +1 -1
  89. package/dist/tasks/complete.js +13 -40
  90. package/dist/tasks/complete.js.map +1 -1
  91. package/dist/tasks/enforcement.js +12 -15
  92. package/dist/tasks/enforcement.js.map +1 -1
  93. package/dist/upgrade.js +246 -3
  94. package/dist/upgrade.js.map +1 -1
  95. package/migrations/drizzle-tasks/20260320013731_wave0-schema-hardening/migration.sql +17 -17
  96. package/package.json +6 -5
  97. package/src/agents/retry.ts +30 -24
  98. package/src/cleo.ts +30 -251
  99. package/src/config.ts +18 -0
  100. package/src/hooks/handlers/file-hooks.ts +29 -3
  101. package/src/hooks/handlers/index.ts +2 -0
  102. package/src/hooks/handlers/mcp-hooks.ts +32 -13
  103. package/src/hooks/handlers/memory-bridge-refresh.ts +47 -0
  104. package/src/hooks/handlers/session-hooks.ts +38 -0
  105. package/src/hooks/handlers/task-hooks.ts +8 -0
  106. package/src/hooks/handlers/work-capture-hooks.ts +184 -0
  107. package/src/index.ts +5 -0
  108. package/src/internal.ts +28 -2
  109. package/src/memory/__tests__/brain-automation.test.ts +941 -0
  110. package/src/memory/auto-extract.ts +40 -0
  111. package/src/memory/brain-embedding.ts +18 -0
  112. package/src/memory/brain-maintenance.ts +183 -0
  113. package/src/memory/brain-retrieval.ts +85 -7
  114. package/src/memory/embedding-local.ts +107 -0
  115. package/src/memory/embedding-queue.ts +304 -0
  116. package/src/memory/embedding-worker.ts +79 -0
  117. package/src/memory/memory-bridge.ts +101 -2
  118. package/src/memory/session-memory.ts +123 -0
  119. package/src/sessions/index.ts +2 -6
  120. package/src/store/__tests__/test-db-helper.js +14 -2
  121. package/src/store/__tests__/test-db-helper.ts +4 -1
  122. package/src/store/sqlite.ts +28 -0
  123. package/src/tasks/__tests__/complete-unblocks.test.ts +4 -1
  124. package/src/tasks/__tests__/complete.test.ts +18 -6
  125. package/src/tasks/__tests__/epic-enforcement.test.ts +4 -1
  126. package/src/tasks/__tests__/update.test.ts +4 -1
  127. package/src/tasks/complete.ts +8 -8
  128. package/templates/config.template.json +19 -0
  129. 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(projectRoot: string): Promise<void> {
267
+ export async function refreshMemoryBridge(
268
+ projectRoot: string,
269
+ scope?: string,
270
+ currentTaskId?: string,
271
+ ): Promise<void> {
264
272
  try {
265
- await writeMemoryBridge(projectRoot);
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
  // ============================================================================
@@ -262,12 +262,8 @@ export async function endSession(
262
262
  /* Memory bridge is best-effort */
263
263
  });
264
264
 
265
- // Regenerate .cleo/memory-bridge.md (best-effort, T5240)
266
- import('../memory/memory-bridge.js')
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
- const accessor = await createSqliteDataAccessor(tempDir);
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: { session: { requiredForMutate: false } },
51
+ enforcement: {
52
+ session: { requiredForMutate: false },
53
+ acceptance: { mode: 'off' },
54
+ },
52
55
  lifecycle: { mode: 'off' },
53
56
  verification: { enabled: false },
54
57
  });