@m6d/cortex-server 1.4.0 → 1.6.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/README.md CHANGED
@@ -57,7 +57,7 @@ export default {
57
57
 
58
58
  `cortex.serve()` is async — it runs database migrations on startup.
59
59
 
60
- Each key in `agents` becomes a route prefix (e.g. `assistant` → `/agents/assistant/...`). Agents can define per-agent `systemPrompt`, `tools`, `backendFetch`, `loadSessionData`, and lifecycle hooks (`onToolCall`, `onStreamFinish`).
60
+ Each key in `agents` becomes a route prefix (e.g. `assistant` → `/agents/assistant/...`). Agents can define per-agent `systemPrompt`, `tools`, `backendFetch`, `loadSessionData`, `resolveRequestContext`, and lifecycle hooks (`onToolCall`, `onStreamFinish`).
61
61
 
62
62
  ## Requirements
63
63
 
@@ -1,4 +1,4 @@
1
1
  import type { ResolvedCortexAgentConfig } from "../config.ts";
2
2
  import type { Thread } from "../types.ts";
3
- export declare function stream(messages: unknown[], thread: Thread, userId: string, token: string, config: ResolvedCortexAgentConfig): Promise<Response>;
3
+ export declare function stream(messages: unknown[], thread: Thread, userId: string, token: string, requestContext: Record<string, unknown>, config: ResolvedCortexAgentConfig): Promise<Response>;
4
4
  export declare function generateTitle(threadId: string, prompt: string, userId: string, config: ResolvedCortexAgentConfig): Promise<void>;
@@ -1,9 +1,9 @@
1
1
  import type { ResolvedContext } from "../graph/resolver.ts";
2
- import type { ResolvedCortexAgentConfig } from "../config.ts";
2
+ import type { PromptContext, ResolvedCortexAgentConfig } from "../config.ts";
3
3
  import type { Thread } from "../types.ts";
4
4
  /**
5
5
  * Resolves session data for the thread, loading from the configured
6
6
  * session loader if not already cached on the thread.
7
7
  */
8
8
  export declare function resolveSession(config: ResolvedCortexAgentConfig, thread: Thread, token: string): Promise<Record<string, unknown> | null>;
9
- export declare function buildSystemPrompt(config: ResolvedCortexAgentConfig, resolved: ResolvedContext | null, session: Record<string, unknown> | null): Promise<string>;
9
+ export declare function buildSystemPrompt(config: ResolvedCortexAgentConfig, resolved: ResolvedContext | null, promptContext: PromptContext): Promise<string>;
@@ -10,6 +10,10 @@ export type KnowledgeConfig = {
10
10
  };
11
11
  domains?: Record<string, DomainDef>;
12
12
  };
13
+ export type PromptContext = {
14
+ session: Record<string, unknown> | null;
15
+ requestContext: Record<string, unknown>;
16
+ };
13
17
  export type DatabaseConfig = {
14
18
  type: "mssql";
15
19
  connectionString: string;
@@ -23,7 +27,7 @@ export type StorageConfig = {
23
27
  bucketName?: string;
24
28
  };
25
29
  export type CortexAgentDefinition = {
26
- systemPrompt: string | ((session: Record<string, unknown> | null) => string | Promise<string>);
30
+ systemPrompt: string | ((context: PromptContext) => string | Promise<string>);
27
31
  tools?: ToolSet;
28
32
  backendFetch?: {
29
33
  baseUrl: string;
@@ -35,6 +39,7 @@ export type CortexAgentDefinition = {
35
39
  interceptor?: RequestInterceptorOptions;
36
40
  };
37
41
  loadSessionData?: (token: string) => Promise<Record<string, unknown>>;
42
+ resolveRequestContext?: (request: Request) => Record<string, unknown> | Promise<Record<string, unknown>>;
38
43
  onToolCall?: (toolCall: {
39
44
  toolName: string;
40
45
  toolCallId: string;
@@ -92,7 +97,7 @@ export type ResolvedCortexAgentConfig = {
92
97
  url: string;
93
98
  apiKey: string;
94
99
  };
95
- systemPrompt: string | ((session: Record<string, unknown> | null) => string | Promise<string>);
100
+ systemPrompt: string | ((context: PromptContext) => string | Promise<string>);
96
101
  tools?: ToolSet;
97
102
  backendFetch?: {
98
103
  baseUrl: string;
@@ -104,6 +109,7 @@ export type ResolvedCortexAgentConfig = {
104
109
  interceptor?: RequestInterceptorOptions;
105
110
  };
106
111
  loadSessionData?: (token: string) => Promise<Record<string, unknown>>;
112
+ resolveRequestContext?: (request: Request) => Record<string, unknown> | Promise<Record<string, unknown>>;
107
113
  onToolCall?: (toolCall: {
108
114
  toolName: string;
109
115
  toolCallId: string;
@@ -0,0 +1,2 @@
1
+ import type { DomainDef } from "./types.ts";
2
+ export declare function expandDomains(domains: Record<string, DomainDef>): Record<string, DomainDef>;
@@ -8,17 +8,20 @@ export declare function defineConcept(def: Omit<ConceptDef, "__brand">): {
8
8
  aliases?: string[] | undefined;
9
9
  parentConcept?: ConceptDef | undefined;
10
10
  governedBy?: RuleDef[] | undefined;
11
+ metadata?: Record<string, unknown> | undefined;
11
12
  __brand: "concept";
12
13
  };
13
14
  export declare function defineRule(def: Omit<RuleDef, "__brand">): {
14
15
  name: string;
15
16
  description: string;
17
+ metadata?: Record<string, unknown> | undefined;
16
18
  __brand: "rule";
17
19
  };
18
20
  export declare function defineService(def: Omit<ServiceDef, "__brand">): {
19
21
  name: string;
20
22
  description: string;
21
23
  governedBy?: RuleDef[] | undefined;
24
+ metadata?: Record<string, unknown> | undefined;
22
25
  builtInId: string;
23
26
  belongsTo: ConceptDef;
24
27
  __brand: "service";
@@ -26,6 +29,7 @@ export declare function defineService(def: Omit<ServiceDef, "__brand">): {
26
29
  export declare function defineDomain(def: Omit<DomainDef, "__brand">): {
27
30
  name: string;
28
31
  description: string;
32
+ metadata?: Record<string, unknown> | undefined;
29
33
  concepts?: ConceptDef[] | undefined;
30
34
  endpoints?: EndpointDef[] | undefined;
31
35
  services?: ServiceDef[] | undefined;
@@ -57,4 +61,5 @@ export declare function defineEndpoint(input: EndpointInput): {
57
61
  fromField: string;
58
62
  }[] | undefined;
59
63
  governedBy: RuleDef[] | undefined;
64
+ metadata: Record<string, unknown> | undefined;
60
65
  };
@@ -34,6 +34,7 @@ export type ResolvedEndpoint = {
34
34
  response: string;
35
35
  dependencies: EndpointDependency[];
36
36
  rules: string[];
37
+ metadata: string;
37
38
  };
38
39
  export type ResolvedService = {
39
40
  concept: string;
@@ -41,6 +42,7 @@ export type ResolvedService = {
41
42
  builtInId: string;
42
43
  description: string;
43
44
  rules: string[];
45
+ metadata: string;
44
46
  };
45
47
  export type ResolvedContext = {
46
48
  readEndpoints: ResolvedEndpoint[];
@@ -31,6 +31,7 @@ export type ConceptDef = {
31
31
  aliases?: string[];
32
32
  parentConcept?: ConceptDef;
33
33
  governedBy?: RuleDef[];
34
+ metadata?: Record<string, unknown>;
34
35
  };
35
36
  export type EndpointDef = {
36
37
  readonly __brand: "endpoint";
@@ -57,6 +58,7 @@ export type EndpointDef = {
57
58
  fromField: string;
58
59
  }[];
59
60
  governedBy?: RuleDef[];
61
+ metadata?: Record<string, unknown>;
60
62
  };
61
63
  export type EndpointInput = {
62
64
  name: string;
@@ -79,6 +81,7 @@ export type EndpointInput = {
79
81
  fromField: string;
80
82
  }[];
81
83
  governedBy?: RuleDef[];
84
+ metadata?: Record<string, unknown>;
82
85
  };
83
86
  export type ServiceDef = {
84
87
  readonly __brand: "service";
@@ -87,11 +90,13 @@ export type ServiceDef = {
87
90
  builtInId: string;
88
91
  belongsTo: ConceptDef;
89
92
  governedBy?: RuleDef[];
93
+ metadata?: Record<string, unknown>;
90
94
  };
91
95
  export type RuleDef = {
92
96
  readonly __brand: "rule";
93
97
  name: string;
94
98
  description: string;
99
+ metadata?: Record<string, unknown>;
95
100
  };
96
101
  export type DomainDef = {
97
102
  readonly __brand: "domain";
@@ -101,4 +106,5 @@ export type DomainDef = {
101
106
  endpoints?: EndpointDef[];
102
107
  services?: ServiceDef[];
103
108
  rules?: RuleDef[];
109
+ metadata?: Record<string, unknown>;
104
110
  };
@@ -1,4 +1,4 @@
1
- export type { CortexConfig, CortexAgentDefinition, KnowledgeConfig, DatabaseConfig, StorageConfig, } from "./config";
1
+ export type { CortexConfig, CortexAgentDefinition, KnowledgeConfig, DatabaseConfig, StorageConfig, PromptContext, } from "./config";
2
2
  export type { ContextConfig, ThreadContextMeta } from "./ai/context/types";
3
3
  export type { Thread, AppEnv } from "./types";
4
4
  export type { CortexInstance } from "./factory";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m6d/cortex-server",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Reusable AI agent chat server library for Hono + Bun",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/ai/index.ts CHANGED
@@ -36,6 +36,7 @@ export async function stream(
36
36
  thread: Thread,
37
37
  userId: string,
38
38
  token: string,
39
+ requestContext: Record<string, unknown>,
39
40
  config: ResolvedCortexAgentConfig,
40
41
  ) {
41
42
  const abortController = new AbortController();
@@ -111,7 +112,10 @@ export async function stream(
111
112
  ...config.tools,
112
113
  } as ToolSet;
113
114
 
114
- const systemPrompt = await buildSystemPrompt(config, resolved, session);
115
+ const systemPrompt = await buildSystemPrompt(config, resolved, {
116
+ session,
117
+ requestContext,
118
+ });
115
119
 
116
120
  // The context builder reserved a static token budget for the system prompt + tools.
117
121
  // Now that we have the actual values, verify the reserve was sufficient and trim
package/src/ai/prompt.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ResolvedContext } from "../graph/resolver.ts";
2
- import type { ResolvedCortexAgentConfig } from "../config.ts";
2
+ import type { PromptContext, ResolvedCortexAgentConfig } from "../config.ts";
3
3
  import type { Thread } from "../types.ts";
4
4
 
5
5
  /**
@@ -26,12 +26,12 @@ export async function resolveSession(
26
26
  export async function buildSystemPrompt(
27
27
  config: ResolvedCortexAgentConfig,
28
28
  resolved: ResolvedContext | null,
29
- session: Record<string, unknown> | null,
29
+ promptContext: PromptContext,
30
30
  ) {
31
31
  // Resolve the consumer's base system prompt
32
32
  let basePrompt: string;
33
33
  if (typeof config.systemPrompt === "function") {
34
- basePrompt = await config.systemPrompt(session);
34
+ basePrompt = await config.systemPrompt(promptContext);
35
35
  } else {
36
36
  basePrompt = config.systemPrompt;
37
37
  }
@@ -65,6 +65,7 @@ The following endpoints were automatically matched to the user's message.`,
65
65
  )
66
66
  .join("\n")
67
67
  : "";
68
+ const meta = ep.metadata !== "{}" ? `\n- Metadata: ${ep.metadata}` : "";
68
69
  parts.push(
69
70
  `
70
71
  ### ${ep.concept} (read)
@@ -72,7 +73,7 @@ The following endpoints were automatically matched to the user's message.`,
72
73
  - ${ep.method} ${ep.path}
73
74
  - Params: ${ep.params}
74
75
  - Body: ${ep.body}
75
- - Response: ${ep.response}${rules}${deps}`,
76
+ - Response: ${ep.response}${rules}${deps}${meta}`,
76
77
  );
77
78
  }
78
79
 
@@ -88,6 +89,7 @@ The following endpoints were automatically matched to the user's message.`,
88
89
  )
89
90
  .join("\n")
90
91
  : "";
92
+ const meta = ep.metadata !== "{}" ? `\n- Metadata: ${ep.metadata}` : "";
91
93
  parts.push(
92
94
  `
93
95
  ### ${ep.concept} (write)
@@ -95,17 +97,18 @@ The following endpoints were automatically matched to the user's message.`,
95
97
  - ${ep.method} ${ep.path}
96
98
  - Params: ${ep.params}
97
99
  - Body: ${ep.body}
98
- - Response: ${ep.response}${rules}${deps}`,
100
+ - Response: ${ep.response}${rules}${deps}${meta}`,
99
101
  );
100
102
  }
101
103
 
102
104
  for (const svc of resolved.services) {
103
105
  const rules = svc.rules.length > 0 ? `\n Rules: ${svc.rules.join("; ")}` : "";
106
+ const meta = svc.metadata !== "{}" ? `\n- Metadata: ${svc.metadata}` : "";
104
107
  parts.push(
105
108
  `
106
109
  ### ${svc.concept} via ${svc.serviceName} (service)
107
110
  - Built-in ID: ${svc.builtInId}
108
- - Description: ${svc.description || "N/A"}${rules}`,
111
+ - Description: ${svc.description || "N/A"}${rules}${meta}`,
109
112
  );
110
113
  }
111
114
 
package/src/config.ts CHANGED
@@ -10,6 +10,11 @@ export type KnowledgeConfig = {
10
10
  domains?: Record<string, DomainDef>;
11
11
  };
12
12
 
13
+ export type PromptContext = {
14
+ session: Record<string, unknown> | null;
15
+ requestContext: Record<string, unknown>;
16
+ };
17
+
13
18
  export type DatabaseConfig = {
14
19
  type: "mssql";
15
20
  connectionString: string;
@@ -25,7 +30,7 @@ export type StorageConfig = {
25
30
  };
26
31
 
27
32
  export type CortexAgentDefinition = {
28
- systemPrompt: string | ((session: Record<string, unknown> | null) => string | Promise<string>);
33
+ systemPrompt: string | ((context: PromptContext) => string | Promise<string>);
29
34
  tools?: ToolSet;
30
35
  backendFetch?: {
31
36
  baseUrl: string;
@@ -38,6 +43,9 @@ export type CortexAgentDefinition = {
38
43
  interceptor?: RequestInterceptorOptions;
39
44
  };
40
45
  loadSessionData?: (token: string) => Promise<Record<string, unknown>>;
46
+ resolveRequestContext?: (
47
+ request: Request,
48
+ ) => Record<string, unknown> | Promise<Record<string, unknown>>;
41
49
  onToolCall?: (toolCall: {
42
50
  toolName: string;
43
51
  toolCallId: string;
@@ -93,7 +101,7 @@ export type ResolvedCortexAgentConfig = {
93
101
  url: string;
94
102
  apiKey: string;
95
103
  };
96
- systemPrompt: string | ((session: Record<string, unknown> | null) => string | Promise<string>);
104
+ systemPrompt: string | ((context: PromptContext) => string | Promise<string>);
97
105
  tools?: ToolSet;
98
106
  backendFetch?: {
99
107
  baseUrl: string;
@@ -106,6 +114,9 @@ export type ResolvedCortexAgentConfig = {
106
114
  interceptor?: RequestInterceptorOptions;
107
115
  };
108
116
  loadSessionData?: (token: string) => Promise<Record<string, unknown>>;
117
+ resolveRequestContext?: (
118
+ request: Request,
119
+ ) => Record<string, unknown> | Promise<Record<string, unknown>>;
109
120
  onToolCall?: (toolCall: {
110
121
  toolName: string;
111
122
  toolCallId: string;
package/src/factory.ts CHANGED
@@ -95,6 +95,7 @@ export function createCortex(config: CortexConfig) {
95
95
  tools: agentDef.tools,
96
96
  backendFetch: agentDef.backendFetch,
97
97
  loadSessionData: agentDef.loadSessionData,
98
+ resolveRequestContext: agentDef.resolveRequestContext,
98
99
  onToolCall: agentDef.onToolCall,
99
100
  onStreamFinish: agentDef.onStreamFinish,
100
101
  context: resolvedContext,
@@ -0,0 +1,276 @@
1
+ import type { ConceptDef, DomainDef, EndpointDef, RuleDef, ServiceDef } from "./types.ts";
2
+
3
+ type DomainCollections = {
4
+ concepts: ConceptDef[];
5
+ endpoints: EndpointDef[];
6
+ services: ServiceDef[];
7
+ rules: RuleDef[];
8
+ };
9
+
10
+ type OwnershipMaps = {
11
+ concepts: WeakMap<ConceptDef, string>;
12
+ endpoints: WeakMap<EndpointDef, string>;
13
+ services: WeakMap<ServiceDef, string>;
14
+ rules: WeakMap<RuleDef, string>;
15
+ };
16
+
17
+ export function expandDomains(domains: Record<string, DomainDef>) {
18
+ const ownership = createOwnershipMaps(domains);
19
+ const expanded: Record<string, DomainDef> = {};
20
+
21
+ for (const [domainKey, domain] of Object.entries(domains)) {
22
+ const collections = cloneCollections(domain);
23
+ const pendingConcepts = [...collections.concepts];
24
+ const pendingEndpoints = [...collections.endpoints];
25
+ const pendingServices = [...collections.services];
26
+
27
+ while (
28
+ pendingConcepts.length > 0 ||
29
+ pendingEndpoints.length > 0 ||
30
+ pendingServices.length > 0
31
+ ) {
32
+ const concept = pendingConcepts.pop();
33
+ if (concept) {
34
+ includeConcept({
35
+ domainKey,
36
+ concept,
37
+ ownership,
38
+ collections,
39
+ pendingConcepts,
40
+ pendingEndpoints,
41
+ });
42
+ }
43
+
44
+ const endpoint = pendingEndpoints.pop();
45
+ if (endpoint) {
46
+ includeEndpoint({
47
+ domainKey,
48
+ endpoint,
49
+ ownership,
50
+ collections,
51
+ pendingConcepts,
52
+ pendingEndpoints,
53
+ });
54
+ }
55
+
56
+ const service = pendingServices.pop();
57
+ if (service) {
58
+ includeService({
59
+ domainKey,
60
+ service,
61
+ ownership,
62
+ collections,
63
+ pendingConcepts,
64
+ });
65
+ }
66
+ }
67
+
68
+ expanded[domainKey] = {
69
+ ...domain,
70
+ concepts: collections.concepts,
71
+ endpoints: collections.endpoints,
72
+ services: collections.services,
73
+ rules: collections.rules,
74
+ };
75
+ }
76
+
77
+ return expanded;
78
+ }
79
+
80
+ function createOwnershipMaps(domains: Record<string, DomainDef>) {
81
+ const ownership: OwnershipMaps = {
82
+ concepts: new WeakMap<ConceptDef, string>(),
83
+ endpoints: new WeakMap<EndpointDef, string>(),
84
+ services: new WeakMap<ServiceDef, string>(),
85
+ rules: new WeakMap<RuleDef, string>(),
86
+ };
87
+
88
+ for (const [domainKey, domain] of Object.entries(domains)) {
89
+ for (const concept of domain.concepts ?? []) {
90
+ assignOwner(ownership.concepts, concept, domainKey);
91
+ }
92
+
93
+ for (const endpoint of domain.endpoints ?? []) {
94
+ assignOwner(ownership.endpoints, endpoint, domainKey);
95
+ }
96
+
97
+ for (const service of domain.services ?? []) {
98
+ assignOwner(ownership.services, service, domainKey);
99
+ }
100
+
101
+ for (const rule of domain.rules ?? []) {
102
+ assignOwner(ownership.rules, rule, domainKey);
103
+ }
104
+ }
105
+
106
+ return ownership;
107
+ }
108
+
109
+ function assignOwner<T extends object>(owners: WeakMap<T, string>, value: T, domainKey: string) {
110
+ if (!owners.has(value)) {
111
+ owners.set(value, domainKey);
112
+ }
113
+ }
114
+
115
+ function cloneCollections(domain: DomainDef) {
116
+ return {
117
+ concepts: [...(domain.concepts ?? [])],
118
+ endpoints: [...(domain.endpoints ?? [])],
119
+ services: [...(domain.services ?? [])],
120
+ rules: [...(domain.rules ?? [])],
121
+ } satisfies DomainCollections;
122
+ }
123
+
124
+ function includeConcept(options: {
125
+ domainKey: string;
126
+ concept: ConceptDef;
127
+ ownership: OwnershipMaps;
128
+ collections: DomainCollections;
129
+ pendingConcepts: ConceptDef[];
130
+ pendingEndpoints: EndpointDef[];
131
+ }) {
132
+ const { domainKey, concept, ownership, collections, pendingConcepts, pendingEndpoints } =
133
+ options;
134
+
135
+ includeRule(domainKey, concept.governedBy ?? [], ownership, collections);
136
+
137
+ if (shouldAttach(ownership.concepts, concept, domainKey)) {
138
+ pushUnique(collections.concepts, concept);
139
+ }
140
+
141
+ if (
142
+ concept.parentConcept &&
143
+ shouldAttach(ownership.concepts, concept.parentConcept, domainKey)
144
+ ) {
145
+ if (pushUnique(collections.concepts, concept.parentConcept)) {
146
+ pendingConcepts.push(concept.parentConcept);
147
+ }
148
+ }
149
+
150
+ for (const endpoint of collections.endpoints) {
151
+ if (referencesConcept(endpoint, concept)) {
152
+ includeEndpoint({
153
+ domainKey,
154
+ endpoint,
155
+ ownership,
156
+ collections,
157
+ pendingConcepts,
158
+ pendingEndpoints,
159
+ });
160
+ }
161
+ }
162
+ }
163
+
164
+ function includeEndpoint(options: {
165
+ domainKey: string;
166
+ endpoint: EndpointDef;
167
+ ownership: OwnershipMaps;
168
+ collections: DomainCollections;
169
+ pendingConcepts: ConceptDef[];
170
+ pendingEndpoints: EndpointDef[];
171
+ }) {
172
+ const { domainKey, endpoint, ownership, collections, pendingConcepts, pendingEndpoints } =
173
+ options;
174
+
175
+ if (shouldAttach(ownership.endpoints, endpoint, domainKey)) {
176
+ pushUnique(collections.endpoints, endpoint);
177
+ }
178
+
179
+ includeRule(domainKey, endpoint.governedBy ?? [], ownership, collections);
180
+
181
+ for (const concept of endpoint.queries ?? []) {
182
+ if (
183
+ shouldAttach(ownership.concepts, concept, domainKey) &&
184
+ pushUnique(collections.concepts, concept)
185
+ ) {
186
+ pendingConcepts.push(concept);
187
+ }
188
+ }
189
+
190
+ for (const concept of endpoint.mutates ?? []) {
191
+ if (
192
+ shouldAttach(ownership.concepts, concept, domainKey) &&
193
+ pushUnique(collections.concepts, concept)
194
+ ) {
195
+ pendingConcepts.push(concept);
196
+ }
197
+ }
198
+
199
+ for (const returned of endpoint.returns ?? []) {
200
+ if (
201
+ shouldAttach(ownership.concepts, returned.concept, domainKey) &&
202
+ pushUnique(collections.concepts, returned.concept)
203
+ ) {
204
+ pendingConcepts.push(returned.concept);
205
+ }
206
+ }
207
+
208
+ for (const dependency of endpoint.dependsOn ?? []) {
209
+ if (
210
+ shouldAttach(ownership.endpoints, dependency.endpoint, domainKey) &&
211
+ pushUnique(collections.endpoints, dependency.endpoint)
212
+ ) {
213
+ pendingEndpoints.push(dependency.endpoint);
214
+ }
215
+ }
216
+ }
217
+
218
+ function includeService(options: {
219
+ domainKey: string;
220
+ service: ServiceDef;
221
+ ownership: OwnershipMaps;
222
+ collections: DomainCollections;
223
+ pendingConcepts: ConceptDef[];
224
+ }) {
225
+ const { domainKey, service, ownership, collections, pendingConcepts } = options;
226
+
227
+ if (shouldAttach(ownership.services, service, domainKey)) {
228
+ pushUnique(collections.services, service);
229
+ }
230
+
231
+ includeRule(domainKey, service.governedBy ?? [], ownership, collections);
232
+
233
+ if (
234
+ shouldAttach(ownership.concepts, service.belongsTo, domainKey) &&
235
+ pushUnique(collections.concepts, service.belongsTo)
236
+ ) {
237
+ pendingConcepts.push(service.belongsTo);
238
+ }
239
+ }
240
+
241
+ function includeRule(
242
+ domainKey: string,
243
+ rules: readonly RuleDef[],
244
+ ownership: OwnershipMaps,
245
+ collections: DomainCollections,
246
+ ) {
247
+ for (const rule of rules) {
248
+ if (shouldAttach(ownership.rules, rule, domainKey)) {
249
+ pushUnique(collections.rules, rule);
250
+ }
251
+ }
252
+ }
253
+
254
+ function shouldAttach<T extends object>(owners: WeakMap<T, string>, value: T, domainKey: string) {
255
+ const owner = owners.get(value);
256
+ return owner == null || owner === domainKey;
257
+ }
258
+
259
+ function pushUnique<T extends object>(values: T[], value: T) {
260
+ if (values.includes(value)) {
261
+ return false;
262
+ }
263
+
264
+ values.push(value);
265
+ return true;
266
+ }
267
+
268
+ function referencesConcept(endpoint: EndpointDef, concept: ConceptDef) {
269
+ return (
270
+ endpoint.queries?.includes(concept) ||
271
+ endpoint.mutates?.includes(concept) ||
272
+ endpoint.returns?.some(function (returned) {
273
+ return returned.concept === concept;
274
+ })
275
+ );
276
+ }
@@ -31,9 +31,12 @@ export function generateCypher(data: DomainDef) {
31
31
  const edges: string[] = [];
32
32
 
33
33
  // -- Domain node --
34
+ const domainMeta = data.metadata
35
+ ? `,\n d.metadata = '${jsonStr(data.metadata)}'`
36
+ : "";
34
37
  nodes.push(
35
38
  `MERGE (d:Domain {name: '${esc(data.name)}'})
36
- SET d.description = '${esc(data.description)}'`,
39
+ SET d.description = '${esc(data.description)}'${domainMeta}`,
37
40
  );
38
41
 
39
42
  // -- Concept nodes --
@@ -42,10 +45,11 @@ export function generateCypher(data: DomainDef) {
42
45
  c.aliases && c.aliases.length > 0
43
46
  ? `, c.aliases = [${c.aliases.map((a) => `'${esc(a)}'`).join(", ")}]`
44
47
  : "";
48
+ const conceptMeta = c.metadata ? `, c.metadata = '${jsonStr(c.metadata)}'` : "";
45
49
 
46
50
  nodes.push(
47
51
  `MERGE (c:Concept {name: '${esc(c.name)}'})
48
- SET c.description = '${esc(c.description)}'${aliasClause}`,
52
+ SET c.description = '${esc(c.description)}'${aliasClause}${conceptMeta}`,
49
53
  );
50
54
 
51
55
  edges.push(
@@ -73,6 +77,9 @@ export function generateCypher(data: DomainDef) {
73
77
 
74
78
  // -- Endpoint nodes --
75
79
  for (const ep of data.endpoints ?? []) {
80
+ const endpointMeta = ep.metadata
81
+ ? `,\n e.metadata = '${jsonStr(ep.metadata)}'`
82
+ : "";
76
83
  nodes.push(
77
84
  `MERGE (e:Endpoint {path: '${esc(ep.path)}', method: '${ep.method}'})
78
85
  SET e.name = '${esc(ep.name)}',
@@ -82,7 +89,7 @@ export function generateCypher(data: DomainDef) {
82
89
  e.response = '${jsonStr(ep.response)}',
83
90
  e.propertiesDescriptions = '${jsonStr(ep.propertiesDescriptions)}',
84
91
  e.successStatus = ${ep.successStatus},
85
- e.errorStatuses = [${ep.errorStatuses.join(", ")}]`,
92
+ e.errorStatuses = [${ep.errorStatuses.join(", ")}]${endpointMeta}`,
86
93
  );
87
94
 
88
95
  for (const concept of ep.queries ?? []) {
@@ -131,10 +138,13 @@ export function generateCypher(data: DomainDef) {
131
138
 
132
139
  // -- Service nodes --
133
140
  for (const svc of data.services ?? []) {
141
+ const serviceMeta = svc.metadata
142
+ ? `,\n s.metadata = '${jsonStr(svc.metadata)}'`
143
+ : "";
134
144
  nodes.push(
135
145
  `MERGE (s:Service {builtInId: '${esc(svc.builtInId)}'})
136
146
  SET s.name = '${esc(svc.name)}',
137
- s.description = '${esc(svc.description)}'`,
147
+ s.description = '${esc(svc.description)}'${serviceMeta}`,
138
148
  );
139
149
 
140
150
  edges.push(
@@ -154,9 +164,12 @@ export function generateCypher(data: DomainDef) {
154
164
 
155
165
  // -- Rule nodes --
156
166
  for (const rule of data.rules ?? []) {
167
+ const ruleMeta = rule.metadata
168
+ ? `,\n r.metadata = '${jsonStr(rule.metadata)}'`
169
+ : "";
157
170
  nodes.push(
158
171
  `MERGE (r:Rule {name: '${esc(rule.name)}'})
159
- SET r.description = '${esc(rule.description)}'`,
172
+ SET r.description = '${esc(rule.description)}'${ruleMeta}`,
160
173
  );
161
174
  }
162
175
 
@@ -64,5 +64,6 @@ export function defineEndpoint(input: EndpointInput) {
64
64
  returns: input.returns,
65
65
  dependsOn: input.dependsOn,
66
66
  governedBy: input.governedBy,
67
+ metadata: input.metadata,
67
68
  } satisfies EndpointDef;
68
69
  }
@@ -50,6 +50,7 @@ export type ResolvedEndpoint = {
50
50
  response: string;
51
51
  dependencies: EndpointDependency[];
52
52
  rules: string[];
53
+ metadata: string;
53
54
  };
54
55
 
55
56
  export type ResolvedService = {
@@ -58,6 +59,7 @@ export type ResolvedService = {
58
59
  builtInId: string;
59
60
  description: string;
60
61
  rules: string[];
62
+ metadata: string;
61
63
  };
62
64
 
63
65
  export type ResolvedContext = {
@@ -79,6 +81,7 @@ type EndpointRow = {
79
81
  params: string | null;
80
82
  body: string | null;
81
83
  response: string | null;
84
+ metadata: string | null;
82
85
  dependencies: (EndpointDependency | { depPath: null })[];
83
86
  rules: (string | null)[];
84
87
  };
@@ -88,6 +91,7 @@ type ServiceRow = {
88
91
  serviceName: string;
89
92
  builtInId: string;
90
93
  description: string | null;
94
+ metadata: string | null;
91
95
  rules: (string | null)[];
92
96
  };
93
97
 
@@ -158,6 +162,7 @@ export async function resolveFromGraph(prompt: string, config: ResolverConfig) {
158
162
  e.params AS params,
159
163
  e.body AS body,
160
164
  e.response AS response,
165
+ e.metadata AS metadata,
161
166
  dependencies,
162
167
  collect(DISTINCT rEndpoint.description) +
163
168
  collect(DISTINCT rConcept.description) AS rules`,
@@ -177,6 +182,7 @@ export async function resolveFromGraph(prompt: string, config: ResolverConfig) {
177
182
  params: row.params ?? "[]",
178
183
  body: row.body ?? "[]",
179
184
  response: row.response ?? "[]",
185
+ metadata: row.metadata ?? "{}",
180
186
  dependencies: row.dependencies.filter(
181
187
  (dep): dep is EndpointDependency => dep.depPath != null,
182
188
  ),
@@ -198,6 +204,7 @@ export async function resolveFromGraph(prompt: string, config: ResolverConfig) {
198
204
  svc.name AS serviceName,
199
205
  svc.builtInId AS builtInId,
200
206
  svc.description AS description,
207
+ svc.metadata AS metadata,
201
208
  collect(DISTINCT rService.description) +
202
209
  collect(DISTINCT rConcept.description) AS rules`,
203
210
  { names: conceptNames },
@@ -210,6 +217,7 @@ export async function resolveFromGraph(prompt: string, config: ResolverConfig) {
210
217
  serviceName: row.serviceName,
211
218
  builtInId: row.builtInId,
212
219
  description: row.description ?? "",
220
+ metadata: row.metadata ?? "{}",
213
221
  rules: row.rules.filter((rule): rule is string => Boolean(rule)),
214
222
  };
215
223
  }),
@@ -239,6 +247,7 @@ export async function resolveFromGraph(prompt: string, config: ResolverConfig) {
239
247
  e.params AS params,
240
248
  e.body AS body,
241
249
  e.response AS response,
250
+ e.metadata AS metadata,
242
251
  dependencies,
243
252
  collect(DISTINCT rEndpoint.description) +
244
253
  collect(DISTINCT rConcept.description) AS rules`,
@@ -259,6 +268,7 @@ export async function resolveFromGraph(prompt: string, config: ResolverConfig) {
259
268
  params: row.params ?? "[]",
260
269
  body: row.body ?? "[]",
261
270
  response: row.response ?? "[]",
271
+ metadata: row.metadata ?? "{}",
262
272
  dependencies: row.dependencies.filter(
263
273
  (dep): dep is EndpointDependency => dep.depPath != null,
264
274
  ),
package/src/graph/seed.ts CHANGED
@@ -10,6 +10,7 @@ import type { EmbeddingModel } from "ai";
10
10
  import { embed } from "ai";
11
11
  import type { Neo4jClient, Neo4jConfig } from "./neo4j.ts";
12
12
  import { createNeo4jClient } from "./neo4j.ts";
13
+ import { expandDomains } from "./expand-domains.ts";
13
14
  import { generateCypher, type GeneratedCypher } from "./generate-cypher.ts";
14
15
  import type { DomainDef } from "./types.ts";
15
16
  import { validateDomain } from "./validate.ts";
@@ -122,8 +123,10 @@ async function generateEmbeddings(client: Neo4jClient, embeddingConfig: Embeddin
122
123
  // ---------------------------------------------------------------------------
123
124
 
124
125
  export async function seedGraph(config: SeedGraphConfig, domains: Record<string, DomainDef>) {
126
+ const expandedDomains = expandDomains(domains);
127
+
125
128
  // Validate all domains
126
- const validationErrors = Object.entries(domains).flatMap(([name, domain]) =>
129
+ const validationErrors = Object.entries(expandedDomains).flatMap(([name, domain]) =>
127
130
  validateDomain(domain).map((err) => `[${name}] ${err}`),
128
131
  );
129
132
 
@@ -133,7 +136,7 @@ export async function seedGraph(config: SeedGraphConfig, domains: Record<string,
133
136
  }
134
137
 
135
138
  const client = createNeo4jClient(config.neo4j, config.embedding.model);
136
- const targets = Object.entries(domains);
139
+ const targets = Object.entries(expandedDomains);
137
140
 
138
141
  console.log(`Seeding ${targets.length} domain(s)...`);
139
142
 
@@ -52,6 +52,7 @@ export type ConceptDef = {
52
52
  aliases?: string[];
53
53
  parentConcept?: ConceptDef;
54
54
  governedBy?: RuleDef[];
55
+ metadata?: Record<string, unknown>;
55
56
  };
56
57
 
57
58
  // ---------------------------------------------------------------------------
@@ -84,6 +85,7 @@ export type EndpointDef = {
84
85
  }[];
85
86
 
86
87
  governedBy?: RuleDef[];
88
+ metadata?: Record<string, unknown>;
87
89
  };
88
90
 
89
91
  // ---------------------------------------------------------------------------
@@ -112,6 +114,7 @@ export type EndpointInput = {
112
114
  }[];
113
115
 
114
116
  governedBy?: RuleDef[];
117
+ metadata?: Record<string, unknown>;
115
118
  };
116
119
 
117
120
  // ---------------------------------------------------------------------------
@@ -125,6 +128,7 @@ export type ServiceDef = {
125
128
  builtInId: string;
126
129
  belongsTo: ConceptDef;
127
130
  governedBy?: RuleDef[];
131
+ metadata?: Record<string, unknown>;
128
132
  };
129
133
 
130
134
  // ---------------------------------------------------------------------------
@@ -135,6 +139,7 @@ export type RuleDef = {
135
139
  readonly __brand: "rule";
136
140
  name: string;
137
141
  description: string;
142
+ metadata?: Record<string, unknown>;
138
143
  };
139
144
 
140
145
  // ---------------------------------------------------------------------------
@@ -149,4 +154,5 @@ export type DomainDef = {
149
154
  endpoints?: EndpointDef[];
150
155
  services?: ServiceDef[];
151
156
  rules?: RuleDef[];
157
+ metadata?: Record<string, unknown>;
152
158
  };
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export type {
5
5
  KnowledgeConfig,
6
6
  DatabaseConfig,
7
7
  StorageConfig,
8
+ PromptContext,
8
9
  } from "./config";
9
10
 
10
11
  export type { ContextConfig, ThreadContextMeta } from "./ai/context/types";
@@ -27,6 +27,9 @@ export function createChatRoutes() {
27
27
  const agentId = c.get("agentId");
28
28
  const { id: userId, token } = c.get("user");
29
29
  const { messages, id: threadId } = c.req.valid("json");
30
+ const requestContext = config.resolveRequestContext
31
+ ? await config.resolveRequestContext(c.req.raw)
32
+ : {};
30
33
 
31
34
  const thread = await config.db.threads.getById(userId, threadId);
32
35
 
@@ -34,7 +37,7 @@ export function createChatRoutes() {
34
37
  throw new HTTPException(404, { message: "Not found" });
35
38
  }
36
39
 
37
- return stream(messages, thread, userId, token, config);
40
+ return stream(messages, thread, userId, token, requestContext, config);
38
41
  },
39
42
  );
40
43
 
@@ -1 +0,0 @@
1
- export {};
@@ -1,21 +0,0 @@
1
- import { afterEach, describe, expect, it } from "bun:test";
2
- import { registerStream, removeStream, subscribe } from "./active-streams.ts";
3
-
4
- describe("active-streams", function () {
5
- afterEach(function () {
6
- removeStream("thread-under-test");
7
- });
8
-
9
- it("does not remove a newer stream when cleaning up an older one", function () {
10
- const firstStream = registerStream("thread-under-test", new AbortController());
11
- const secondStream = registerStream("thread-under-test", new AbortController());
12
-
13
- removeStream("thread-under-test", firstStream.id);
14
-
15
- expect(subscribe("thread-under-test")).not.toBeNull();
16
-
17
- removeStream("thread-under-test", secondStream.id);
18
-
19
- expect(subscribe("thread-under-test")).toBeNull();
20
- });
21
- });