@animalabs/connectome-host 0.7.3 → 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 (50) hide show
  1. package/CHANGELOG.md +156 -10
  2. package/HEADLESS-FLEET-PLAN.md +22 -0
  3. package/README.md +12 -1
  4. package/docs/AGENT-ONBOARDING.md +1 -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 +2 -2
  9. package/scripts/audit-module-optins.ts +288 -0
  10. package/src/framework-strategy.ts +13 -4
  11. package/src/headless.ts +14 -0
  12. package/src/index.ts +12 -9
  13. package/src/modules/fleet-module.ts +60 -1
  14. package/src/modules/fleet-types.ts +30 -1
  15. package/src/modules/mcpl-admin-module.ts +33 -4
  16. package/src/modules/retrieval-module.ts +249 -51
  17. package/src/modules/retrieval-trace-page.ts +254 -0
  18. package/src/modules/retrieval-trace.ts +904 -0
  19. package/src/modules/tts-relay-module.ts +33 -18
  20. package/src/modules/web-ui-module.ts +445 -894
  21. package/src/recipe.ts +55 -4
  22. package/src/retrieval-config.ts +39 -0
  23. package/src/strategies/frontdesk-strategy.ts +34 -125
  24. package/src/tui.ts +325 -54
  25. package/src/web/panel-data.ts +1187 -0
  26. package/src/web/protocol.ts +75 -10
  27. package/test/audit-module-optins.test.ts +167 -0
  28. package/test/fleet-panel-request.test.ts +90 -0
  29. package/test/framework-strategy-defaults.test.ts +22 -0
  30. package/test/frontdesk-strategy.test.ts +25 -37
  31. package/test/headless-panel-request.test.ts +201 -0
  32. package/test/mcpl-admin-module.test.ts +23 -0
  33. package/test/mock-headless-child.ts +14 -0
  34. package/test/retrieval-auth-loopback.test.ts +49 -0
  35. package/test/retrieval-config.test.ts +74 -0
  36. package/test/retrieval-module.test.ts +821 -0
  37. package/test/tui-format.test.ts +106 -0
  38. package/test/web-ui-context-coverage.test.ts +1 -1
  39. package/test/web-ui-module.test.ts +189 -3
  40. package/test/web-ui-observers.test.ts +8 -5
  41. package/test/web-ui-protocol.test.ts +0 -0
  42. package/web/src/App.tsx +159 -44
  43. package/web/src/Context.tsx +35 -8
  44. package/web/src/ContextDocument.tsx +20 -5
  45. package/web/src/Files.tsx +2 -8
  46. package/web/src/Lessons.tsx +2 -38
  47. package/web/src/Mcpl.tsx +80 -14
  48. package/web/src/Pins.tsx +5 -0
  49. package/web/src/Settings.tsx +5 -0
  50. 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
 
@@ -164,9 +256,15 @@ export class RetrievalModule implements Module {
164
256
 
165
257
  this.lastContextHash = contextHash;
166
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');
167
264
  return injections;
168
265
  } catch (err) {
169
266
  // Fail open — don't block inference if retrieval fails
267
+ trace?.finish('error', err);
170
268
  console.error('RetrievalModule: retrieval failed:', err);
171
269
  return [];
172
270
  }
@@ -176,24 +274,44 @@ export class RetrievalModule implements Module {
176
274
  // Pipeline Steps
177
275
  // =========================================================================
178
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
+
179
284
  /**
180
- * 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.
181
286
  */
182
- private async flagConcepts(recentContext: string): Promise<string[]> {
287
+ private async flagConcepts(
288
+ recentContext: string,
289
+ trace?: RetrievalTraceRun,
290
+ ): Promise<string[]> {
183
291
  const model = this.config.retrievalModel ?? 'claude-haiku-4-5-20251001';
292
+ const providerParams = this.retrievalProviderParams();
293
+ const input = `Recent conversation:\n${recentContext}`;
184
294
 
185
295
  const request: NormalizedRequest = {
186
296
  messages: [
187
297
  {
188
298
  participant: 'user',
189
- content: [{ type: 'text', text: `Recent conversation:\n${recentContext}` }],
299
+ content: [{ type: 'text', text: input }],
190
300
  },
191
301
  ],
192
302
  system: CONCEPT_FLAG_PROMPT,
193
303
  config: { model, maxTokens: 500, temperature: 0 },
304
+ ...(providerParams ? { providerParams } : {}),
194
305
  };
195
306
 
196
- 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
+ }
197
315
  const text = response.content
198
316
  .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
199
317
  .map(b => b.text)
@@ -202,16 +320,28 @@ export class RetrievalModule implements Module {
202
320
  try {
203
321
  const parsed = JSON.parse(text);
204
322
  if (Array.isArray(parsed)) {
205
- 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;
206
326
  }
327
+ trace?.finishConceptExtraction(text, [], 'invalid', response.content);
207
328
  } catch {
208
- // Try to extract from markdown code block
329
+ // Try to extract an array from a prose/markdown wrapper.
209
330
  const match = text.match(/\[([^\]]*)\]/);
210
331
  if (match) {
211
332
  try {
212
- 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
+ }
213
342
  } catch { /* fall through */ }
214
343
  }
344
+ trace?.finishConceptExtraction(text, [], 'invalid', response.content);
215
345
  }
216
346
  return [];
217
347
  }
@@ -250,32 +380,47 @@ export class RetrievalModule implements Module {
250
380
  }
251
381
 
252
382
  /**
253
- * 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.
254
384
  */
255
- 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[]> {
256
390
  if (candidates.length <= 3) {
257
- // 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');
258
393
  return candidates;
259
394
  }
260
395
 
261
396
  const model = this.config.retrievalModel ?? 'claude-haiku-4-5-20251001';
397
+ const providerParams = this.retrievalProviderParams();
262
398
 
263
399
  const candidateList = candidates.map(l =>
264
400
  `[${l.id}] (${l.confidence.toFixed(2)}) ${l.content}`
265
401
  ).join('\n');
402
+ const input = `Current conversation:\n${recentContext}\n\nCandidate lessons:\n${candidateList}`;
266
403
 
267
404
  const request: NormalizedRequest = {
268
405
  messages: [
269
406
  {
270
407
  participant: 'user',
271
- content: [{ type: 'text', text: `Current conversation:\n${recentContext}\n\nCandidate lessons:\n${candidateList}` }],
408
+ content: [{ type: 'text', text: input }],
272
409
  },
273
410
  ],
274
411
  system: RELEVANCE_VALIDATION_PROMPT,
275
412
  config: { model, maxTokens: 500, temperature: 0 },
413
+ ...(providerParams ? { providerParams } : {}),
276
414
  };
277
415
 
278
- 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
+ }
279
424
  const text = response.content
280
425
  .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
281
426
  .map(b => b.text)
@@ -284,38 +429,91 @@ export class RetrievalModule implements Module {
284
429
  try {
285
430
  const parsed = JSON.parse(text);
286
431
  if (Array.isArray(parsed)) {
287
- 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);
288
435
  return candidates.filter(l => relevantIds.has(l.id));
289
436
  }
290
437
  } catch {
291
- // On parse failure, return top 5 by confidence (fail open)
292
- return candidates.slice(0, 5);
438
+ // Fall through to confidence-ordered fallback.
293
439
  }
294
- return candidates.slice(0, 5);
440
+
441
+ const fallback = candidates.slice(0, 5);
442
+ trace?.finishRelevance(text, [], 'fallback', response.content);
443
+ return fallback;
295
444
  }
296
445
 
297
446
  // =========================================================================
298
447
  // Helpers
299
448
  // =========================================================================
300
449
 
301
- private getRecentContext(): string | null {
450
+ private getRecentContext(): RecentRetrievalContext | null {
302
451
  if (!this.ctx) return null;
303
452
 
304
- // Get the last few messages from the conversation
453
+ // Get the last few messages from the conversation.
305
454
  const { messages } = this.ctx.queryMessages({});
306
455
  if (messages.length === 0) return null;
307
456
 
308
- // Take the last 10 messages for context
457
+ // Take the last 10 messages for context.
309
458
  const recent = messages.slice(-10);
310
- return recent
459
+ const text = recent
311
460
  .map(m => {
312
- const text = m.content
461
+ const messageText = m.content
313
462
  .filter((b): b is { type: 'text'; text: string } => b.type === 'text')
314
463
  .map(b => b.text)
315
464
  .join('\n');
316
- return `${m.participant}: ${text}`;
465
+ return `${m.participant}: ${messageText}`;
317
466
  })
318
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;
319
517
  }
320
518
 
321
519
  private hashContext(text: string): string {