@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
package/src/recipe.ts CHANGED
@@ -95,6 +95,11 @@ export interface RecipeStrategy {
95
95
  /** Override wording for the witnessed-record instruction ({targetTokens}
96
96
  * substituted). */
97
97
  witnessedInstruction?: string;
98
+ /** Identity reminder appended to every compression/merge instruction.
99
+ * For agents in multi-resident channels (and older models especially):
100
+ * names the agent and directs attribution so pure-witness chunks don't
101
+ * flip the summarizer into another speaker's identity. */
102
+ identityReminder?: string;
98
103
  }
99
104
 
100
105
  export interface RecipeAgent {
@@ -421,10 +426,19 @@ export interface RecipeModules {
421
426
  /**
422
427
  * Lesson retrieval-injection (requires `lessons`). OPT-IN — defaults to off
423
428
  * and is deliberately not part of the standard recipe: it injects
424
- * context-dependent content into every compile and spends two Haiku calls
425
- * per turn. Enable only for agents that actually curate a lesson library.
429
+ * context-dependent content into every compile and spends up to two
430
+ * configured retrieval-model calls. Enable only for agents that actually
431
+ * curate a lesson library.
426
432
  */
427
- retrieval?: boolean | { model?: string; maxInjected?: number };
433
+ retrieval?: boolean | {
434
+ model?: string;
435
+ maxInjected?: number;
436
+ /**
437
+ * Optional OpenAI Responses/Codex reasoning effort for both retrieval calls.
438
+ * Requires an explicit retrieval model.
439
+ */
440
+ reasoningEffort?: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
441
+ };
428
442
  wake?: boolean | import('@animalabs/agent-framework').GateConfig;
429
443
  workspace?: boolean | { mounts: RecipeWorkspaceMount[]; configMount?: boolean };
430
444
  /**
@@ -718,7 +732,7 @@ export const DEFAULT_RECIPE: Recipe = {
718
732
  },
719
733
  modules: {
720
734
  // subagents + lessons + retrieval deliberately omitted — all opt-in only
721
- // (retrieval additionally adds per-turn context churn + Haiku costs);
735
+ // (retrieval additionally adds per-turn context churn + retrieval-model costs);
722
736
  // see the RecipeModules field docs.
723
737
  wake: true,
724
738
  workspace: true,
@@ -1255,6 +1269,43 @@ export function validateRecipe(raw: unknown): Recipe {
1255
1269
  }
1256
1270
  }
1257
1271
 
1272
+ // Validate retrieval provider reasoning when configured.
1273
+ const retrieval = mods.retrieval;
1274
+ if (retrieval !== undefined && typeof retrieval !== 'boolean') {
1275
+ if (!retrieval || typeof retrieval !== 'object' || Array.isArray(retrieval)) {
1276
+ throw new Error('Recipe modules.retrieval must be a boolean or object.');
1277
+ }
1278
+ const retrievalConfig = retrieval as Record<string, unknown>;
1279
+ if (retrievalConfig.reasoningContext !== undefined) {
1280
+ throw new Error(
1281
+ 'modules.retrieval.reasoningContext is not supported: retrieval model calls ' +
1282
+ 'are independent one-shot requests with no earlier reasoning items.',
1283
+ );
1284
+ }
1285
+ const efforts = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
1286
+ if (retrievalConfig.reasoningEffort !== undefined &&
1287
+ (typeof retrievalConfig.reasoningEffort !== 'string'
1288
+ || !efforts.includes(retrievalConfig.reasoningEffort))) {
1289
+ throw new Error(`Invalid modules.retrieval.reasoningEffort ${JSON.stringify(retrievalConfig.reasoningEffort)}.`);
1290
+ }
1291
+ if (retrievalConfig.reasoningEffort !== undefined
1292
+ && agent.provider !== 'openai-responses'
1293
+ && agent.provider !== 'openai-codex') {
1294
+ throw new Error(
1295
+ 'modules.retrieval.reasoningEffort requires agent.provider ' +
1296
+ '"openai-responses" or "openai-codex".',
1297
+ );
1298
+ }
1299
+ if (retrievalConfig.reasoningEffort !== undefined
1300
+ && (typeof retrievalConfig.model !== 'string'
1301
+ || !retrievalConfig.model.trim())) {
1302
+ throw new Error(
1303
+ 'modules.retrieval.model must be a non-empty string when ' +
1304
+ 'modules.retrieval.reasoningEffort is configured.',
1305
+ );
1306
+ }
1307
+ }
1308
+
1258
1309
  // Validate ttsRelay if present — url + token are load-bearing, and a
1259
1310
  // recipe that names the module but can't reach a relay should fail at
1260
1311
  // load, not silently stream nowhere.
@@ -0,0 +1,39 @@
1
+ import type { Membrane } from '@animalabs/membrane';
2
+ import type { RecipeAgent, RecipeModules } from './recipe.js';
3
+ import type { RetrievalModuleConfig } from './modules/retrieval-module.js';
4
+
5
+ type RetrievalRecipeConfig = Exclude<RecipeModules['retrieval'], boolean | undefined>;
6
+
7
+ /** Translate the recipe's retrieval block into the module's runtime config. */
8
+ export function buildRetrievalModuleConfig(
9
+ membrane: Membrane,
10
+ retrieval: RecipeModules['retrieval'],
11
+ provider: RecipeAgent['provider'] = 'anthropic',
12
+ ): RetrievalModuleConfig {
13
+ const config: RetrievalRecipeConfig = typeof retrieval === 'object' ? retrieval : {};
14
+ if (config.reasoningEffort
15
+ && provider !== 'openai-responses'
16
+ && provider !== 'openai-codex') {
17
+ throw new Error(
18
+ 'modules.retrieval.reasoningEffort requires agent.provider ' +
19
+ '"openai-responses" or "openai-codex".',
20
+ );
21
+ }
22
+ if (config.reasoningEffort
23
+ && (typeof config.model !== 'string' || !config.model.trim())) {
24
+ throw new Error(
25
+ 'modules.retrieval.model must be a non-empty string when ' +
26
+ 'modules.retrieval.reasoningEffort is configured.',
27
+ );
28
+ }
29
+ const retrievalReasoning = config.reasoningEffort
30
+ ? { effort: config.reasoningEffort }
31
+ : undefined;
32
+
33
+ return {
34
+ membrane,
35
+ retrievalModel: config.model,
36
+ retrievalReasoning,
37
+ maxInjectedLessons: config.maxInjected,
38
+ };
39
+ }
@@ -1,30 +1,16 @@
1
1
  import { AutobiographicalStrategy } from '@animalabs/context-manager';
2
2
  import type {
3
3
  AutobiographicalConfig,
4
+ Chunk,
4
5
  ContextEntry,
5
6
  MessageStoreView,
6
7
  ContextLogView,
7
8
  TokenBudget,
8
9
  StoredMessage,
9
- SummaryEntry,
10
10
  } from '@animalabs/context-manager';
11
11
  import type { ContentBlock } from '@animalabs/membrane';
12
12
  import { formatZonedTime, resolveTimeZone } from '@animalabs/agent-framework';
13
13
 
14
- // Structural mirror of AutobiographicalStrategy's internal Chunk.
15
- // Kept inline because @animalabs/context-manager does not currently export it.
16
- interface Chunk {
17
- index: number;
18
- startIndex: number;
19
- endIndex: number;
20
- messages: StoredMessage[];
21
- tokens: number;
22
- compressed: boolean;
23
- diary?: string;
24
- summaryId?: string;
25
- phaseType?: string;
26
- }
27
-
28
14
  export type FrontdeskStrategyOptions = Partial<AutobiographicalConfig> & { timeZone?: string };
29
15
 
30
16
  /**
@@ -35,10 +21,22 @@ export type FrontdeskStrategyOptions = Partial<AutobiographicalConfig> & { timeZ
35
21
  * 1. Provenance wrapping — prepends a `[zulip · #channel · topic · @user · HH:MM · msg-id]`
36
22
  * header to each MCPL-originated entry so the agent knows the message came from a
37
23
  * channel and which reply path to use.
38
- * 2. Topic-aware compression — chunk boundaries prefer Zulip-topic transitions and the
39
- * compression prompt instructs per-topic structure.
40
- * 3. Question/mention salience — unanswered user questions and @mentions are preserved
41
- * verbatim longer (both during compression and during L1 selection under budget).
24
+ * 2. Topic-aware compression — chunk boundaries close at Zulip-topic transitions
25
+ * (via the base chunker's `chunkBoundaryHint` seam) and the compression prompt
26
+ * instructs per-topic structure.
27
+ * 3. Question/mention salience unanswered user questions and @mentions are named
28
+ * verbatim in the compression prompt so summaries preserve them.
29
+ *
30
+ * History note: through conhost 0.7.x this class forked the whole of
31
+ * `rebuildChunks` for feature 2 — written against a pre-chunk-persistence
32
+ * base, silently bypassing chunk records and the fail-closed orphan guard —
33
+ * and biased the hierarchical renderer's L1 selection for feature 3.
34
+ * Frontdesk agents now ride the adaptive path (see framework-strategy.ts
35
+ * defaults): chunking goes through the base implementation with a boundary
36
+ * hint, and salient content survives through the compression prompt rather
37
+ * than selection-order bias. Stores created by the fork carry no chunk
38
+ * records; context-manager's `migrateChunkRecords` backfills them from L1
39
+ * `sourceIds` on first load.
42
40
  */
43
41
  export class FrontdeskStrategy extends AutobiographicalStrategy {
44
42
  override readonly name: string = 'frontdesk';
@@ -162,77 +160,13 @@ export class FrontdeskStrategy extends AutobiographicalStrategy {
162
160
  // ==========================================================================
163
161
 
164
162
  /**
165
- * Override rebuildChunks to additionally close chunk boundaries at Zulip-topic
166
- * transitions. Falls back to base size/count behaviour when topic metadata is absent.
163
+ * Close chunk boundaries at Zulip-topic transitions, so summaries of
164
+ * unrelated topics are not fused. Rides the base chunker (context-manager
165
+ * ≥0.6.3): record persistence, minimum-size and tool-pairing guards all
166
+ * apply to hinted closes.
167
167
  */
168
- protected override rebuildChunks(store: MessageStoreView): void {
169
- const messagesToChunk = this.getCompressibleMessages(store);
170
-
171
- const existingCompressed = new Map<string, Chunk>();
172
- for (const chunk of this.chunks as unknown as Chunk[]) {
173
- if (chunk.compressed) {
174
- existingCompressed.set(this.chunkKey(chunk as never), chunk);
175
- }
176
- }
177
-
178
- this.chunks = [];
179
- this.compressionQueue = [];
180
-
181
- let currentChunk: StoredMessage[] = [];
182
- let currentTokens = 0;
183
- let chunkFilteredStart = 0;
184
- const MIN_CHUNK = 4;
185
-
186
- const push = (startIdx: number, endIdx: number, msgs: StoredMessage[], tokens: number) => {
187
- const chunk = this.createChunk(
188
- this.chunks.length,
189
- startIdx,
190
- endIdx,
191
- msgs,
192
- tokens,
193
- existingCompressed as never,
194
- );
195
- this.chunks.push(chunk);
196
- if (!chunk.compressed) this.compressionQueue.push(chunk.index);
197
- };
198
-
199
- for (let i = 0; i < messagesToChunk.length; i++) {
200
- const msg = messagesToChunk[i];
201
- let msgTokens = store.estimateTokens(msg);
202
- if (this.config.attachmentsIgnoreSize) {
203
- msgTokens = this.estimateTextOnlyTokens(msg);
204
- }
205
-
206
- // Topic boundary: close current chunk BEFORE adding msg when topic changes
207
- // and the chunk has at least MIN_CHUNK messages. This keeps summaries of
208
- // unrelated topics from being merged.
209
- if (
210
- currentChunk.length >= MIN_CHUNK &&
211
- this.isTopicBoundary(currentChunk[currentChunk.length - 1], msg)
212
- ) {
213
- push(chunkFilteredStart, i, currentChunk, currentTokens);
214
- currentChunk = [];
215
- currentTokens = 0;
216
- chunkFilteredStart = i;
217
- }
218
-
219
- currentChunk.push(msg);
220
- currentTokens += msgTokens;
221
-
222
- const shouldClose =
223
- currentTokens >= this.config.targetChunkTokens && currentChunk.length >= MIN_CHUNK;
224
-
225
- if (shouldClose) {
226
- push(chunkFilteredStart, i + 1, currentChunk, currentTokens);
227
- currentChunk = [];
228
- currentTokens = 0;
229
- chunkFilteredStart = i + 1;
230
- }
231
- }
232
-
233
- if (currentChunk.length >= MIN_CHUNK) {
234
- push(chunkFilteredStart, messagesToChunk.length, currentChunk, currentTokens);
235
- }
168
+ protected override chunkBoundaryHint(prev: StoredMessage, next: StoredMessage): boolean {
169
+ return this.isTopicBoundary(prev, next);
236
170
  }
237
171
 
238
172
  protected isTopicBoundary(prev: StoredMessage, curr: StoredMessage): boolean {
@@ -255,6 +189,11 @@ export class FrontdeskStrategy extends AutobiographicalStrategy {
255
189
  // ==========================================================================
256
190
 
257
191
  protected override getCompressionInstruction(chunk: Chunk, targetTokens: number): string {
192
+ // Witnessed chunks keep the base treatment (recipe-configurable
193
+ // witnessed prompt); the old fork predated it and steamrolled it.
194
+ if (this.chunkIsWitnessed(chunk)) {
195
+ return super.getCompressionInstruction(chunk, targetTokens);
196
+ }
258
197
  const topics = new Set<string>();
259
198
  for (const m of chunk.messages) {
260
199
  const t = this.extractTopicKey(m);
@@ -289,43 +228,13 @@ export class FrontdeskStrategy extends AutobiographicalStrategy {
289
228
  }
290
229
 
291
230
  // ==========================================================================
292
- // Feature 3b: Salience-biased L1 selection
293
- // ==========================================================================
294
-
295
- protected override selectL1Summaries(
296
- shownL1: SummaryEntry[],
297
- budget: number,
298
- maxTokens: number,
299
- ): { selected: SummaryEntry[]; tokensUsed: number } {
300
- if (shownL1.length === 0) return { selected: [], tokensUsed: 0 };
301
-
302
- const isSalient = (s: SummaryEntry): boolean =>
303
- s.sourceIds.some((id) => this.salientSourceIds.has(id));
304
-
305
- const salient: SummaryEntry[] = [];
306
- const routine: SummaryEntry[] = [];
307
- for (const s of shownL1) {
308
- (isSalient(s) ? salient : routine).push(s);
309
- }
310
-
311
- const selected: SummaryEntry[] = [];
312
- let used = 0;
313
-
314
- for (const group of [salient, routine]) {
315
- for (const s of group) {
316
- if (used + s.tokens > budget) break;
317
- if (used + s.tokens > maxTokens) break;
318
- selected.push(s);
319
- used += s.tokens;
320
- }
321
- }
322
-
323
- return { selected, tokensUsed: used };
324
- }
325
-
326
- // ==========================================================================
327
- // Salience tracking (shared by 3a and 3b)
231
+ // Salience tracking (feeds the compression instruction)
328
232
  // ==========================================================================
233
+ // The pre-adaptive frontdesk also overrode the hierarchical renderer's
234
+ // selectL1Summaries to emit salient L1s first under budget pressure. Under
235
+ // adaptive resolution the picker selects a coverage frontier — emission
236
+ // order is not budget-competitive — so that bias is retired; salient
237
+ // content survives because getCompressionInstruction names it verbatim.
329
238
 
330
239
  /**
331
240
  * Recompute which user messages are "unanswered questions or mentions":