@kadi.build/core 0.12.0 → 0.13.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
@@ -206,6 +206,27 @@ const client = new KadiClient({
206
206
  });
207
207
  ```
208
208
 
209
+ **Tool snapshot on reconnection:** When an agent first registers with a broker, kadi-core captures a snapshot of the tools sent during that initial registration. On every subsequent reconnection, the same snapshot is replayed — tools that were registered locally after the initial connection (e.g. via `loadNative`) are **not** leaked to the broker.
210
+
211
+ This means abilities that use the native-mode pattern (connect first, then register tools locally) will correctly announce zero tools to the broker on reconnection, just as they did on the initial connect.
212
+
213
+ If you dynamically register new tools after the initial connection and want the broker to know about them, call `refreshBrokerTools()`:
214
+
215
+ ```typescript
216
+ // Register a new tool after the initial connection
217
+ client.registerTool({
218
+ name: 'new-dynamic-tool',
219
+ description: 'Added at runtime',
220
+ input: z.object({ data: z.string() }),
221
+ }, async ({ data }) => ({ result: data }));
222
+
223
+ // Recapture the tool snapshot and re-announce to the broker
224
+ await client.refreshBrokerTools();
225
+
226
+ // Or target a specific broker
227
+ await client.refreshBrokerTools('production');
228
+ ```
229
+
209
230
  ---
210
231
 
211
232
  ## Loading Abilities
@@ -745,6 +766,7 @@ new KadiClient(config: ClientConfig)
745
766
  | `publish(channel, data, options?)` | Publish event to broker |
746
767
  | `emit(event, data)` | Emit event to consumer (when serving as ability) |
747
768
  | `serve(mode)` | Serve as ability (`'stdio'` or `'broker'`) |
769
+ | `refreshBrokerTools(broker?)` | Recapture tool snapshot and re-announce to broker. See [Reconnection](#reconnection) |
748
770
  | `getConnectedBrokers()` | List connected broker names |
749
771
 
750
772
  **Properties:**
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Configuration loader for KADI agents and abilities.
3
+ *
4
+ * Provides standardized config.yml discovery with a 2-tier fallback:
5
+ * 1. **Project-level** — walk up from startDir looking for config.yml
6
+ * 2. **Global-level** — ~/.kadi/config.yml (shared KADI infrastructure settings)
7
+ *
8
+ * Resolution order per-field (highest-priority first):
9
+ * 1. Environment variables (PREFIX_KEY)
10
+ * 2. Project config.yml section
11
+ * 3. Global config.yml section (~/.kadi/config.yml)
12
+ * 4. Built-in defaults
13
+ *
14
+ * Secrets (API keys, tokens) should NEVER appear in config.yml.
15
+ * Use secret-ability / kadi-secret for credentials.
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * import { loadConfig } from '@kadi.build/core';
20
+ *
21
+ * const { config, configPath, source } = loadConfig({
22
+ * section: 'memory',
23
+ * envPrefix: 'MEMORY',
24
+ * defaults: { database: 'kadi_memory', embedding_model: 'text-embedding-3-small' },
25
+ * });
26
+ * ```
27
+ *
28
+ * TypeScript equivalent of: kadi-core-py/src/kadi/config.py
29
+ */
30
+ /** How the config file was discovered. */
31
+ export type ConfigSource = 'project' | 'global' | 'default';
32
+ /** Options for {@link loadConfig}. */
33
+ export interface LoadConfigOptions {
34
+ /**
35
+ * YAML section key to extract (e.g. `'tunnel'`, `'memory'`, `'kadi_agent'`).
36
+ * The top-level key in config.yml whose value becomes the config object.
37
+ */
38
+ section: string;
39
+ /**
40
+ * Directory to start the upward walk from.
41
+ * @default process.cwd()
42
+ */
43
+ startDir?: string;
44
+ /**
45
+ * Config filename to search for.
46
+ * @default 'config.yml'
47
+ */
48
+ filename?: string;
49
+ /**
50
+ * Environment variable prefix for automatic overrides.
51
+ *
52
+ * When set, loadConfig will scan `process.env` for keys starting with
53
+ * `PREFIX_` and merge them into the config (highest priority).
54
+ *
55
+ * Mapping: `PREFIX_KEY_NAME` → config field `key_name` (lowercased).
56
+ *
57
+ * @example envPrefix: 'MEMORY' → MEMORY_DATABASE overrides config.database
58
+ */
59
+ envPrefix?: string;
60
+ /**
61
+ * Built-in defaults (lowest priority).
62
+ * These are used when neither env vars nor config.yml provide a value.
63
+ */
64
+ defaults?: Record<string, unknown>;
65
+ }
66
+ /** Result of {@link loadConfig}. */
67
+ export interface ConfigResult<T = Record<string, unknown>> {
68
+ /** Merged config object: env > project config.yml > global config.yml > defaults. */
69
+ config: T;
70
+ /** Absolute path to the config.yml that was loaded, or null if none found. */
71
+ configPath: string | null;
72
+ /** Where the primary config was found. */
73
+ source: ConfigSource;
74
+ }
75
+ /**
76
+ * Walk up the directory tree looking for a file.
77
+ *
78
+ * @param filename - File to search for (default: `'config.yml'`)
79
+ * @param startDir - Directory to start from (default: `process.cwd()`)
80
+ * @returns Absolute path if found, `null` otherwise.
81
+ */
82
+ export declare function findConfigFile(filename?: string, startDir?: string): string | null;
83
+ /**
84
+ * Find the global KADI config file.
85
+ *
86
+ * @param filename - Config filename (default: `'config.yml'`)
87
+ * @returns Absolute path to `~/.kadi/<filename>` if it exists, `null` otherwise.
88
+ */
89
+ export declare function findGlobalConfigFile(filename?: string): string | null;
90
+ /**
91
+ * Load configuration for a KADI agent or ability.
92
+ *
93
+ * Resolution order per-field (highest-priority first):
94
+ * 1. Environment variables (`envPrefix` + `_KEY`)
95
+ * 2. Project `config.yml` section (walk-up from startDir)
96
+ * 3. Global `~/.kadi/config.yml` section
97
+ * 4. Built-in `defaults`
98
+ *
99
+ * @typeParam T - The shape of the merged config object.
100
+ * @param options - Configuration loading options.
101
+ * @returns Merged config with metadata about where it was found.
102
+ *
103
+ * @example
104
+ * ```typescript
105
+ * const { config } = loadConfig<TunnelConfig>({
106
+ * section: 'tunnel',
107
+ * envPrefix: 'KADI_TUNNEL',
108
+ * defaults: { default_service: 'kadi', server_port: 7000 },
109
+ * });
110
+ * ```
111
+ */
112
+ export declare function loadConfig<T = Record<string, unknown>>(options: LoadConfigOptions): ConfigResult<T>;
113
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAuDH,0CAA0C;AAC1C,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,QAAQ,GAAG,SAAS,CAAC;AAE5D,sCAAsC;AACtC,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,OAAO,EAAE,MAAM,CAAC;IAEhB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;;;;;;;OASG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED,oCAAoC;AACpC,MAAM,WAAW,YAAY,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACvD,qFAAqF;IACrF,MAAM,EAAE,CAAC,CAAC;IACV,8EAA8E;IAC9E,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,0CAA0C;IAC1C,MAAM,EAAE,YAAY,CAAC;CACtB;AAMD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,GAAE,MAAyB,EACnC,QAAQ,CAAC,EAAE,MAAM,GAChB,MAAM,GAAG,IAAI,CAmBf;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,GAAE,MAAyB,GAClC,MAAM,GAAG,IAAI,CAUf;AAgFD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,UAAU,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACpD,OAAO,EAAE,iBAAiB,GACzB,YAAY,CAAC,CAAC,CAAC,CAsDjB"}
package/dist/config.js ADDED
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Configuration loader for KADI agents and abilities.
3
+ *
4
+ * Provides standardized config.yml discovery with a 2-tier fallback:
5
+ * 1. **Project-level** — walk up from startDir looking for config.yml
6
+ * 2. **Global-level** — ~/.kadi/config.yml (shared KADI infrastructure settings)
7
+ *
8
+ * Resolution order per-field (highest-priority first):
9
+ * 1. Environment variables (PREFIX_KEY)
10
+ * 2. Project config.yml section
11
+ * 3. Global config.yml section (~/.kadi/config.yml)
12
+ * 4. Built-in defaults
13
+ *
14
+ * Secrets (API keys, tokens) should NEVER appear in config.yml.
15
+ * Use secret-ability / kadi-secret for credentials.
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * import { loadConfig } from '@kadi.build/core';
20
+ *
21
+ * const { config, configPath, source } = loadConfig({
22
+ * section: 'memory',
23
+ * envPrefix: 'MEMORY',
24
+ * defaults: { database: 'kadi_memory', embedding_model: 'text-embedding-3-small' },
25
+ * });
26
+ * ```
27
+ *
28
+ * TypeScript equivalent of: kadi-core-py/src/kadi/config.py
29
+ */
30
+ import * as fs from 'node:fs';
31
+ import * as path from 'node:path';
32
+ import * as os from 'node:os';
33
+ // ═══════════════════════════════════════════════════════════════════════
34
+ // YAML import — optional runtime dependency
35
+ // ═══════════════════════════════════════════════════════════════════════
36
+ /**
37
+ * We lazy-import js-yaml so that kadi-core does NOT require it as a hard
38
+ * dependency. Consumers that use loadConfig() need js-yaml installed;
39
+ * everything else in kadi-core works without it.
40
+ *
41
+ * In practice every KADI agent/ability already has js-yaml, so this is
42
+ * a formality rather than a real constraint.
43
+ */
44
+ let yaml = null;
45
+ let yamlResolved = false;
46
+ function requireYaml() {
47
+ if (!yamlResolved) {
48
+ yamlResolved = true;
49
+ try {
50
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
51
+ yaml = require('js-yaml');
52
+ }
53
+ catch {
54
+ try {
55
+ // ESM fallback — createRequire for pure-ESM environments
56
+ const { createRequire } = require('node:module');
57
+ const localRequire = createRequire(process.cwd() + '/');
58
+ yaml = localRequire('js-yaml');
59
+ }
60
+ catch {
61
+ yaml = null;
62
+ }
63
+ }
64
+ }
65
+ return yaml;
66
+ }
67
+ // ═══════════════════════════════════════════════════════════════════════
68
+ // Constants
69
+ // ═══════════════════════════════════════════════════════════════════════
70
+ /** Default config filename. */
71
+ const DEFAULT_FILENAME = 'config.yml';
72
+ /** Global KADI config directory. */
73
+ const GLOBAL_KADI_DIR = path.join(os.homedir(), '.kadi');
74
+ // ═══════════════════════════════════════════════════════════════════════
75
+ // File discovery
76
+ // ═══════════════════════════════════════════════════════════════════════
77
+ /**
78
+ * Walk up the directory tree looking for a file.
79
+ *
80
+ * @param filename - File to search for (default: `'config.yml'`)
81
+ * @param startDir - Directory to start from (default: `process.cwd()`)
82
+ * @returns Absolute path if found, `null` otherwise.
83
+ */
84
+ export function findConfigFile(filename = DEFAULT_FILENAME, startDir) {
85
+ let dir = path.resolve(startDir ?? process.cwd());
86
+ // eslint-disable-next-line no-constant-condition
87
+ while (true) {
88
+ const candidate = path.join(dir, filename);
89
+ try {
90
+ if (fs.statSync(candidate).isFile()) {
91
+ return candidate;
92
+ }
93
+ }
94
+ catch {
95
+ // Not found, keep walking
96
+ }
97
+ const parent = path.dirname(dir);
98
+ if (parent === dir)
99
+ break; // Filesystem root
100
+ dir = parent;
101
+ }
102
+ return null;
103
+ }
104
+ /**
105
+ * Find the global KADI config file.
106
+ *
107
+ * @param filename - Config filename (default: `'config.yml'`)
108
+ * @returns Absolute path to `~/.kadi/<filename>` if it exists, `null` otherwise.
109
+ */
110
+ export function findGlobalConfigFile(filename = DEFAULT_FILENAME) {
111
+ const globalPath = path.join(GLOBAL_KADI_DIR, filename);
112
+ try {
113
+ if (fs.statSync(globalPath).isFile()) {
114
+ return globalPath;
115
+ }
116
+ }
117
+ catch {
118
+ // Not found
119
+ }
120
+ return null;
121
+ }
122
+ // ═══════════════════════════════════════════════════════════════════════
123
+ // Section extraction
124
+ // ═══════════════════════════════════════════════════════════════════════
125
+ /**
126
+ * Parse a YAML file and extract a top-level section.
127
+ */
128
+ function readSection(filePath, section) {
129
+ const lib = requireYaml();
130
+ if (!lib)
131
+ return {};
132
+ try {
133
+ const content = fs.readFileSync(filePath, 'utf8');
134
+ const parsed = lib.load(content);
135
+ if (!parsed || typeof parsed !== 'object')
136
+ return {};
137
+ const sectionData = parsed[section];
138
+ if (!sectionData || typeof sectionData !== 'object')
139
+ return {};
140
+ return sectionData;
141
+ }
142
+ catch {
143
+ return {};
144
+ }
145
+ }
146
+ // ═══════════════════════════════════════════════════════════════════════
147
+ // Environment variable overlay
148
+ // ═══════════════════════════════════════════════════════════════════════
149
+ /**
150
+ * Build an overrides object from environment variables matching a prefix.
151
+ *
152
+ * `PREFIX_KEY_NAME=value` → `{ key_name: value }`
153
+ *
154
+ * Numeric strings are auto-coerced to numbers.
155
+ * `'true'` / `'false'` are coerced to booleans.
156
+ */
157
+ function envOverrides(prefix, knownKeys) {
158
+ const result = {};
159
+ const prefixUpper = prefix.toUpperCase() + '_';
160
+ for (const [envKey, envVal] of Object.entries(process.env)) {
161
+ if (!envKey.startsWith(prefixUpper) || envVal === undefined)
162
+ continue;
163
+ const configKey = envKey.slice(prefixUpper.length).toLowerCase();
164
+ // Skip if we have a known-keys set and this key isn't in it
165
+ if (knownKeys && !knownKeys.has(configKey))
166
+ continue;
167
+ result[configKey] = coerce(envVal);
168
+ }
169
+ return result;
170
+ }
171
+ /**
172
+ * Coerce a string environment value to its natural JS type.
173
+ */
174
+ function coerce(value) {
175
+ if (value === 'true')
176
+ return true;
177
+ if (value === 'false')
178
+ return false;
179
+ if (value === '')
180
+ return value;
181
+ // Attempt numeric coercion only for things that look numeric
182
+ const num = Number(value);
183
+ if (!Number.isNaN(num) && value.trim() !== '')
184
+ return num;
185
+ return value;
186
+ }
187
+ // ═══════════════════════════════════════════════════════════════════════
188
+ // Main API
189
+ // ═══════════════════════════════════════════════════════════════════════
190
+ /**
191
+ * Load configuration for a KADI agent or ability.
192
+ *
193
+ * Resolution order per-field (highest-priority first):
194
+ * 1. Environment variables (`envPrefix` + `_KEY`)
195
+ * 2. Project `config.yml` section (walk-up from startDir)
196
+ * 3. Global `~/.kadi/config.yml` section
197
+ * 4. Built-in `defaults`
198
+ *
199
+ * @typeParam T - The shape of the merged config object.
200
+ * @param options - Configuration loading options.
201
+ * @returns Merged config with metadata about where it was found.
202
+ *
203
+ * @example
204
+ * ```typescript
205
+ * const { config } = loadConfig<TunnelConfig>({
206
+ * section: 'tunnel',
207
+ * envPrefix: 'KADI_TUNNEL',
208
+ * defaults: { default_service: 'kadi', server_port: 7000 },
209
+ * });
210
+ * ```
211
+ */
212
+ export function loadConfig(options) {
213
+ const { section, startDir, filename = DEFAULT_FILENAME, envPrefix, defaults = {}, } = options;
214
+ // ── Layer 4: Defaults ──────────────────────────────────────────────
215
+ let merged = { ...defaults };
216
+ // ── Layer 3: Global config.yml (~/.kadi/config.yml) ────────────────
217
+ let configPath = null;
218
+ let source = 'default';
219
+ const globalPath = findGlobalConfigFile(filename);
220
+ if (globalPath) {
221
+ const globalSection = readSection(globalPath, section);
222
+ if (Object.keys(globalSection).length > 0) {
223
+ merged = shallowMerge(merged, globalSection);
224
+ configPath = globalPath;
225
+ source = 'global';
226
+ }
227
+ }
228
+ // ── Layer 2: Project config.yml (walk-up) ──────────────────────────
229
+ const projectPath = findConfigFile(filename, startDir);
230
+ if (projectPath) {
231
+ // Only use project config if it's not the same file as global
232
+ const isDistinct = !globalPath || path.resolve(projectPath) !== path.resolve(globalPath);
233
+ if (isDistinct) {
234
+ const projectSection = readSection(projectPath, section);
235
+ if (Object.keys(projectSection).length > 0) {
236
+ merged = shallowMerge(merged, projectSection);
237
+ configPath = projectPath;
238
+ source = 'project';
239
+ }
240
+ }
241
+ }
242
+ // ── Layer 1: Environment variables (highest priority) ──────────────
243
+ if (envPrefix) {
244
+ const env = envOverrides(envPrefix);
245
+ if (Object.keys(env).length > 0) {
246
+ merged = shallowMerge(merged, env);
247
+ }
248
+ }
249
+ return {
250
+ config: merged,
251
+ configPath,
252
+ source,
253
+ };
254
+ }
255
+ // ═══════════════════════════════════════════════════════════════════════
256
+ // Helpers
257
+ // ═══════════════════════════════════════════════════════════════════════
258
+ /**
259
+ * Shallow merge — source keys overwrite target keys.
260
+ * Only copies defined (non-undefined) values.
261
+ */
262
+ function shallowMerge(target, source) {
263
+ const result = { ...target };
264
+ for (const [key, val] of Object.entries(source)) {
265
+ if (val !== undefined) {
266
+ result[key] = val;
267
+ }
268
+ }
269
+ return result;
270
+ }
271
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -32,6 +32,8 @@ export type { ClientConfig, BrokerEntry, ResolvedConfig, ToolDefinition, BrokerT
32
32
  export { AgentJsonManager } from './agent-json.js';
33
33
  export { ProcessManager, ManagedProcess } from './process-manager.js';
34
34
  export { getByPath, setByPath, deleteByPath, deepMerge } from './utils.js';
35
+ export { loadConfig, findConfigFile, findGlobalConfigFile } from './config.js';
36
+ export type { LoadConfigOptions, ConfigResult, ConfigSource } from './config.js';
35
37
  export { StdioMessageReader, StdioMessageWriter } from './stdio-framing.js';
36
38
  export { zodToJsonSchema, isZodSchema } from './zod.js';
37
39
  export { findProjectRoot, readLockFile, findAbilityEntry, resolveAbilityPath, resolveAbilityEntry, getInstalledAbilityNames, } from './lockfile.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAGH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAGzC,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAG3D,YAAY,EAEV,YAAY,EACZ,WAAW,EACX,cAAc,EAGd,cAAc,EACd,oBAAoB,EACpB,iBAAiB,EACjB,WAAW,EACX,cAAc,EACd,UAAU,EACV,cAAc,EAGd,aAAa,EACb,aAAa,EACb,aAAa,EACb,YAAY,EAGZ,mBAAmB,EACnB,WAAW,EACX,iBAAiB,EACjB,gBAAgB,EAChB,iBAAiB,EACjB,mBAAmB,EAGnB,WAAW,EACX,YAAY,EACZ,cAAc,EACd,iBAAiB,EAGjB,WAAW,EACX,kBAAkB,EAClB,cAAc,EACd,gBAAgB,EAChB,WAAW,EAGX,cAAc,EACd,eAAe,EACf,mBAAmB,EACnB,YAAY,EACZ,cAAc,EAGd,aAAa,EACb,gBAAgB,EAGhB,cAAc,EAGd,SAAS,EACT,gBAAgB,EAChB,qBAAqB,EACrB,sBAAsB,EACtB,uBAAuB,EACvB,kBAAkB,EAClB,WAAW,EACX,cAAc,EAGd,WAAW,EACX,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,eAAe,EACf,aAAa,EACb,kBAAkB,EAClB,mBAAmB,GACpB,MAAM,YAAY,CAAC;AAGpB,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAGnD,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAGtE,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAG3E,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAG5E,OAAO,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAGxD,OAAO,EACL,eAAe,EACf,YAAY,EACZ,gBAAgB,EAChB,kBAAkB,EAClB,mBAAmB,EACnB,wBAAwB,GACzB,MAAM,eAAe,CAAC;AAGvB,OAAO,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAC7D,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAC7D,YAAY,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AAGrE,OAAO,KAAK,QAAQ,MAAM,eAAe,CAAC;AAG1C,OAAO,EAAE,sBAAsB,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAC;AACjF,YAAY,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAGH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAGzC,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAG3D,YAAY,EAEV,YAAY,EACZ,WAAW,EACX,cAAc,EAGd,cAAc,EACd,oBAAoB,EACpB,iBAAiB,EACjB,WAAW,EACX,cAAc,EACd,UAAU,EACV,cAAc,EAGd,aAAa,EACb,aAAa,EACb,aAAa,EACb,YAAY,EAGZ,mBAAmB,EACnB,WAAW,EACX,iBAAiB,EACjB,gBAAgB,EAChB,iBAAiB,EACjB,mBAAmB,EAGnB,WAAW,EACX,YAAY,EACZ,cAAc,EACd,iBAAiB,EAGjB,WAAW,EACX,kBAAkB,EAClB,cAAc,EACd,gBAAgB,EAChB,WAAW,EAGX,cAAc,EACd,eAAe,EACf,mBAAmB,EACnB,YAAY,EACZ,cAAc,EAGd,aAAa,EACb,gBAAgB,EAGhB,cAAc,EAGd,SAAS,EACT,gBAAgB,EAChB,qBAAqB,EACrB,sBAAsB,EACtB,uBAAuB,EACvB,kBAAkB,EAClB,WAAW,EACX,cAAc,EAGd,WAAW,EACX,YAAY,EACZ,YAAY,EACZ,WAAW,EACX,eAAe,EACf,aAAa,EACb,kBAAkB,EAClB,mBAAmB,GACpB,MAAM,YAAY,CAAC;AAGpB,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAGnD,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAGtE,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAG3E,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAC/E,YAAY,EAAE,iBAAiB,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAGjF,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAG5E,OAAO,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAGxD,OAAO,EACL,eAAe,EACf,YAAY,EACZ,gBAAgB,EAChB,kBAAkB,EAClB,mBAAmB,EACnB,wBAAwB,GACzB,MAAM,eAAe,CAAC;AAGvB,OAAO,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAC7D,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAC7D,YAAY,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AAGrE,OAAO,KAAK,QAAQ,MAAM,eAAe,CAAC;AAG1C,OAAO,EAAE,sBAAsB,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAC;AACjF,YAAY,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC"}
package/dist/index.js CHANGED
@@ -36,6 +36,8 @@ export { AgentJsonManager } from './agent-json.js';
36
36
  export { ProcessManager, ManagedProcess } from './process-manager.js';
37
37
  // Dot-path utilities
38
38
  export { getByPath, setByPath, deleteByPath, deepMerge } from './utils.js';
39
+ // Config loader (walk-up config.yml + ~/.kadi/ global fallback)
40
+ export { loadConfig, findConfigFile, findGlobalConfigFile } from './config.js';
39
41
  // Stdio framing (for advanced use cases — building custom bridge protocols)
40
42
  export { StdioMessageReader, StdioMessageWriter } from './stdio-framing.js';
41
43
  // Zod utilities
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,uCAAuC;AACvC,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,cAAc;AACd,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,SAAS;AACT,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAiFxC,qBAAqB;AACrB,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAEnD,kBAAkB;AAClB,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAEtE,qBAAqB;AACrB,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE3E,4EAA4E;AAC5E,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAE5E,gBAAgB;AAChB,OAAO,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAExD,sBAAsB;AACtB,OAAO,EACL,eAAe,EACf,YAAY,EACZ,gBAAgB,EAChB,kBAAkB,EAClB,mBAAmB,EACnB,wBAAwB,GACzB,MAAM,eAAe,CAAC;AAEvB,sCAAsC;AACtC,OAAO,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAC7D,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAG7D,4BAA4B;AAC5B,OAAO,KAAK,QAAQ,MAAM,eAAe,CAAC;AAE1C,kDAAkD;AAClD,OAAO,EAAE,sBAAsB,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,uCAAuC;AACvC,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,cAAc;AACd,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,SAAS;AACT,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAiFxC,qBAAqB;AACrB,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAEnD,kBAAkB;AAClB,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAEtE,qBAAqB;AACrB,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE3E,gEAAgE;AAChE,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAG/E,4EAA4E;AAC5E,OAAO,EAAE,kBAAkB,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAE5E,gBAAgB;AAChB,OAAO,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAExD,sBAAsB;AACtB,OAAO,EACL,eAAe,EACf,YAAY,EACZ,gBAAgB,EAChB,kBAAkB,EAClB,mBAAmB,EACnB,wBAAwB,GACzB,MAAM,eAAe,CAAC;AAEvB,sCAAsC;AACtC,OAAO,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAC7D,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAG7D,4BAA4B;AAC5B,OAAO,KAAK,QAAQ,MAAM,eAAe,CAAC;AAE1C,kDAAkD;AAClD,OAAO,EAAE,sBAAsB,EAAE,0BAA0B,EAAE,MAAM,aAAa,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kadi.build/core",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "A lean, readable SDK for building KADI agents.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/config.ts ADDED
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Configuration loader for KADI agents and abilities.
3
+ *
4
+ * Provides standardized config.yml discovery with a 2-tier fallback:
5
+ * 1. **Project-level** — walk up from startDir looking for config.yml
6
+ * 2. **Global-level** — ~/.kadi/config.yml (shared KADI infrastructure settings)
7
+ *
8
+ * Resolution order per-field (highest-priority first):
9
+ * 1. Environment variables (PREFIX_KEY)
10
+ * 2. Project config.yml section
11
+ * 3. Global config.yml section (~/.kadi/config.yml)
12
+ * 4. Built-in defaults
13
+ *
14
+ * Secrets (API keys, tokens) should NEVER appear in config.yml.
15
+ * Use secret-ability / kadi-secret for credentials.
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * import { loadConfig } from '@kadi.build/core';
20
+ *
21
+ * const { config, configPath, source } = loadConfig({
22
+ * section: 'memory',
23
+ * envPrefix: 'MEMORY',
24
+ * defaults: { database: 'kadi_memory', embedding_model: 'text-embedding-3-small' },
25
+ * });
26
+ * ```
27
+ *
28
+ * TypeScript equivalent of: kadi-core-py/src/kadi/config.py
29
+ */
30
+
31
+ import * as fs from 'node:fs';
32
+ import * as path from 'node:path';
33
+ import * as os from 'node:os';
34
+
35
+ // ═══════════════════════════════════════════════════════════════════════
36
+ // YAML import — optional runtime dependency
37
+ // ═══════════════════════════════════════════════════════════════════════
38
+
39
+ /**
40
+ * We lazy-import js-yaml so that kadi-core does NOT require it as a hard
41
+ * dependency. Consumers that use loadConfig() need js-yaml installed;
42
+ * everything else in kadi-core works without it.
43
+ *
44
+ * In practice every KADI agent/ability already has js-yaml, so this is
45
+ * a formality rather than a real constraint.
46
+ */
47
+ let yaml: { load(content: string): unknown } | null = null;
48
+ let yamlResolved = false;
49
+
50
+ function requireYaml(): typeof yaml {
51
+ if (!yamlResolved) {
52
+ yamlResolved = true;
53
+ try {
54
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
55
+ yaml = require('js-yaml') as typeof yaml;
56
+ } catch {
57
+ try {
58
+ // ESM fallback — createRequire for pure-ESM environments
59
+ const { createRequire } = require('node:module');
60
+ const localRequire = createRequire(process.cwd() + '/');
61
+ yaml = localRequire('js-yaml') as typeof yaml;
62
+ } catch {
63
+ yaml = null;
64
+ }
65
+ }
66
+ }
67
+ return yaml;
68
+ }
69
+
70
+ // ═══════════════════════════════════════════════════════════════════════
71
+ // Constants
72
+ // ═══════════════════════════════════════════════════════════════════════
73
+
74
+ /** Default config filename. */
75
+ const DEFAULT_FILENAME = 'config.yml';
76
+
77
+ /** Global KADI config directory. */
78
+ const GLOBAL_KADI_DIR = path.join(os.homedir(), '.kadi');
79
+
80
+ // ═══════════════════════════════════════════════════════════════════════
81
+ // Types
82
+ // ═══════════════════════════════════════════════════════════════════════
83
+
84
+ /** How the config file was discovered. */
85
+ export type ConfigSource = 'project' | 'global' | 'default';
86
+
87
+ /** Options for {@link loadConfig}. */
88
+ export interface LoadConfigOptions {
89
+ /**
90
+ * YAML section key to extract (e.g. `'tunnel'`, `'memory'`, `'kadi_agent'`).
91
+ * The top-level key in config.yml whose value becomes the config object.
92
+ */
93
+ section: string;
94
+
95
+ /**
96
+ * Directory to start the upward walk from.
97
+ * @default process.cwd()
98
+ */
99
+ startDir?: string;
100
+
101
+ /**
102
+ * Config filename to search for.
103
+ * @default 'config.yml'
104
+ */
105
+ filename?: string;
106
+
107
+ /**
108
+ * Environment variable prefix for automatic overrides.
109
+ *
110
+ * When set, loadConfig will scan `process.env` for keys starting with
111
+ * `PREFIX_` and merge them into the config (highest priority).
112
+ *
113
+ * Mapping: `PREFIX_KEY_NAME` → config field `key_name` (lowercased).
114
+ *
115
+ * @example envPrefix: 'MEMORY' → MEMORY_DATABASE overrides config.database
116
+ */
117
+ envPrefix?: string;
118
+
119
+ /**
120
+ * Built-in defaults (lowest priority).
121
+ * These are used when neither env vars nor config.yml provide a value.
122
+ */
123
+ defaults?: Record<string, unknown>;
124
+ }
125
+
126
+ /** Result of {@link loadConfig}. */
127
+ export interface ConfigResult<T = Record<string, unknown>> {
128
+ /** Merged config object: env > project config.yml > global config.yml > defaults. */
129
+ config: T;
130
+ /** Absolute path to the config.yml that was loaded, or null if none found. */
131
+ configPath: string | null;
132
+ /** Where the primary config was found. */
133
+ source: ConfigSource;
134
+ }
135
+
136
+ // ═══════════════════════════════════════════════════════════════════════
137
+ // File discovery
138
+ // ═══════════════════════════════════════════════════════════════════════
139
+
140
+ /**
141
+ * Walk up the directory tree looking for a file.
142
+ *
143
+ * @param filename - File to search for (default: `'config.yml'`)
144
+ * @param startDir - Directory to start from (default: `process.cwd()`)
145
+ * @returns Absolute path if found, `null` otherwise.
146
+ */
147
+ export function findConfigFile(
148
+ filename: string = DEFAULT_FILENAME,
149
+ startDir?: string,
150
+ ): string | null {
151
+ let dir = path.resolve(startDir ?? process.cwd());
152
+
153
+ // eslint-disable-next-line no-constant-condition
154
+ while (true) {
155
+ const candidate = path.join(dir, filename);
156
+ try {
157
+ if (fs.statSync(candidate).isFile()) {
158
+ return candidate;
159
+ }
160
+ } catch {
161
+ // Not found, keep walking
162
+ }
163
+ const parent = path.dirname(dir);
164
+ if (parent === dir) break; // Filesystem root
165
+ dir = parent;
166
+ }
167
+
168
+ return null;
169
+ }
170
+
171
+ /**
172
+ * Find the global KADI config file.
173
+ *
174
+ * @param filename - Config filename (default: `'config.yml'`)
175
+ * @returns Absolute path to `~/.kadi/<filename>` if it exists, `null` otherwise.
176
+ */
177
+ export function findGlobalConfigFile(
178
+ filename: string = DEFAULT_FILENAME,
179
+ ): string | null {
180
+ const globalPath = path.join(GLOBAL_KADI_DIR, filename);
181
+ try {
182
+ if (fs.statSync(globalPath).isFile()) {
183
+ return globalPath;
184
+ }
185
+ } catch {
186
+ // Not found
187
+ }
188
+ return null;
189
+ }
190
+
191
+ // ═══════════════════════════════════════════════════════════════════════
192
+ // Section extraction
193
+ // ═══════════════════════════════════════════════════════════════════════
194
+
195
+ /**
196
+ * Parse a YAML file and extract a top-level section.
197
+ */
198
+ function readSection(
199
+ filePath: string,
200
+ section: string,
201
+ ): Record<string, unknown> {
202
+ const lib = requireYaml();
203
+ if (!lib) return {};
204
+
205
+ try {
206
+ const content = fs.readFileSync(filePath, 'utf8');
207
+ const parsed = lib.load(content) as Record<string, unknown> | null;
208
+ if (!parsed || typeof parsed !== 'object') return {};
209
+ const sectionData = parsed[section];
210
+ if (!sectionData || typeof sectionData !== 'object') return {};
211
+ return sectionData as Record<string, unknown>;
212
+ } catch {
213
+ return {};
214
+ }
215
+ }
216
+
217
+ // ═══════════════════════════════════════════════════════════════════════
218
+ // Environment variable overlay
219
+ // ═══════════════════════════════════════════════════════════════════════
220
+
221
+ /**
222
+ * Build an overrides object from environment variables matching a prefix.
223
+ *
224
+ * `PREFIX_KEY_NAME=value` → `{ key_name: value }`
225
+ *
226
+ * Numeric strings are auto-coerced to numbers.
227
+ * `'true'` / `'false'` are coerced to booleans.
228
+ */
229
+ function envOverrides(
230
+ prefix: string,
231
+ knownKeys?: Set<string>,
232
+ ): Record<string, unknown> {
233
+ const result: Record<string, unknown> = {};
234
+ const prefixUpper = prefix.toUpperCase() + '_';
235
+
236
+ for (const [envKey, envVal] of Object.entries(process.env)) {
237
+ if (!envKey.startsWith(prefixUpper) || envVal === undefined) continue;
238
+
239
+ const configKey = envKey.slice(prefixUpper.length).toLowerCase();
240
+
241
+ // Skip if we have a known-keys set and this key isn't in it
242
+ if (knownKeys && !knownKeys.has(configKey)) continue;
243
+
244
+ result[configKey] = coerce(envVal);
245
+ }
246
+
247
+ return result;
248
+ }
249
+
250
+ /**
251
+ * Coerce a string environment value to its natural JS type.
252
+ */
253
+ function coerce(value: string): unknown {
254
+ if (value === 'true') return true;
255
+ if (value === 'false') return false;
256
+ if (value === '') return value;
257
+
258
+ // Attempt numeric coercion only for things that look numeric
259
+ const num = Number(value);
260
+ if (!Number.isNaN(num) && value.trim() !== '') return num;
261
+
262
+ return value;
263
+ }
264
+
265
+ // ═══════════════════════════════════════════════════════════════════════
266
+ // Main API
267
+ // ═══════════════════════════════════════════════════════════════════════
268
+
269
+ /**
270
+ * Load configuration for a KADI agent or ability.
271
+ *
272
+ * Resolution order per-field (highest-priority first):
273
+ * 1. Environment variables (`envPrefix` + `_KEY`)
274
+ * 2. Project `config.yml` section (walk-up from startDir)
275
+ * 3. Global `~/.kadi/config.yml` section
276
+ * 4. Built-in `defaults`
277
+ *
278
+ * @typeParam T - The shape of the merged config object.
279
+ * @param options - Configuration loading options.
280
+ * @returns Merged config with metadata about where it was found.
281
+ *
282
+ * @example
283
+ * ```typescript
284
+ * const { config } = loadConfig<TunnelConfig>({
285
+ * section: 'tunnel',
286
+ * envPrefix: 'KADI_TUNNEL',
287
+ * defaults: { default_service: 'kadi', server_port: 7000 },
288
+ * });
289
+ * ```
290
+ */
291
+ export function loadConfig<T = Record<string, unknown>>(
292
+ options: LoadConfigOptions,
293
+ ): ConfigResult<T> {
294
+ const {
295
+ section,
296
+ startDir,
297
+ filename = DEFAULT_FILENAME,
298
+ envPrefix,
299
+ defaults = {},
300
+ } = options;
301
+
302
+ // ── Layer 4: Defaults ──────────────────────────────────────────────
303
+ let merged: Record<string, unknown> = { ...defaults };
304
+
305
+ // ── Layer 3: Global config.yml (~/.kadi/config.yml) ────────────────
306
+ let configPath: string | null = null;
307
+ let source: ConfigSource = 'default';
308
+
309
+ const globalPath = findGlobalConfigFile(filename);
310
+ if (globalPath) {
311
+ const globalSection = readSection(globalPath, section);
312
+ if (Object.keys(globalSection).length > 0) {
313
+ merged = shallowMerge(merged, globalSection);
314
+ configPath = globalPath;
315
+ source = 'global';
316
+ }
317
+ }
318
+
319
+ // ── Layer 2: Project config.yml (walk-up) ──────────────────────────
320
+ const projectPath = findConfigFile(filename, startDir);
321
+ if (projectPath) {
322
+ // Only use project config if it's not the same file as global
323
+ const isDistinct = !globalPath || path.resolve(projectPath) !== path.resolve(globalPath);
324
+ if (isDistinct) {
325
+ const projectSection = readSection(projectPath, section);
326
+ if (Object.keys(projectSection).length > 0) {
327
+ merged = shallowMerge(merged, projectSection);
328
+ configPath = projectPath;
329
+ source = 'project';
330
+ }
331
+ }
332
+ }
333
+
334
+ // ── Layer 1: Environment variables (highest priority) ──────────────
335
+ if (envPrefix) {
336
+ const env = envOverrides(envPrefix);
337
+ if (Object.keys(env).length > 0) {
338
+ merged = shallowMerge(merged, env);
339
+ }
340
+ }
341
+
342
+ return {
343
+ config: merged as T,
344
+ configPath,
345
+ source,
346
+ };
347
+ }
348
+
349
+ // ═══════════════════════════════════════════════════════════════════════
350
+ // Helpers
351
+ // ═══════════════════════════════════════════════════════════════════════
352
+
353
+ /**
354
+ * Shallow merge — source keys overwrite target keys.
355
+ * Only copies defined (non-undefined) values.
356
+ */
357
+ function shallowMerge(
358
+ target: Record<string, unknown>,
359
+ source: Record<string, unknown>,
360
+ ): Record<string, unknown> {
361
+ const result = { ...target };
362
+ for (const [key, val] of Object.entries(source)) {
363
+ if (val !== undefined) {
364
+ result[key] = val;
365
+ }
366
+ }
367
+ return result;
368
+ }
package/src/index.ts CHANGED
@@ -122,6 +122,10 @@ export { ProcessManager, ManagedProcess } from './process-manager.js';
122
122
  // Dot-path utilities
123
123
  export { getByPath, setByPath, deleteByPath, deepMerge } from './utils.js';
124
124
 
125
+ // Config loader (walk-up config.yml + ~/.kadi/ global fallback)
126
+ export { loadConfig, findConfigFile, findGlobalConfigFile } from './config.js';
127
+ export type { LoadConfigOptions, ConfigResult, ConfigSource } from './config.js';
128
+
125
129
  // Stdio framing (for advanced use cases — building custom bridge protocols)
126
130
  export { StdioMessageReader, StdioMessageWriter } from './stdio-framing.js';
127
131