@animalabs/connectome-host 0.7.3 → 0.8.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.
- package/.env.example +12 -5
- package/.github/PULL_REQUEST_TEMPLATE.md +3 -2
- package/.github/workflows/changelog.yml +9 -4
- package/.github/workflows/ci.yml +5 -3
- package/.github/workflows/publish.yml +12 -6
- package/CHANGELOG.md +401 -10
- package/CONTRIBUTING.md +47 -19
- package/HEADLESS-FLEET-PLAN.md +22 -0
- package/README.md +39 -1
- package/bun.lock +27 -31
- package/changelog.d/README.md +28 -0
- package/docs/AGENT-ONBOARDING.md +1 -1
- package/docs/debug-context-api.md +2 -2
- package/docs/retrieval-traces.md +173 -0
- package/docs/webui-deployment.md +2 -1
- package/package.json +6 -6
- package/recipes/SETUP.md +11 -5
- package/recipes/TRIUMVIRATE-SETUP.md +68 -14
- package/recipes/knowledge-miner.json +0 -30
- package/recipes/mock-test.json +19 -0
- package/recipes/triumvirate.json +6 -1
- package/scripts/audit-module-optins.ts +288 -0
- package/scripts/release-changelog.ts +210 -21
- package/src/cache-keepalive-log.ts +41 -0
- package/src/commands.ts +96 -0
- package/src/framework-strategy.ts +50 -4
- package/src/gate-telemetry.ts +106 -0
- package/src/headless.ts +24 -0
- package/src/index.ts +179 -64
- package/src/mcpl-config.ts +99 -1
- package/src/modules/fleet-module.ts +60 -1
- package/src/modules/fleet-types.ts +30 -1
- package/src/modules/identity-module.ts +310 -2
- package/src/modules/instructions-module.ts +265 -0
- package/src/modules/mcpl-admin-module.ts +89 -13
- package/src/modules/retrieval-module.ts +249 -51
- package/src/modules/retrieval-trace-page.ts +254 -0
- package/src/modules/retrieval-trace.ts +904 -0
- package/src/modules/subagent-module.ts +18 -0
- package/src/modules/tts-relay-module.ts +33 -18
- package/src/modules/web-ui-module.ts +445 -894
- package/src/recipe.ts +787 -29
- package/src/retrieval-config.ts +39 -0
- package/src/strategies/frontdesk-strategy.ts +34 -125
- package/src/tui.ts +325 -54
- package/src/web/panel-data.ts +1206 -0
- package/src/web/protocol.ts +75 -10
- package/src/workspace-mounts.ts +73 -0
- package/test/audit-module-optins.test.ts +174 -0
- package/test/cache-keepalive-log.test.ts +83 -0
- package/test/conversations-recipe.test.ts +142 -0
- package/test/fleet-panel-request.test.ts +90 -0
- package/test/framework-fkm-composition.test.ts +35 -3
- package/test/framework-strategy-defaults.test.ts +41 -0
- package/test/frontdesk-strategy.test.ts +25 -37
- package/test/gate-telemetry-adapter.test.ts +84 -0
- package/test/gate-telemetry.test.ts +91 -0
- package/test/headless-panel-request.test.ts +201 -0
- package/test/identity-and-surfaces.test.ts +212 -1
- package/test/instructions-module.test.ts +258 -0
- package/test/mcpl-admin-module.test.ts +64 -0
- package/test/mcpl-agent-overlay.test.ts +51 -3
- package/test/mcpl-child-env.test.ts +64 -0
- package/test/mock-headless-child.ts +14 -0
- package/test/nudge-command.test.ts +47 -0
- package/test/recipe-cache-keepalive.test.ts +59 -0
- package/test/recipe-compression-fallback.test.ts +19 -0
- package/test/recipe-hybrid-prose-routing.test.ts +12 -0
- package/test/recipe-instructions.test.ts +176 -0
- package/test/recipe-kv-unified.test.ts +87 -0
- package/test/recipe-mcp-source.test.ts +54 -0
- package/test/recipe-openai-compatible.test.ts +54 -0
- package/test/recipe-path-resolution.test.ts +19 -8
- package/test/recipe-provider.test.ts +14 -0
- package/test/recipe-save-unresolved.test.ts +244 -0
- package/test/recipe-source-only.test.ts +38 -0
- package/test/release-changelog.test.ts +202 -0
- package/test/retrieval-auth-loopback.test.ts +49 -0
- package/test/retrieval-config.test.ts +74 -0
- package/test/retrieval-module.test.ts +821 -0
- package/test/subagent-prose-routing.test.ts +109 -0
- package/test/tui-format.test.ts +106 -0
- package/test/web-ui-context-coverage.test.ts +1 -1
- package/test/web-ui-module.test.ts +189 -3
- package/test/web-ui-observers.test.ts +8 -5
- package/test/web-ui-protocol.test.ts +0 -0
- package/test/workspace-mounts.test.ts +68 -0
- package/web/src/App.tsx +160 -44
- package/web/src/Context.tsx +35 -8
- package/web/src/ContextDocument.tsx +20 -5
- package/web/src/Files.tsx +2 -8
- package/web/src/Health.tsx +61 -1
- package/web/src/Lessons.tsx +2 -38
- package/web/src/Mcpl.tsx +80 -14
- package/web/src/Pins.tsx +5 -0
- package/web/src/Settings.tsx +5 -0
- package/web/vite.config.ts +8 -2
|
@@ -118,8 +118,10 @@ export class McplAdminModule implements Module {
|
|
|
118
118
|
{
|
|
119
119
|
name: 'mcpl_list',
|
|
120
120
|
description:
|
|
121
|
-
'List all MCPL servers:
|
|
122
|
-
'
|
|
121
|
+
'List all MCPL servers: connection/retry state, whether policy was established, ' +
|
|
122
|
+
'the effective grant, masked/denied capability paths, host-command authority, ' +
|
|
123
|
+
'validated manifest revision/fetch/negotiation freshness, tool count, target, ' +
|
|
124
|
+
'and config source.',
|
|
123
125
|
inputSchema: { type: 'object', properties: {} },
|
|
124
126
|
},
|
|
125
127
|
{
|
|
@@ -129,7 +131,14 @@ export class McplAdminModule implements Module {
|
|
|
129
131
|
'restarts) and hot-connect it now — its tools become available immediately. ' +
|
|
130
132
|
'If a server with this id is already running it is restarted with the new ' +
|
|
131
133
|
'config. Provide either `command` (stdio, spawned as the host user) or `url` ' +
|
|
132
|
-
'(WebSocket). Relative ./ args resolve against the host working directory.'
|
|
134
|
+
'(WebSocket). Relative ./ args resolve against the host working directory. ' +
|
|
135
|
+
'Sensible defaults: omit (or pass empty) the list fields and every feature ' +
|
|
136
|
+
'set and tool the server offers is available; an empty array means ' +
|
|
137
|
+
'"unspecified", never deny-all (deny-all is disabledTools/' +
|
|
138
|
+
'disabledFeatureSets: ["*"]). Self-deployed servers get channels + tools ' +
|
|
139
|
+
'only — consequential capabilities (context hooks around your inference, ' +
|
|
140
|
+
'server-initiated inference, inference lifecycle) are host-masked; a server ' +
|
|
141
|
+
'that genuinely needs one is an operator conversation, not a deploy flag.',
|
|
133
142
|
inputSchema: {
|
|
134
143
|
type: 'object',
|
|
135
144
|
properties: {
|
|
@@ -141,10 +150,10 @@ export class McplAdminModule implements Module {
|
|
|
141
150
|
token: { type: 'string', description: 'Bearer token for WebSocket auth (only when the operator hands you one — prefer `access`).' },
|
|
142
151
|
access: { type: 'string', description: 'Name of a host-managed access grant (e.g. "eidoverse"): the host attaches your standing credentials to the connection automatically. Nothing for you to obtain or handle.' },
|
|
143
152
|
toolPrefix: { type: 'string', description: 'Tool namespace prefix. Default: mcpl--<id>.' },
|
|
144
|
-
reconnect: { type: 'boolean', description: 'Auto-reconnect on transport failure (
|
|
145
|
-
enabledFeatureSets: { type: 'array', items: { type: 'string' } },
|
|
146
|
-
disabledFeatureSets: { type: 'array', items: { type: 'string' } },
|
|
147
|
-
enabledTools: { type: 'array', items: { type: 'string' }, description: 'Tool allow-list (bare names, * wildcard).' },
|
|
153
|
+
reconnect: { type: 'boolean', description: 'Auto-reconnect on transport failure. Default: true for websocket URLs (a bounced server comes back on its own), false for stdio. Note: does NOT respawn a crashed child — use mcpl_restart for that.' },
|
|
154
|
+
enabledFeatureSets: { type: 'array', items: { type: 'string' }, description: 'Feature-set allowlist (* wildcard). Omit or pass [] for all offered.' },
|
|
155
|
+
disabledFeatureSets: { type: 'array', items: { type: 'string' }, description: 'Feature-set deny-list; wins over enabled.' },
|
|
156
|
+
enabledTools: { type: 'array', items: { type: 'string' }, description: 'Tool allow-list (bare names, * wildcard). Omit or pass [] for all offered.' },
|
|
148
157
|
disabledTools: { type: 'array', items: { type: 'string' }, description: 'Tool deny-list; wins over enabledTools.' },
|
|
149
158
|
},
|
|
150
159
|
required: ['id'],
|
|
@@ -227,7 +236,24 @@ export class McplAdminModule implements Module {
|
|
|
227
236
|
|
|
228
237
|
private handleList(): ToolResult {
|
|
229
238
|
const framework = this.requireFramework();
|
|
230
|
-
|
|
239
|
+
// These fields land in agent-framework 0.8's MCPL grant work. Keep them
|
|
240
|
+
// optional here so connectome-host remains truthful ("unknown") if it is
|
|
241
|
+
// temporarily run against an older framework package during rollout.
|
|
242
|
+
const live = framework.listMcplServers() as Array<
|
|
243
|
+
ReturnType<AgentFramework['listMcplServers']>[number] & {
|
|
244
|
+
retrying?: boolean;
|
|
245
|
+
policyEstablished?: boolean;
|
|
246
|
+
effectiveGrant?: string[];
|
|
247
|
+
maskedCapabilities?: string[];
|
|
248
|
+
deniedCapabilities?: string[];
|
|
249
|
+
allowHostCommands?: boolean;
|
|
250
|
+
manifestState?: {
|
|
251
|
+
lastValidatedRevision: string | null;
|
|
252
|
+
lastFetchedAt: number | null;
|
|
253
|
+
lastNegotiatedAt: number | null;
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
>;
|
|
231
257
|
const overlay = readAgentOverlay(this.overlayPath);
|
|
232
258
|
const fileServers = readMcplServersFile(this.configPath);
|
|
233
259
|
|
|
@@ -237,8 +263,20 @@ export class McplAdminModule implements Module {
|
|
|
237
263
|
? 'agent-overlay'
|
|
238
264
|
: s.id in fileServers ? 'file/recipe' : 'recipe';
|
|
239
265
|
const target = s.command ?? s.url ?? '?';
|
|
266
|
+
const connectionState = s.connected ? 'CONNECTED' : s.retrying ? 'RETRYING' : 'DISCONNECTED';
|
|
267
|
+
const policyState = s.policyEstablished === undefined
|
|
268
|
+
? 'unknown'
|
|
269
|
+
: s.policyEstablished ? 'established' : 'not-established';
|
|
270
|
+
const hostCommands = s.allowHostCommands === undefined
|
|
271
|
+
? 'unknown'
|
|
272
|
+
: s.allowHostCommands ? 'allow' : 'deny';
|
|
240
273
|
lines.push(
|
|
241
|
-
`${s.id}: ${
|
|
274
|
+
`${s.id}: ${connectionState} — policy=${policyState}, ` +
|
|
275
|
+
`grant=${formatCapabilityList(s.effectiveGrant)}, ` +
|
|
276
|
+
`masked=${formatCapabilityList(s.maskedCapabilities)}, ` +
|
|
277
|
+
`denied=${formatCapabilityList(s.deniedCapabilities)}, ` +
|
|
278
|
+
`hostCommands=${hostCommands}, ` +
|
|
279
|
+
`manifest=${formatManifestState(s.manifestState)}; ${s.toolCount} tools, ` +
|
|
242
280
|
`prefix=${s.toolPrefix}, source=${source}, ${target}`,
|
|
243
281
|
);
|
|
244
282
|
}
|
|
@@ -290,10 +328,17 @@ export class McplAdminModule implements Module {
|
|
|
290
328
|
}
|
|
291
329
|
if (typeof input.toolPrefix === 'string') entry.toolPrefix = input.toolPrefix;
|
|
292
330
|
if (typeof input.reconnect === 'boolean') entry.reconnect = input.reconnect;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
331
|
+
// Empty arrays are NOT persisted: OpenAI-style strict function calling
|
|
332
|
+
// forces every schema property, so callers emit `[]` meaning
|
|
333
|
+
// "unspecified" — and a persisted empty ALLOWLIST is deny-all under the
|
|
334
|
+
// §5.3 pin (Mica's silently eventless eidoverse, 2026-08-04).
|
|
335
|
+
// resolveOverlayEntry drops them at read time too; this keeps the file
|
|
336
|
+
// itself from carrying the trap. Deny-all is spelled ["*"] on the
|
|
337
|
+
// deny-lists.
|
|
338
|
+
if (Array.isArray(input.enabledFeatureSets) && input.enabledFeatureSets.length) entry.enabledFeatureSets = input.enabledFeatureSets.map(String);
|
|
339
|
+
if (Array.isArray(input.disabledFeatureSets) && input.disabledFeatureSets.length) entry.disabledFeatureSets = input.disabledFeatureSets.map(String);
|
|
340
|
+
if (Array.isArray(input.enabledTools) && input.enabledTools.length) entry.enabledTools = input.enabledTools.map(String);
|
|
341
|
+
if (Array.isArray(input.disabledTools) && input.disabledTools.length) entry.disabledTools = input.disabledTools.map(String);
|
|
297
342
|
|
|
298
343
|
// Persist to the overlay first — a connect failure still leaves the entry
|
|
299
344
|
// in place so the agent can fix the server and mcpl_restart it.
|
|
@@ -378,3 +423,34 @@ export class McplAdminModule implements Module {
|
|
|
378
423
|
return ok(`Unloaded server "${id}" — its tools are gone from your toolset. ${persistNote}`);
|
|
379
424
|
}
|
|
380
425
|
}
|
|
426
|
+
|
|
427
|
+
function formatCapabilityList(paths: string[] | undefined): string {
|
|
428
|
+
if (paths === undefined) return 'unknown';
|
|
429
|
+
return `[${paths.join(',')}]`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function formatManifestState(state: {
|
|
433
|
+
lastValidatedRevision: string | null;
|
|
434
|
+
lastFetchedAt: number | null;
|
|
435
|
+
lastNegotiatedAt: number | null;
|
|
436
|
+
} | undefined): string {
|
|
437
|
+
if (state === undefined) return 'unknown';
|
|
438
|
+
return `{revision=${formatManifestRevision(state.lastValidatedRevision)},` +
|
|
439
|
+
`fetchedAt=${formatManifestTimestamp(state.lastFetchedAt)},` +
|
|
440
|
+
`negotiatedAt=${formatManifestTimestamp(state.lastNegotiatedAt)}}`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Bound and quote the server-authored, equality-only revision before putting
|
|
444
|
+
* it on a model-facing text surface. Conforming revisions fit well below the
|
|
445
|
+
* limit; malformed peers cannot inject control lines or unbounded text. */
|
|
446
|
+
function formatManifestRevision(revision: string | null): string {
|
|
447
|
+
if (revision === null) return 'none';
|
|
448
|
+
const bounded = revision.length <= 64 ? revision : `${revision.slice(0, 61)}...`;
|
|
449
|
+
return JSON.stringify(bounded);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function formatManifestTimestamp(timestamp: number | null): string {
|
|
453
|
+
if (timestamp === null) return 'none';
|
|
454
|
+
const date = new Date(timestamp);
|
|
455
|
+
return Number.isNaN(date.getTime()) ? 'invalid' : date.toISOString();
|
|
456
|
+
}
|
|
@@ -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
|
|
6
|
-
*
|
|
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
|
|
8
|
+
* 3. Validate relevance: filter to actually relevant lessons
|
|
9
9
|
*
|
|
10
|
-
* Steps 1 and 3 use
|
|
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
|
|
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(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
|
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
|
|
285
|
+
* Step 1: Use the configured retrieval model to identify concepts that might benefit from background knowledge.
|
|
181
286
|
*/
|
|
182
|
-
private async flagConcepts(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
383
|
+
* Step 3: Use the configured retrieval model to validate which candidates are actually relevant.
|
|
254
384
|
*/
|
|
255
|
-
private async validateRelevance(
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
292
|
-
return candidates.slice(0, 5);
|
|
438
|
+
// Fall through to confidence-ordered fallback.
|
|
293
439
|
}
|
|
294
|
-
|
|
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():
|
|
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
|
-
|
|
459
|
+
const text = recent
|
|
311
460
|
.map(m => {
|
|
312
|
-
const
|
|
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}: ${
|
|
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 {
|