@soleri/core 9.4.0 → 9.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/hooks/candidate-scorer.d.ts +28 -0
  2. package/dist/hooks/candidate-scorer.d.ts.map +1 -0
  3. package/dist/hooks/candidate-scorer.js +20 -0
  4. package/dist/hooks/candidate-scorer.js.map +1 -0
  5. package/dist/hooks/index.d.ts +2 -0
  6. package/dist/hooks/index.d.ts.map +1 -0
  7. package/dist/hooks/index.js +2 -0
  8. package/dist/hooks/index.js.map +1 -0
  9. package/dist/planning/plan-lifecycle.d.ts.map +1 -1
  10. package/dist/planning/plan-lifecycle.js +6 -1
  11. package/dist/planning/plan-lifecycle.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/brain/brain.ts +120 -46
  14. package/src/brain/intelligence.ts +42 -34
  15. package/src/chat/agent-loop.ts +1 -1
  16. package/src/chat/notifications.ts +4 -0
  17. package/src/control/intent-router.ts +10 -8
  18. package/src/curator/curator.ts +145 -29
  19. package/src/hooks/candidate-scorer.test.ts +76 -0
  20. package/src/hooks/candidate-scorer.ts +39 -0
  21. package/src/hooks/index.ts +6 -0
  22. package/src/index.ts +2 -0
  23. package/src/llm/llm-client.ts +1 -0
  24. package/src/persistence/sqlite-provider.ts +1 -0
  25. package/src/planning/github-projection.ts +48 -44
  26. package/src/planning/plan-lifecycle.ts +14 -1
  27. package/src/queue/pipeline-runner.ts +4 -0
  28. package/src/runtime/curator-extra-ops.test.ts +7 -0
  29. package/src/runtime/curator-extra-ops.ts +10 -1
  30. package/src/runtime/facades/curator-facade.test.ts +7 -0
  31. package/src/runtime/facades/memory-facade.ts +187 -0
  32. package/src/runtime/orchestrate-ops.ts +3 -3
  33. package/src/runtime/runtime.test.ts +50 -2
  34. package/src/runtime/runtime.ts +117 -89
  35. package/src/runtime/shutdown-registry.test.ts +151 -0
  36. package/src/runtime/shutdown-registry.ts +85 -0
  37. package/src/runtime/types.ts +4 -1
  38. package/src/transport/http-server.ts +50 -3
  39. package/src/transport/ws-server.ts +8 -0
  40. package/src/vault/linking.test.ts +12 -0
  41. package/src/vault/linking.ts +90 -44
  42. package/src/vault/vault-maintenance.ts +11 -18
  43. package/src/vault/vault-memories.ts +21 -13
  44. package/src/vault/vault-schema.ts +21 -0
  45. package/src/vault/vault.ts +8 -3
@@ -10,9 +10,18 @@ import type { OpDefinition } from '../facades/types.js';
10
10
  import type { AgentRuntime } from './types.js';
11
11
 
12
12
  export function createCuratorExtraOps(runtime: AgentRuntime): OpDefinition[] {
13
- const { curator, jobQueue, pipelineRunner } = runtime;
13
+ const { curator, jobQueue, pipelineRunner, shutdownRegistry } = runtime;
14
14
  let consolidationInterval: ReturnType<typeof setInterval> | null = null;
15
15
 
16
+ // Register cleanup for any consolidation interval started during this session
17
+ shutdownRegistry.register('curatorConsolidation', () => {
18
+ if (consolidationInterval) {
19
+ clearInterval(consolidationInterval);
20
+ consolidationInterval = null;
21
+ }
22
+ pipelineRunner.stop();
23
+ });
24
+
16
25
  return [
17
26
  // ─── Entry History ──────────────────────────────────────────────
18
27
  {
@@ -30,6 +30,13 @@ function mockRuntime(): AgentRuntime {
30
30
  start: vi.fn(),
31
31
  stop: vi.fn(),
32
32
  },
33
+ shutdownRegistry: {
34
+ register: vi.fn(),
35
+ closeAll: vi.fn(),
36
+ closeAllSync: vi.fn(),
37
+ size: 0,
38
+ isClosed: false,
39
+ },
33
40
  } as unknown as AgentRuntime;
34
41
  }
35
42
 
@@ -195,6 +195,193 @@ export function createMemoryFacadeOps(runtime: AgentRuntime): OpDefinition[] {
195
195
  },
196
196
  },
197
197
 
198
+ // ─── Handoff ────────────────────────────────────────────────
199
+ {
200
+ name: 'handoff_generate',
201
+ description:
202
+ 'Generate a structured handoff document for context transitions. ' +
203
+ 'Pulls from active plan (if any) and recent session memories to produce ' +
204
+ 'a markdown document that can bootstrap a new context window. ' +
205
+ 'Ephemeral — NOT persisted to vault.',
206
+ auth: 'read',
207
+ schema: z.object({
208
+ projectPath: z
209
+ .string()
210
+ .optional()
211
+ .default('.')
212
+ .describe('Project path for filtering memories'),
213
+ sessionLimit: z
214
+ .number()
215
+ .optional()
216
+ .default(3)
217
+ .describe('Number of recent session memories to include'),
218
+ }),
219
+ handler: async (params) => {
220
+ const { planner } = runtime;
221
+ const projectPath = params.projectPath as string;
222
+ const sessionLimit = (params.sessionLimit as number) ?? 3;
223
+
224
+ const sections: string[] = [];
225
+ const now = new Date().toISOString();
226
+
227
+ sections.push('# Handoff Document');
228
+ sections.push('');
229
+ sections.push(`Generated: ${now}`);
230
+ sections.push('');
231
+
232
+ // ─── Active Plan Context ───────────────────────────
233
+ const activePlans = planner.getActive();
234
+ if (activePlans.length > 0) {
235
+ const plan = activePlans[0]; // Most relevant active plan
236
+ sections.push('## Active Plan');
237
+ sections.push('');
238
+ sections.push(`| Field | Value |`);
239
+ sections.push(`|-------|-------|`);
240
+ sections.push(`| **Plan ID** | ${plan.id} |`);
241
+ sections.push(`| **Objective** | ${plan.objective} |`);
242
+ sections.push(`| **Status** | ${plan.status} |`);
243
+ sections.push(`| **Scope** | ${plan.scope} |`);
244
+ sections.push('');
245
+
246
+ // Decisions
247
+ if (plan.decisions.length > 0) {
248
+ sections.push('### Decisions');
249
+ sections.push('');
250
+ for (const d of plan.decisions) {
251
+ if (typeof d === 'string') {
252
+ sections.push(`- ${d}`);
253
+ } else {
254
+ sections.push(`- **${d.decision}** — ${d.rationale}`);
255
+ }
256
+ }
257
+ sections.push('');
258
+ }
259
+
260
+ // Task status summary
261
+ if (plan.tasks.length > 0) {
262
+ sections.push('### Tasks');
263
+ sections.push('');
264
+ sections.push('| # | Task | Status |');
265
+ sections.push('|---|------|--------|');
266
+ for (let i = 0; i < plan.tasks.length; i++) {
267
+ const t = plan.tasks[i];
268
+ sections.push(`| ${i + 1} | ${t.title} | ${t.status} |`);
269
+ }
270
+ sections.push('');
271
+ }
272
+
273
+ // Approach
274
+ if (plan.approach) {
275
+ sections.push('### Approach');
276
+ sections.push('');
277
+ sections.push(plan.approach);
278
+ sections.push('');
279
+ }
280
+
281
+ // Additional active plans (just IDs)
282
+ if (activePlans.length > 1) {
283
+ sections.push('### Other Active Plans');
284
+ sections.push('');
285
+ for (let i = 1; i < activePlans.length; i++) {
286
+ const p = activePlans[i];
287
+ sections.push(`- **${p.id}**: ${p.objective} (${p.status})`);
288
+ }
289
+ sections.push('');
290
+ }
291
+ } else {
292
+ sections.push('## Active Plan');
293
+ sections.push('');
294
+ sections.push('No active plans.');
295
+ sections.push('');
296
+ }
297
+
298
+ // ─── Recent Session Context ────────────────────────
299
+ const recentSessions = vault.listMemories({
300
+ type: 'session',
301
+ projectPath,
302
+ limit: sessionLimit,
303
+ });
304
+
305
+ if (recentSessions.length > 0) {
306
+ sections.push('## Recent Sessions');
307
+ sections.push('');
308
+ for (const session of recentSessions) {
309
+ sections.push(`### ${session.createdAt}`);
310
+ sections.push('');
311
+ if (session.summary) {
312
+ sections.push(session.summary);
313
+ sections.push('');
314
+ }
315
+ if (session.nextSteps && session.nextSteps.length > 0) {
316
+ sections.push('**Next steps:**');
317
+ for (const step of session.nextSteps) {
318
+ sections.push(`- ${step}`);
319
+ }
320
+ sections.push('');
321
+ }
322
+ if (session.decisions && session.decisions.length > 0) {
323
+ sections.push('**Decisions:**');
324
+ for (const d of session.decisions) {
325
+ sections.push(`- ${d}`);
326
+ }
327
+ sections.push('');
328
+ }
329
+ if (session.filesModified && session.filesModified.length > 0) {
330
+ sections.push(`**Files modified:** ${session.filesModified.join(', ')}`);
331
+ sections.push('');
332
+ }
333
+ }
334
+ } else {
335
+ sections.push('## Recent Sessions');
336
+ sections.push('');
337
+ sections.push('No recent session memories found.');
338
+ sections.push('');
339
+ }
340
+
341
+ // ─── Resumption Hints ──────────────────────────────
342
+ sections.push('## Resumption');
343
+ sections.push('');
344
+ sections.push('Use this document to restore context after a context window transition.');
345
+ sections.push('');
346
+ if (activePlans.length > 0) {
347
+ const plan = activePlans[0];
348
+ const pendingTasks = plan.tasks.filter(
349
+ (t) => t.status === 'pending' || t.status === 'in_progress',
350
+ );
351
+ if (pendingTasks.length > 0) {
352
+ sections.push('**Immediate next actions:**');
353
+ for (const t of pendingTasks.slice(0, 5)) {
354
+ sections.push(
355
+ `- ${t.status === 'in_progress' ? '[IN PROGRESS]' : '[PENDING]'} ${t.title}`,
356
+ );
357
+ }
358
+ sections.push('');
359
+ }
360
+ if (plan.status === 'executing') {
361
+ sections.push(
362
+ '> Plan is in `executing` state. Continue with pending tasks or call `op:plan_reconcile` if complete.',
363
+ );
364
+ } else if (plan.status === 'reconciling') {
365
+ sections.push(
366
+ '> Plan is in `reconciling` state. Call `op:plan_complete_lifecycle` to finalize.',
367
+ );
368
+ }
369
+ }
370
+
371
+ const markdown = sections.join('\n');
372
+
373
+ return {
374
+ handoff: markdown,
375
+ meta: {
376
+ activePlanCount: activePlans.length,
377
+ activePlanId: activePlans.length > 0 ? activePlans[0].id : null,
378
+ recentSessionCount: recentSessions.length,
379
+ generatedAt: now,
380
+ },
381
+ };
382
+ },
383
+ },
384
+
198
385
  // ─── Satellite ops ───────────────────────────────────────────
199
386
  ...createMemoryExtraOps(runtime),
200
387
  ...createMemoryCrossProjectOps(runtime),
@@ -847,7 +847,7 @@ export function createOrchestrateOps(
847
847
  }
848
848
 
849
849
  // 2. Detect GitHub context
850
- const ctx = detectGitHubContext(projectPath);
850
+ const ctx = await detectGitHubContext(projectPath);
851
851
  if (!ctx) {
852
852
  return {
853
853
  status: 'skipped',
@@ -885,7 +885,7 @@ export function createOrchestrateOps(
885
885
  };
886
886
  }
887
887
 
888
- const updated = updateGitHubIssueBody(ctx.repo, linkToIssue, body);
888
+ const updated = await updateGitHubIssueBody(ctx.repo, linkToIssue, body);
889
889
  if (!updated) {
890
890
  return {
891
891
  status: 'error',
@@ -949,7 +949,7 @@ export function createOrchestrateOps(
949
949
  continue;
950
950
  }
951
951
 
952
- const issueNumber = createGitHubIssue(ctx.repo, task.title, body, {
952
+ const issueNumber = await createGitHubIssue(ctx.repo, task.title, body, {
953
953
  milestone: milestoneNumber,
954
954
  labels: labels.length > 0 ? labels : undefined,
955
955
  });
@@ -81,7 +81,10 @@ vi.mock('../governance/governance.js', () => ({
81
81
  }));
82
82
 
83
83
  vi.mock('../loop/loop-manager.js', () => ({
84
- LoopManager: mockClass(),
84
+ LoopManager: vi.fn(function (this: Record<string, unknown>) {
85
+ this.isActive = vi.fn().mockReturnValue(false);
86
+ this.cancelLoop = vi.fn();
87
+ }),
85
88
  }));
86
89
 
87
90
  vi.mock('../control/identity-manager.js', () => ({
@@ -185,7 +188,9 @@ vi.mock('../context/context-engine.js', () => ({
185
188
  }));
186
189
 
187
190
  vi.mock('../agency/agency-manager.js', () => ({
188
- AgencyManager: mockClass(),
191
+ AgencyManager: vi.fn(function (this: Record<string, unknown>) {
192
+ this.disable = vi.fn();
193
+ }),
189
194
  }));
190
195
 
191
196
  vi.mock('../vault/knowledge-review.js', () => ({
@@ -220,6 +225,7 @@ vi.mock('../queue/job-queue.js', () => ({
220
225
  vi.mock('../queue/pipeline-runner.js', () => ({
221
226
  PipelineRunner: vi.fn(function (this: Record<string, unknown>) {
222
227
  this.registerHandler = vi.fn();
228
+ this.stop = vi.fn();
223
229
  }),
224
230
  }));
225
231
 
@@ -261,6 +267,18 @@ vi.mock('./context-health.js', () => ({
261
267
  }),
262
268
  }));
263
269
 
270
+ vi.mock('./shutdown-registry.js', () => ({
271
+ ShutdownRegistry: vi.fn(function (this: Record<string, unknown>) {
272
+ this.register = vi.fn();
273
+ this.closeAll = vi.fn().mockResolvedValue(undefined);
274
+ this.closeAllSync = vi.fn();
275
+ this.size = 0;
276
+ this.isClosed = false;
277
+ this.entries = [];
278
+ this.closed = false;
279
+ }),
280
+ }));
281
+
264
282
  vi.mock('node:fs', () => ({
265
283
  existsSync: vi.fn().mockReturnValue(false),
266
284
  mkdirSync: vi.fn(),
@@ -360,4 +378,34 @@ describe('createAgentRuntime', () => {
360
378
  it('initializes context health monitor', () => {
361
379
  expect(runtime.contextHealth).toBeDefined();
362
380
  });
381
+
382
+ it('initializes shutdown registry', () => {
383
+ expect(runtime.shutdownRegistry).toBeDefined();
384
+ expect(runtime.shutdownRegistry.register).toBeDefined();
385
+ });
386
+
387
+ it('registers cleanup callbacks with shutdown registry', () => {
388
+ // vaultManager, pipelineRunner, agencyManager, loopManager
389
+ expect(runtime.shutdownRegistry.register).toHaveBeenCalledWith(
390
+ 'vaultManager',
391
+ expect.any(Function),
392
+ );
393
+ expect(runtime.shutdownRegistry.register).toHaveBeenCalledWith(
394
+ 'pipelineRunner',
395
+ expect.any(Function),
396
+ );
397
+ expect(runtime.shutdownRegistry.register).toHaveBeenCalledWith(
398
+ 'agencyManager',
399
+ expect.any(Function),
400
+ );
401
+ expect(runtime.shutdownRegistry.register).toHaveBeenCalledWith(
402
+ 'loopManager',
403
+ expect.any(Function),
404
+ );
405
+ });
406
+
407
+ it('close() calls shutdownRegistry.closeAllSync()', () => {
408
+ runtime.close();
409
+ expect(runtime.shutdownRegistry.closeAllSync).toHaveBeenCalled();
410
+ });
363
411
  });
@@ -60,6 +60,7 @@ import { loadPersona } from '../persona/loader.js';
60
60
  import { generatePersonaInstructions } from '../persona/prompt-generator.js';
61
61
  import { OperatorProfileStore } from '../operator/operator-profile.js';
62
62
  import { ContextHealthMonitor } from './context-health.js';
63
+ import { ShutdownRegistry } from './shutdown-registry.js';
63
64
 
64
65
  /**
65
66
  * Create a fully initialized agent runtime.
@@ -221,6 +222,117 @@ export function createAgentRuntime(config: AgentRuntimeConfig): AgentRuntime {
221
222
  learningRadar.setOperatorProfile(operatorProfile);
222
223
  brainIntelligence.setOperatorProfile(operatorProfile);
223
224
 
225
+ // ─── Shutdown Registry ────────────────────────────────────────────
226
+ const shutdownRegistry = new ShutdownRegistry();
227
+
228
+ // Build pipeline runner before the runtime object so we can reference it
229
+ const pipelineRunner = (() => {
230
+ const jq = new JobQueue(vault.getProvider());
231
+ const pr = new PipelineRunner(jq);
232
+ // Register default job handlers for curator pipeline
233
+ pr.registerHandler('tag-normalize', async (job) => {
234
+ const entry = vault.get(job.entryId ?? '');
235
+ if (!entry) return { skipped: true, reason: 'entry not found' };
236
+ const result = curator.normalizeTag(entry.tags[0] ?? '');
237
+ return result;
238
+ });
239
+ pr.registerHandler('dedup-check', async (job) => {
240
+ const entry = vault.get(job.entryId ?? '');
241
+ if (!entry) return { skipped: true, reason: 'entry not found' };
242
+ return curator.detectDuplicates(entry.id);
243
+ });
244
+ pr.registerHandler('auto-link', async (job) => {
245
+ if (linkManager) {
246
+ const suggestions = linkManager.suggestLinks(job.entryId ?? '', 3);
247
+ for (const s of suggestions) {
248
+ linkManager.addLink(
249
+ job.entryId ?? '',
250
+ s.entryId,
251
+ s.suggestedType,
252
+ `pipeline: ${s.reason}`,
253
+ );
254
+ }
255
+ return { linked: suggestions.length };
256
+ }
257
+ return { skipped: true, reason: 'link manager not available' };
258
+ });
259
+ pr.registerHandler('quality-gate', async (job) => {
260
+ const entry = vault.get(job.entryId ?? '');
261
+ if (!entry) return { skipped: true, reason: 'entry not found' };
262
+ return evaluateQuality(entry, llmClient);
263
+ });
264
+ pr.registerHandler('classify', async (job) => {
265
+ const entry = vault.get(job.entryId ?? '');
266
+ if (!entry) return { skipped: true, reason: 'entry not found' };
267
+ return classifyEntry(entry, llmClient);
268
+ });
269
+
270
+ // ─── 9 additional handlers for full Salvador parity (#216) ────
271
+ pr.registerHandler('enrich-frontmatter', async (job) => {
272
+ const entry = vault.get(job.entryId ?? '');
273
+ if (!entry) return { skipped: true, reason: 'entry not found' };
274
+ return curator.enrichMetadata(entry.id);
275
+ });
276
+ pr.registerHandler('detect-staleness', async (job) => {
277
+ const entry = vault.get(job.entryId ?? '');
278
+ if (!entry) return { skipped: true, reason: 'entry not found' };
279
+ // Check if entry is older than 90 days (using validFrom or fallback to 0)
280
+ const entryTimestamp = (entry.validFrom ?? 0) * 1000 || Date.now();
281
+ const ageMs = Date.now() - entryTimestamp;
282
+ const staleDays = 90;
283
+ const isStale = ageMs > staleDays * 86400000;
284
+ return { stale: isStale, ageDays: Math.floor(ageMs / 86400000), entryId: entry.id };
285
+ });
286
+ pr.registerHandler('detect-duplicate', async (job) => {
287
+ const entry = vault.get(job.entryId ?? '');
288
+ if (!entry) return { skipped: true, reason: 'entry not found' };
289
+ return curator.detectDuplicates(entry.id);
290
+ });
291
+ pr.registerHandler('detect-contradiction', async (job) => {
292
+ const entry = vault.get(job.entryId ?? '');
293
+ if (!entry) return { skipped: true, reason: 'entry not found' };
294
+ const contradictions = curator.detectContradictions(0.4);
295
+ const relevant = contradictions.filter(
296
+ (c) => c.patternId === job.entryId || c.antipatternId === job.entryId,
297
+ );
298
+ return { found: relevant.length, contradictions: relevant };
299
+ });
300
+ pr.registerHandler('consolidate-duplicates', async (_job) => {
301
+ return curator.consolidate({ dryRun: false, staleDaysThreshold: 90 });
302
+ });
303
+ pr.registerHandler('archive-stale', async (_job) => {
304
+ // Run consolidation with stale detection
305
+ const result = curator.consolidate({ dryRun: false, staleDaysThreshold: 90 });
306
+ return { archived: result.staleEntries.length, result };
307
+ });
308
+ pr.registerHandler('verify-searchable', async (job) => {
309
+ const entry = vault.get(job.entryId ?? '');
310
+ if (!entry) return { skipped: true, reason: 'entry not found' };
311
+ const searchResults = vault.search(entry.title, { limit: 1 });
312
+ const found = searchResults.some((r) => r.entry.id === entry.id);
313
+ return { searchable: found, entryId: entry.id };
314
+ });
315
+ return pr;
316
+ })();
317
+
318
+ // ─── Register cleanup callbacks (LIFO: first registered = last closed) ──
319
+ // Vault manager closes last (other modules may flush to vault during close)
320
+ shutdownRegistry.register('vaultManager', () => vaultManager.close());
321
+ // Pipeline runner — clear its polling interval
322
+ shutdownRegistry.register('pipelineRunner', () => pipelineRunner.stop());
323
+ // Agency manager — close FSWatchers and debounce timers
324
+ shutdownRegistry.register('agencyManager', () => agencyManager.disable());
325
+ // Loop manager — clear accumulated state
326
+ shutdownRegistry.register('loopManager', () => {
327
+ if (loop.isActive()) {
328
+ try {
329
+ loop.cancelLoop();
330
+ } catch {
331
+ // Loop may already be inactive
332
+ }
333
+ }
334
+ });
335
+
224
336
  return {
225
337
  config,
226
338
  logger,
@@ -256,94 +368,7 @@ export function createAgentRuntime(config: AgentRuntimeConfig): AgentRuntime {
256
368
  knowledgeSynthesizer: new KnowledgeSynthesizer(brain, llmClient),
257
369
  chainRunner: new ChainRunner(vault.getProvider()),
258
370
  jobQueue: new JobQueue(vault.getProvider()),
259
- pipelineRunner: (() => {
260
- const jq = new JobQueue(vault.getProvider());
261
- const pr = new PipelineRunner(jq);
262
- // Register default job handlers for curator pipeline
263
- pr.registerHandler('tag-normalize', async (job) => {
264
- const entry = vault.get(job.entryId ?? '');
265
- if (!entry) return { skipped: true, reason: 'entry not found' };
266
- const result = curator.normalizeTag(entry.tags[0] ?? '');
267
- return result;
268
- });
269
- pr.registerHandler('dedup-check', async (job) => {
270
- const entry = vault.get(job.entryId ?? '');
271
- if (!entry) return { skipped: true, reason: 'entry not found' };
272
- return curator.detectDuplicates(entry.id);
273
- });
274
- pr.registerHandler('auto-link', async (job) => {
275
- if (linkManager) {
276
- const suggestions = linkManager.suggestLinks(job.entryId ?? '', 3);
277
- for (const s of suggestions) {
278
- linkManager.addLink(
279
- job.entryId ?? '',
280
- s.entryId,
281
- s.suggestedType,
282
- `pipeline: ${s.reason}`,
283
- );
284
- }
285
- return { linked: suggestions.length };
286
- }
287
- return { skipped: true, reason: 'link manager not available' };
288
- });
289
- pr.registerHandler('quality-gate', async (job) => {
290
- const entry = vault.get(job.entryId ?? '');
291
- if (!entry) return { skipped: true, reason: 'entry not found' };
292
- return evaluateQuality(entry, llmClient);
293
- });
294
- pr.registerHandler('classify', async (job) => {
295
- const entry = vault.get(job.entryId ?? '');
296
- if (!entry) return { skipped: true, reason: 'entry not found' };
297
- return classifyEntry(entry, llmClient);
298
- });
299
-
300
- // ─── 9 additional handlers for full Salvador parity (#216) ────
301
- pr.registerHandler('enrich-frontmatter', async (job) => {
302
- const entry = vault.get(job.entryId ?? '');
303
- if (!entry) return { skipped: true, reason: 'entry not found' };
304
- return curator.enrichMetadata(entry.id);
305
- });
306
- pr.registerHandler('detect-staleness', async (job) => {
307
- const entry = vault.get(job.entryId ?? '');
308
- if (!entry) return { skipped: true, reason: 'entry not found' };
309
- // Check if entry is older than 90 days (using validFrom or fallback to 0)
310
- const entryTimestamp = (entry.validFrom ?? 0) * 1000 || Date.now();
311
- const ageMs = Date.now() - entryTimestamp;
312
- const staleDays = 90;
313
- const isStale = ageMs > staleDays * 86400000;
314
- return { stale: isStale, ageDays: Math.floor(ageMs / 86400000), entryId: entry.id };
315
- });
316
- pr.registerHandler('detect-duplicate', async (job) => {
317
- const entry = vault.get(job.entryId ?? '');
318
- if (!entry) return { skipped: true, reason: 'entry not found' };
319
- return curator.detectDuplicates(entry.id);
320
- });
321
- pr.registerHandler('detect-contradiction', async (job) => {
322
- const entry = vault.get(job.entryId ?? '');
323
- if (!entry) return { skipped: true, reason: 'entry not found' };
324
- const contradictions = curator.detectContradictions(0.4);
325
- const relevant = contradictions.filter(
326
- (c) => c.patternId === job.entryId || c.antipatternId === job.entryId,
327
- );
328
- return { found: relevant.length, contradictions: relevant };
329
- });
330
- pr.registerHandler('consolidate-duplicates', async (_job) => {
331
- return curator.consolidate({ dryRun: false, staleDaysThreshold: 90 });
332
- });
333
- pr.registerHandler('archive-stale', async (_job) => {
334
- // Run consolidation with stale detection
335
- const result = curator.consolidate({ dryRun: false, staleDaysThreshold: 90 });
336
- return { archived: result.staleEntries.length, result };
337
- });
338
- pr.registerHandler('verify-searchable', async (job) => {
339
- const entry = vault.get(job.entryId ?? '');
340
- if (!entry) return { skipped: true, reason: 'entry not found' };
341
- const searchResults = vault.search(entry.title, { limit: 1 });
342
- const found = searchResults.some((r) => r.entry.id === entry.id);
343
- return { searchable: found, entryId: entry.id };
344
- });
345
- return pr;
346
- })(),
371
+ pipelineRunner,
347
372
  operatorProfile,
348
373
  persona: (() => {
349
374
  const p = loadPersona(agentId, config.persona ?? undefined);
@@ -355,9 +380,12 @@ export function createAgentRuntime(config: AgentRuntimeConfig): AgentRuntime {
355
380
  return generatePersonaInstructions(p);
356
381
  })(),
357
382
  contextHealth: new ContextHealthMonitor(),
383
+ shutdownRegistry,
358
384
  createdAt: Date.now(),
359
385
  close: () => {
360
- vaultManager.close();
386
+ // Synchronous close — runs all registered callbacks in LIFO order,
387
+ // then closes the vault (registered first, so runs last).
388
+ shutdownRegistry.closeAllSync();
361
389
  },
362
390
  };
363
391
  }