@animalabs/connectome-host 0.7.2 → 0.7.4

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 (63) hide show
  1. package/CHANGELOG.md +203 -10
  2. package/HEADLESS-FLEET-PLAN.md +22 -0
  3. package/README.md +22 -11
  4. package/docs/AGENT-ONBOARDING.md +20 -1
  5. package/docs/debug-context-api.md +2 -2
  6. package/docs/retrieval-traces.md +173 -0
  7. package/docs/webui-deployment.md +2 -1
  8. package/package.json +3 -3
  9. package/scripts/audit-module-optins.ts +288 -0
  10. package/scripts/warmup-session.ts +17 -3
  11. package/src/codex-subscription-adapter.ts +13 -1
  12. package/src/framework-agent-config.ts +59 -4
  13. package/src/framework-strategy.ts +33 -3
  14. package/src/headless.ts +14 -0
  15. package/src/index.ts +95 -35
  16. package/src/logging-adapter.ts +13 -2
  17. package/src/mcpl-config.ts +8 -0
  18. package/src/modules/fleet-module.ts +60 -1
  19. package/src/modules/fleet-types.ts +30 -1
  20. package/src/modules/identity-module.ts +274 -0
  21. package/src/modules/mcpl-admin-module.ts +78 -5
  22. package/src/modules/observers-module.ts +12 -0
  23. package/src/modules/retrieval-module.ts +254 -52
  24. package/src/modules/retrieval-trace-page.ts +254 -0
  25. package/src/modules/retrieval-trace.ts +904 -0
  26. package/src/modules/settings-module.ts +28 -2
  27. package/src/modules/subscription-gc-module.ts +54 -1
  28. package/src/modules/tts-relay-module.ts +33 -18
  29. package/src/modules/web-ui-module.ts +445 -894
  30. package/src/recipe.ts +137 -12
  31. package/src/retrieval-config.ts +39 -0
  32. package/src/strategies/frontdesk-strategy.ts +34 -125
  33. package/src/tui.ts +325 -54
  34. package/src/web/panel-data.ts +1187 -0
  35. package/src/web/protocol.ts +75 -10
  36. package/test/audit-module-optins.test.ts +167 -0
  37. package/test/bedrock-prompt-caching.test.ts +170 -0
  38. package/test/fleet-panel-request.test.ts +90 -0
  39. package/test/framework-strategy-defaults.test.ts +110 -0
  40. package/test/frontdesk-strategy.test.ts +25 -37
  41. package/test/headless-panel-request.test.ts +201 -0
  42. package/test/identity-and-surfaces.test.ts +157 -0
  43. package/test/mcpl-admin-module.test.ts +23 -0
  44. package/test/mock-headless-child.ts +14 -0
  45. package/test/retrieval-auth-loopback.test.ts +49 -0
  46. package/test/retrieval-config.test.ts +74 -0
  47. package/test/retrieval-module.test.ts +821 -0
  48. package/test/subscription-gc-module.test.ts +152 -0
  49. package/test/tui-format.test.ts +106 -0
  50. package/test/web-ui-context-coverage.test.ts +1 -1
  51. package/test/web-ui-module.test.ts +189 -3
  52. package/test/web-ui-observers.test.ts +8 -5
  53. package/test/web-ui-protocol.test.ts +0 -0
  54. package/web/bun.lock +345 -0
  55. package/web/src/App.tsx +159 -44
  56. package/web/src/Context.tsx +35 -8
  57. package/web/src/ContextDocument.tsx +20 -5
  58. package/web/src/Files.tsx +2 -8
  59. package/web/src/Lessons.tsx +2 -38
  60. package/web/src/Mcpl.tsx +80 -14
  61. package/web/src/Pins.tsx +5 -0
  62. package/web/src/Settings.tsx +5 -0
  63. package/web/vite.config.ts +8 -2
@@ -2,12 +2,12 @@
2
2
  * RetrievalModule — LLM-as-retriever for semantic memory.
3
3
  *
4
4
  * Three-step retrieval pipeline running in gatherContext():
5
- * 1. Flag concepts (Haiku): identify concepts being discussed that might
6
- * benefit from background knowledge
5
+ * 1. Flag concepts: identify concepts being discussed that might benefit
6
+ * from background knowledge
7
7
  * 2. Mechanical query: keyword-match against LessonsModule
8
- * 3. Validate relevance (Haiku): filter to actually relevant lessons
8
+ * 3. Validate relevance: filter to actually relevant lessons
9
9
  *
10
- * Steps 1 and 3 use cheap Haiku calls (~$0.001 each).
10
+ * Steps 1 and 3 use the configured retrieval model and optional reasoning.
11
11
  * Results are cached to avoid redundant calls on unchanged context.
12
12
  */
13
13
 
@@ -24,16 +24,35 @@ import type {
24
24
  import type { Membrane, NormalizedRequest } from '@animalabs/membrane';
25
25
  import type { ContextInjection } from '@animalabs/context-manager';
26
26
  import type { LessonsModule, Lesson } from './lessons-module.js';
27
+ import {
28
+ RetrievalTraceStore,
29
+ type RetrievalTraceListOptions,
30
+ type RetrievalTraceRun,
31
+ } from './retrieval-trace.js';
27
32
 
28
33
  // ---------------------------------------------------------------------------
29
34
  // Configuration
30
35
  // ---------------------------------------------------------------------------
31
36
 
37
+ export type RetrievalReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
38
+
39
+ export interface RetrievalReasoningConfig {
40
+ effort: RetrievalReasoningEffort;
41
+ }
42
+
43
+ interface RecentRetrievalContext {
44
+ text: string;
45
+ messageCount: number;
46
+ messageIds: string[];
47
+ }
48
+
32
49
  export interface RetrievalModuleConfig {
33
- /** Membrane instance for Haiku calls */
50
+ /** Membrane instance for retrieval calls */
34
51
  membrane: Membrane;
35
52
  /** Model to use for retrieval calls (default: claude-haiku-4-5-20251001) */
36
53
  retrievalModel?: string;
54
+ /** Optional OpenAI reasoning effort, applied to both retrieval LLM stages. */
55
+ retrievalReasoning?: RetrievalReasoningConfig;
37
56
  /** Max lessons to inject (default: 5) */
38
57
  maxInjectedLessons?: number;
39
58
  /** Minimum lesson confidence for injection (default: 0.3) */
@@ -67,6 +86,10 @@ export class RetrievalModule implements Module {
67
86
  private config: RetrievalModuleConfig;
68
87
  private lastContextHash = '';
69
88
  private cachedInjections: ContextInjection[] = [];
89
+ private cachedLessonIds: string[] = [];
90
+ private cachedLessons: Lesson[] = [];
91
+ private cachedSourceTraceId: number | undefined;
92
+ private readonly traceStore = new RetrievalTraceStore();
70
93
 
71
94
  constructor(config: RetrievalModuleConfig) {
72
95
  this.config = config;
@@ -93,50 +116,115 @@ export class RetrievalModule implements Module {
93
116
  return {};
94
117
  }
95
118
 
119
+ /** Recent retrieval runs, newest first. Exact conversation inputs are opt-in. */
120
+ getRetrievalTraces(options?: RetrievalTraceListOptions) {
121
+ return this.traceStore.list(options);
122
+ }
123
+
124
+ private beginTrace(agentName: string): RetrievalTraceRun | undefined {
125
+ try {
126
+ const providerParams = this.retrievalProviderParams();
127
+ return this.traceStore.begin({
128
+ agentName,
129
+ model: this.config.retrievalModel ?? 'claude-haiku-4-5-20251001',
130
+ ...(this.config.retrievalReasoning
131
+ ? { requestedReasoning: this.config.retrievalReasoning }
132
+ : {}),
133
+ ...(providerParams ? { providerParams } : {}),
134
+ minConfidence: this.config.minConfidence ?? 0.3,
135
+ maxCandidates: 20,
136
+ maxInjectedLessons: this.config.maxInjectedLessons ?? 5,
137
+ });
138
+ } catch {
139
+ return undefined;
140
+ }
141
+ }
142
+
96
143
  /**
97
144
  * Run the 3-step retrieval pipeline before each inference.
98
145
  */
99
- async gatherContext(_agentName: string): Promise<ContextInjection[]> {
100
- if (!this.ctx) return [];
101
-
102
- // Get the LessonsModule to query
103
- const lessonsModule = this.ctx.getModule<LessonsModule>('lessons');
104
- if (!lessonsModule) return [];
105
-
106
- const lessons = lessonsModule.getLessons().filter(
107
- l => !l.deprecated && l.confidence >= (this.config.minConfidence ?? 0.3)
108
- );
109
- if (lessons.length === 0) return [];
110
-
111
- // Get recent context for concept extraction
112
- const recentMessages = this.getRecentContext();
113
- if (!recentMessages) return [];
114
-
115
- // Check cache: if context hasn't changed, reuse cached results
116
- const contextHash = this.hashContext(recentMessages);
117
- if (contextHash === this.lastContextHash && this.cachedInjections.length > 0) {
118
- return this.cachedInjections;
146
+ async gatherContext(agentName: string): Promise<ContextInjection[]> {
147
+ const trace = this.beginTrace(agentName);
148
+ if (!this.ctx) {
149
+ trace?.finish('not-started');
150
+ return [];
151
+ }
152
+
153
+ let lessons: Lesson[];
154
+ let recentMessages: string;
155
+ let contextHash: string;
156
+ try {
157
+ // These lookups, eligibility checks, context rendering, and hashing are
158
+ // pre-existing throwing paths. Record their failure, then preserve the
159
+ // upstream rejection rather than applying the provider-stage fail-open.
160
+ const lessonsModule = this.ctx.getModule<LessonsModule>('lessons');
161
+ if (!lessonsModule) {
162
+ trace?.finish('no-lessons-module');
163
+ return [];
164
+ }
165
+
166
+ lessons = lessonsModule.getLessons().filter(
167
+ l => !l.deprecated && l.confidence >= (this.config.minConfidence ?? 0.3)
168
+ );
169
+ if (lessons.length === 0) {
170
+ trace?.finish('no-eligible-lessons');
171
+ return [];
172
+ }
173
+
174
+ const recent = this.getRecentContext();
175
+ if (!recent) {
176
+ trace?.finish('no-recent-context');
177
+ return [];
178
+ }
179
+ recentMessages = recent.text;
180
+
181
+ // Check cache: if context hasn't changed, reuse cached results.
182
+ contextHash = this.hashContext(recentMessages);
183
+ trace?.setContext(contextHash, recentMessages, recent.messageCount, recent.messageIds);
184
+ if (contextHash === this.lastContextHash && this.cachedInjections.length > 0) {
185
+ trace?.recordCacheHit(
186
+ this.cachedSourceTraceId,
187
+ this.cachedLessonIds,
188
+ this.cachedLessons,
189
+ this.cachedInjections,
190
+ );
191
+ trace?.finish('cache-hit');
192
+ return this.cachedInjections;
193
+ }
194
+ } catch (error) {
195
+ trace?.finish('error', error);
196
+ throw error;
119
197
  }
120
198
 
121
199
  try {
122
- // Step 1: Flag concepts (Haiku call)
123
- const concepts = await this.flagConcepts(recentMessages);
200
+ // Step 1: Flag concepts
201
+ const concepts = await this.flagConcepts(recentMessages, trace);
124
202
  if (concepts.length === 0) {
125
203
  this.lastContextHash = contextHash;
126
204
  this.cachedInjections = [];
205
+ this.cachedLessonIds = [];
206
+ this.cachedLessons = [];
207
+ this.cachedSourceTraceId = undefined;
208
+ trace?.finish('no-concepts');
127
209
  return [];
128
210
  }
129
211
 
130
212
  // Step 2: Mechanical query (keyword matching)
131
213
  const candidates = this.queryCandidates(concepts, lessons);
214
+ trace?.recordCandidates(concepts, candidates);
132
215
  if (candidates.length === 0) {
133
216
  this.lastContextHash = contextHash;
134
217
  this.cachedInjections = [];
218
+ this.cachedLessonIds = [];
219
+ this.cachedLessons = [];
220
+ this.cachedSourceTraceId = undefined;
221
+ trace?.finish('no-candidates');
135
222
  return [];
136
223
  }
137
224
 
138
- // Step 3: Validate relevance (Haiku call)
139
- const relevant = await this.validateRelevance(recentMessages, candidates);
225
+ // Step 3: Validate relevance
226
+ const relevant = await this.validateRelevance(recentMessages, candidates, trace);
227
+ trace?.recordRelevant(relevant);
140
228
 
141
229
  // Build injection
142
230
  const maxLessons = this.config.maxInjectedLessons ?? 5;
@@ -145,6 +233,10 @@ export class RetrievalModule implements Module {
145
233
  if (injected.length === 0) {
146
234
  this.lastContextHash = contextHash;
147
235
  this.cachedInjections = [];
236
+ this.cachedLessonIds = [];
237
+ this.cachedLessons = [];
238
+ this.cachedSourceTraceId = undefined;
239
+ trace?.finish('no-relevant-lessons');
148
240
  return [];
149
241
  }
150
242
 
@@ -154,15 +246,25 @@ export class RetrievalModule implements Module {
154
246
 
155
247
  const injections: ContextInjection[] = [{
156
248
  namespace: 'retrieval',
157
- position: 'system',
249
+ // 'afterUser', NOT 'system': retrieval content changes with recent
250
+ // context (contextHash above), so injecting it into the system prompt
251
+ // churns the very front of the KV cache and invalidates the entire
252
+ // prefix every turn. Tail injection keeps the stable prefix cached.
253
+ position: 'afterUser',
158
254
  content: [{ type: 'text', text: `## Retrieved Knowledge\n${text}` }],
159
255
  }];
160
256
 
161
257
  this.lastContextHash = contextHash;
162
258
  this.cachedInjections = injections;
259
+ this.cachedLessons = this.safeLessonSnapshots(injected);
260
+ this.cachedLessonIds = this.safeLessonIds(this.cachedLessons);
261
+ this.cachedSourceTraceId = trace?.id;
262
+ trace?.recordInjection(injected, injections);
263
+ trace?.finish('injected');
163
264
  return injections;
164
265
  } catch (err) {
165
266
  // Fail open — don't block inference if retrieval fails
267
+ trace?.finish('error', err);
166
268
  console.error('RetrievalModule: retrieval failed:', err);
167
269
  return [];
168
270
  }
@@ -172,24 +274,44 @@ export class RetrievalModule implements Module {
172
274
  // Pipeline Steps
173
275
  // =========================================================================
174
276
 
277
+ /** Build OpenAI request parameters for configured retrieval reasoning effort. */
278
+ private retrievalProviderParams(): Record<string, unknown> | undefined {
279
+ const reasoning = this.config.retrievalReasoning;
280
+ if (!reasoning) return undefined;
281
+ return { reasoning: { effort: reasoning.effort } };
282
+ }
283
+
175
284
  /**
176
- * Step 1: Use Haiku to identify concepts that might benefit from background knowledge.
285
+ * Step 1: Use the configured retrieval model to identify concepts that might benefit from background knowledge.
177
286
  */
178
- private async flagConcepts(recentContext: string): Promise<string[]> {
287
+ private async flagConcepts(
288
+ recentContext: string,
289
+ trace?: RetrievalTraceRun,
290
+ ): Promise<string[]> {
179
291
  const model = this.config.retrievalModel ?? 'claude-haiku-4-5-20251001';
292
+ const providerParams = this.retrievalProviderParams();
293
+ const input = `Recent conversation:\n${recentContext}`;
180
294
 
181
295
  const request: NormalizedRequest = {
182
296
  messages: [
183
297
  {
184
298
  participant: 'user',
185
- content: [{ type: 'text', text: `Recent conversation:\n${recentContext}` }],
299
+ content: [{ type: 'text', text: input }],
186
300
  },
187
301
  ],
188
302
  system: CONCEPT_FLAG_PROMPT,
189
303
  config: { model, maxTokens: 500, temperature: 0 },
304
+ ...(providerParams ? { providerParams } : {}),
190
305
  };
191
306
 
192
- const response = await this.config.membrane.complete(request);
307
+ trace?.startConceptExtraction(CONCEPT_FLAG_PROMPT, input);
308
+ let response;
309
+ try {
310
+ response = await this.config.membrane.complete(request);
311
+ } catch (error) {
312
+ trace?.recordStageError('conceptExtraction', error);
313
+ throw error;
314
+ }
193
315
  const text = response.content
194
316
  .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
195
317
  .map(b => b.text)
@@ -198,16 +320,28 @@ export class RetrievalModule implements Module {
198
320
  try {
199
321
  const parsed = JSON.parse(text);
200
322
  if (Array.isArray(parsed)) {
201
- return parsed.filter((s): s is string => typeof s === 'string');
323
+ const concepts = parsed.filter((value): value is string => typeof value === 'string');
324
+ trace?.finishConceptExtraction(text, concepts, 'json', response.content);
325
+ return concepts;
202
326
  }
327
+ trace?.finishConceptExtraction(text, [], 'invalid', response.content);
203
328
  } catch {
204
- // Try to extract from markdown code block
329
+ // Try to extract an array from a prose/markdown wrapper.
205
330
  const match = text.match(/\[([^\]]*)\]/);
206
331
  if (match) {
207
332
  try {
208
- return JSON.parse(`[${match[1]}]`);
333
+ const parsed = JSON.parse(`[${match[1]}]`);
334
+ if (Array.isArray(parsed)) {
335
+ const traceValues = parsed.filter((value): value is string => typeof value === 'string');
336
+ trace?.finishConceptExtraction(text, traceValues, 'array-extraction', response.content);
337
+ // Preserve the historical malformed-wrapper behavior exactly: mixed
338
+ // arrays proceed to queryCandidates(), which then fails open rather
339
+ // than silently becoming a different, partially valid concept set.
340
+ return parsed as string[];
341
+ }
209
342
  } catch { /* fall through */ }
210
343
  }
344
+ trace?.finishConceptExtraction(text, [], 'invalid', response.content);
211
345
  }
212
346
  return [];
213
347
  }
@@ -246,32 +380,47 @@ export class RetrievalModule implements Module {
246
380
  }
247
381
 
248
382
  /**
249
- * Step 3: Use Haiku to validate which candidates are actually relevant.
383
+ * Step 3: Use the configured retrieval model to validate which candidates are actually relevant.
250
384
  */
251
- private async validateRelevance(recentContext: string, candidates: Lesson[]): Promise<Lesson[]> {
385
+ private async validateRelevance(
386
+ recentContext: string,
387
+ candidates: Lesson[],
388
+ trace?: RetrievalTraceRun,
389
+ ): Promise<Lesson[]> {
252
390
  if (candidates.length <= 3) {
253
- // If only a few candidates, skip validation — they're probably all relevant
391
+ // If only a few candidates, skip validation — they're probably all relevant.
392
+ trace?.recordRelevanceSkipped('three-or-fewer-candidates');
254
393
  return candidates;
255
394
  }
256
395
 
257
396
  const model = this.config.retrievalModel ?? 'claude-haiku-4-5-20251001';
397
+ const providerParams = this.retrievalProviderParams();
258
398
 
259
399
  const candidateList = candidates.map(l =>
260
400
  `[${l.id}] (${l.confidence.toFixed(2)}) ${l.content}`
261
401
  ).join('\n');
402
+ const input = `Current conversation:\n${recentContext}\n\nCandidate lessons:\n${candidateList}`;
262
403
 
263
404
  const request: NormalizedRequest = {
264
405
  messages: [
265
406
  {
266
407
  participant: 'user',
267
- content: [{ type: 'text', text: `Current conversation:\n${recentContext}\n\nCandidate lessons:\n${candidateList}` }],
408
+ content: [{ type: 'text', text: input }],
268
409
  },
269
410
  ],
270
411
  system: RELEVANCE_VALIDATION_PROMPT,
271
412
  config: { model, maxTokens: 500, temperature: 0 },
413
+ ...(providerParams ? { providerParams } : {}),
272
414
  };
273
415
 
274
- const response = await this.config.membrane.complete(request);
416
+ trace?.startRelevance(RELEVANCE_VALIDATION_PROMPT, input);
417
+ let response;
418
+ try {
419
+ response = await this.config.membrane.complete(request);
420
+ } catch (error) {
421
+ trace?.recordStageError('relevance', error);
422
+ throw error;
423
+ }
275
424
  const text = response.content
276
425
  .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
277
426
  .map(b => b.text)
@@ -280,38 +429,91 @@ export class RetrievalModule implements Module {
280
429
  try {
281
430
  const parsed = JSON.parse(text);
282
431
  if (Array.isArray(parsed)) {
283
- const relevantIds = new Set(parsed.filter((s): s is string => typeof s === 'string'));
432
+ const parsedIds = parsed.filter((value): value is string => typeof value === 'string');
433
+ trace?.finishRelevance(text, parsedIds, 'json', response.content);
434
+ const relevantIds = new Set(parsedIds);
284
435
  return candidates.filter(l => relevantIds.has(l.id));
285
436
  }
286
437
  } catch {
287
- // On parse failure, return top 5 by confidence (fail open)
288
- return candidates.slice(0, 5);
438
+ // Fall through to confidence-ordered fallback.
289
439
  }
290
- return candidates.slice(0, 5);
440
+
441
+ const fallback = candidates.slice(0, 5);
442
+ trace?.finishRelevance(text, [], 'fallback', response.content);
443
+ return fallback;
291
444
  }
292
445
 
293
446
  // =========================================================================
294
447
  // Helpers
295
448
  // =========================================================================
296
449
 
297
- private getRecentContext(): string | null {
450
+ private getRecentContext(): RecentRetrievalContext | null {
298
451
  if (!this.ctx) return null;
299
452
 
300
- // Get the last few messages from the conversation
453
+ // Get the last few messages from the conversation.
301
454
  const { messages } = this.ctx.queryMessages({});
302
455
  if (messages.length === 0) return null;
303
456
 
304
- // Take the last 10 messages for context
457
+ // Take the last 10 messages for context.
305
458
  const recent = messages.slice(-10);
306
- return recent
459
+ const text = recent
307
460
  .map(m => {
308
- const text = m.content
461
+ const messageText = m.content
309
462
  .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
310
463
  .map(b => b.text)
311
464
  .join('\n');
312
- return `${m.participant}: ${text}`;
465
+ return `${m.participant}: ${messageText}`;
313
466
  })
314
467
  .join('\n\n');
468
+ const messageIds = recent.flatMap(message => {
469
+ try {
470
+ const candidate = (message as unknown as { id?: unknown }).id;
471
+ return typeof candidate === 'string' || typeof candidate === 'number'
472
+ ? [String(candidate)]
473
+ : [];
474
+ } catch {
475
+ // Trace-only metadata must never alter retrieval behavior.
476
+ return [];
477
+ }
478
+ });
479
+
480
+ return { text, messageCount: recent.length, messageIds };
481
+ }
482
+
483
+ private safeLessonIds(lessons: Lesson[]): string[] {
484
+ const ids: string[] = [];
485
+ for (const lesson of lessons) {
486
+ try {
487
+ if (typeof lesson.id === 'string') ids.push(lesson.id);
488
+ } catch {
489
+ // Cache provenance is observability metadata; omit unreadable IDs.
490
+ }
491
+ }
492
+ return ids;
493
+ }
494
+
495
+ private safeLessonSnapshots(lessons: Lesson[]): Lesson[] {
496
+ const snapshots: Lesson[] = [];
497
+ for (const item of lessons) {
498
+ try {
499
+ snapshots.push({
500
+ id: item.id,
501
+ content: item.content,
502
+ confidence: item.confidence,
503
+ tags: [...item.tags],
504
+ evidence: [...item.evidence],
505
+ created: item.created,
506
+ updated: item.updated,
507
+ deprecated: item.deprecated,
508
+ ...(item.deprecationReason !== undefined
509
+ ? { deprecationReason: item.deprecationReason }
510
+ : {}),
511
+ });
512
+ } catch {
513
+ // Trace-only lesson snapshots must not alter retrieval behavior.
514
+ }
515
+ }
516
+ return snapshots;
315
517
  }
316
518
 
317
519
  private hashContext(text: string): string {