@alexnetrebskii/hive-agent 0.16.0 → 0.17.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/dist/context.js CHANGED
@@ -1,124 +1,652 @@
1
1
  /**
2
- * Context Management
2
+ * Context - A virtual filesystem for agent communication
3
3
  *
4
- * Token estimation and context management utilities.
5
- */
6
- /**
7
- * Estimate token count from text (approximately 4 chars per token)
4
+ * Enables tools and sub-agents to read/write shared data without passing
5
+ * content through return values. Similar to how Claude Code uses the
6
+ * actual filesystem for agent coordination.
7
+ *
8
+ * Usage:
9
+ * ```typescript
10
+ * const context = new Context({
11
+ * schemas: {
12
+ * 'plan/current': {
13
+ * type: 'object',
14
+ * properties: {
15
+ * title: { type: 'string' },
16
+ * days: { type: 'array' }
17
+ * },
18
+ * required: ['title', 'days']
19
+ * }
20
+ * }
21
+ * })
22
+ *
23
+ * // Pre-populate before run
24
+ * context.write('user/preferences', { theme: 'dark' })
25
+ *
26
+ * const result = await hive.run(message, { context })
27
+ *
28
+ * // Read results after run (validated against schema)
29
+ * const plan = context.read('plan/current')
30
+ * ```
8
31
  */
9
- export function estimateTokens(text) {
10
- return Math.ceil(text.length / 4);
11
- }
12
32
  /**
13
- * Estimate tokens for a message
33
+ * Context provides a virtual filesystem for agent communication
14
34
  */
15
- export function estimateMessageTokens(message) {
16
- if (typeof message.content === 'string') {
17
- return estimateTokens(message.content);
35
+ export class Context {
36
+ data = new Map();
37
+ validators = new Map();
38
+ /** Extension to type mapping */
39
+ static EXT_MAP = {
40
+ '.json': 'json',
41
+ '.md': 'md',
42
+ '.txt': 'txt',
43
+ '.num': 'num',
44
+ '.bool': 'bool',
45
+ '.schema': 'schema',
46
+ '.zod': 'zod',
47
+ '.validate': 'validate'
48
+ };
49
+ constructor(config) {
50
+ if (config?.validators) {
51
+ for (const [key, value] of Object.entries(config.validators)) {
52
+ this.registerValidator(key, value);
53
+ }
54
+ }
18
55
  }
19
- return message.content.reduce((sum, block) => {
20
- return sum + estimateContentBlockTokens(block);
21
- }, 0);
22
- }
23
- /**
24
- * Estimate tokens for a content block
25
- */
26
- export function estimateContentBlockTokens(block) {
27
- switch (block.type) {
28
- case 'text':
29
- return estimateTokens(block.text);
30
- case 'thinking':
31
- return estimateTokens(block.thinking);
32
- case 'tool_use':
33
- return estimateTokens(block.name) + estimateTokens(JSON.stringify(block.input));
34
- case 'tool_result':
35
- return estimateTokens(block.content);
36
- default:
37
- return 0;
56
+ /**
57
+ * Register a validator for a path (can also be called after construction)
58
+ *
59
+ * For paths with format extensions (.json, .md, etc.), the validator adds
60
+ * additional validation on top of the basic format check.
61
+ *
62
+ * @example
63
+ * // Basic format validation only
64
+ * context.registerValidator('data/config.json', null)
65
+ *
66
+ * // Format + JSON Schema validation
67
+ * context.registerValidator('data/config.json', { type: 'object', properties: {...} })
68
+ *
69
+ * // Format + Zod validation
70
+ * context.registerValidator('data/user.json', z.object({ name: z.string() }))
71
+ */
72
+ registerValidator(key, value) {
73
+ // Keep the full path (with extension) as the key
74
+ const path = key;
75
+ if (!value) {
76
+ // Just register the path for format validation (no additional validator)
77
+ return;
78
+ }
79
+ // Determine validator type from value
80
+ if (typeof value === 'function') {
81
+ this.validators.set(path, { type: 'validate', validate: value });
82
+ }
83
+ else if (this.isZodLike(value)) {
84
+ this.validators.set(path, { type: 'zod', zod: value });
85
+ }
86
+ else if ('type' in value && value.type === 'object') {
87
+ this.validators.set(path, { type: 'schema', schema: value });
88
+ }
89
+ else if ('schema' in value || 'validate' in value || 'zod' in value) {
90
+ const cs = value;
91
+ if (cs.zod) {
92
+ this.validators.set(path, { type: 'zod', zod: cs.zod, description: cs.description });
93
+ }
94
+ else if (cs.validate) {
95
+ this.validators.set(path, { type: 'validate', validate: cs.validate, description: cs.description });
96
+ }
97
+ else if (cs.schema) {
98
+ this.validators.set(path, { type: 'schema', schema: cs.schema, description: cs.description });
99
+ }
100
+ }
38
101
  }
39
- }
40
- /**
41
- * Estimate total tokens for all messages
42
- */
43
- export function estimateTotalTokens(messages) {
44
- return messages.reduce((sum, msg) => sum + estimateMessageTokens(msg), 0);
45
- }
46
- /**
47
- * Truncate old messages to fit within token limit
48
- */
49
- export function truncateOldMessages(messages, maxTokens, preserveFirst = 1) {
50
- if (messages.length <= preserveFirst) {
51
- return messages;
52
- }
53
- const result = [];
54
- let totalTokens = 0;
55
- // Always preserve first N messages (usually system context)
56
- for (let i = 0; i < preserveFirst && i < messages.length; i++) {
57
- result.push(messages[i]);
58
- totalTokens += estimateMessageTokens(messages[i]);
59
- }
60
- // Add messages from the end until we hit the limit
61
- const remaining = [];
62
- for (let i = messages.length - 1; i >= preserveFirst; i--) {
63
- const msgTokens = estimateMessageTokens(messages[i]);
64
- if (totalTokens + msgTokens <= maxTokens) {
65
- remaining.unshift(messages[i]);
66
- totalTokens += msgTokens;
67
- }
68
- else {
69
- break;
70
- }
71
- }
72
- return [...result, ...remaining];
73
- }
74
- /**
75
- * Context manager for tracking token usage during execution
76
- */
77
- export class ContextManager {
78
- maxTokens;
79
- strategy;
80
- currentTokens = 0;
81
- constructor(maxTokens = 100000, strategy = 'truncate_old') {
82
- this.maxTokens = maxTokens;
83
- this.strategy = strategy;
102
+ /** Parse path and extension */
103
+ parsePathExt(key) {
104
+ for (const [suffix, type] of Object.entries(Context.EXT_MAP)) {
105
+ if (key.endsWith(suffix)) {
106
+ return { path: key.slice(0, -suffix.length), ext: type };
107
+ }
108
+ }
109
+ return { path: key, ext: null };
110
+ }
111
+ /** Check if extension is a format type */
112
+ isFormatExt(ext) {
113
+ return ['json', 'md', 'txt', 'num', 'bool'].includes(ext);
114
+ }
115
+ /** Check if value looks like a Zod schema */
116
+ isZodLike(value) {
117
+ return (typeof value === 'object' &&
118
+ value !== null &&
119
+ 'safeParse' in value &&
120
+ typeof value.safeParse === 'function');
121
+ }
122
+ /**
123
+ * Write a value to the context at the given path
124
+ *
125
+ * @param path - Dot-separated path (e.g., 'meals.today', 'user.preferences')
126
+ * @param value - Any JSON-serializable value
127
+ * @param writtenBy - Optional identifier of who wrote this (tool name, agent name)
128
+ * @returns WriteResult with success status and any validation errors
129
+ */
130
+ write(path, value, writtenBy) {
131
+ const { ext } = this.parsePathExt(path);
132
+ const validator = this.validators.get(path);
133
+ let errors = [];
134
+ // Step 1: Format validation (from extension)
135
+ if (ext && this.isFormatExt(ext)) {
136
+ errors = this.validateFormat(value, ext, path);
137
+ if (errors.length > 0) {
138
+ return { success: false, errors };
139
+ }
140
+ }
141
+ // Step 2: Additional validation (from registered validator)
142
+ if (validator) {
143
+ if (validator.validate) {
144
+ const result = validator.validate(value);
145
+ if (result && result.length > 0) {
146
+ errors = result;
147
+ }
148
+ }
149
+ else if (validator.schema) {
150
+ errors = this.validateSchema(value, validator.schema, path);
151
+ }
152
+ else if (validator.zod) {
153
+ errors = this.validateZod(value, validator.zod, path);
154
+ }
155
+ }
156
+ if (errors.length > 0) {
157
+ return { success: false, errors };
158
+ }
159
+ const now = Date.now();
160
+ const existing = this.data.get(path);
161
+ this.data.set(path, {
162
+ value,
163
+ createdAt: existing?.createdAt || now,
164
+ updatedAt: now,
165
+ writtenBy
166
+ });
167
+ return { success: true };
168
+ }
169
+ /**
170
+ * Get validator info for a path
171
+ */
172
+ getValidator(path) {
173
+ const v = this.validators.get(path);
174
+ if (!v)
175
+ return undefined;
176
+ return { type: v.type, schema: v.schema, description: v.description };
177
+ }
178
+ /**
179
+ * Get schema for a path (if JSON schema validator)
180
+ */
181
+ getSchema(path) {
182
+ const v = this.validators.get(path);
183
+ return v?.schema;
84
184
  }
85
185
  /**
86
- * Update current token count
186
+ * Get validator description for a path
87
187
  */
88
- updateTokenCount(messages) {
89
- this.currentTokens = estimateTotalTokens(messages);
188
+ getValidatorDescription(path) {
189
+ return this.validators.get(path)?.description;
90
190
  }
91
191
  /**
92
- * Get remaining tokens available
192
+ * Get all paths with validators
93
193
  */
94
- getRemainingTokens() {
95
- return Math.max(0, this.maxTokens - this.currentTokens);
194
+ getValidatorPaths() {
195
+ return Array.from(this.validators.keys());
96
196
  }
97
197
  /**
98
- * Check if context is within limits
198
+ * Validate a value against a JSON schema
99
199
  */
100
- isWithinLimits() {
101
- return this.currentTokens <= this.maxTokens;
200
+ validateSchema(value, schema, basePath) {
201
+ const errors = [];
202
+ // Type check
203
+ if (schema.type === 'object') {
204
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
205
+ errors.push({
206
+ path: basePath,
207
+ message: 'Expected object',
208
+ expected: 'object',
209
+ received: value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value
210
+ });
211
+ return errors;
212
+ }
213
+ const obj = value;
214
+ // Check required properties
215
+ if (schema.required) {
216
+ for (const prop of schema.required) {
217
+ if (!(prop in obj)) {
218
+ errors.push({
219
+ path: `${basePath}.${prop}`,
220
+ message: `Missing required property: ${prop}`,
221
+ expected: 'present'
222
+ });
223
+ }
224
+ }
225
+ }
226
+ // Validate properties
227
+ if (schema.properties) {
228
+ for (const [prop, propSchema] of Object.entries(schema.properties)) {
229
+ if (prop in obj) {
230
+ const propErrors = this.validateProperty(obj[prop], propSchema, `${basePath}.${prop}`);
231
+ errors.push(...propErrors);
232
+ }
233
+ }
234
+ }
235
+ }
236
+ return errors;
102
237
  }
103
238
  /**
104
- * Manage context according to strategy
239
+ * Validate a single property
105
240
  */
106
- manageContext(messages) {
107
- this.updateTokenCount(messages);
108
- if (this.isWithinLimits()) {
109
- return messages;
241
+ validateProperty(value, schema, path) {
242
+ const errors = [];
243
+ // Type validation
244
+ if (schema.type === 'string') {
245
+ if (typeof value !== 'string') {
246
+ errors.push({
247
+ path,
248
+ message: 'Expected string',
249
+ expected: 'string',
250
+ received: typeof value
251
+ });
252
+ }
253
+ else if (schema.enum && !schema.enum.includes(value)) {
254
+ errors.push({
255
+ path,
256
+ message: `Value must be one of: ${schema.enum.join(', ')}`,
257
+ expected: schema.enum.join(' | '),
258
+ received: value
259
+ });
260
+ }
110
261
  }
111
- switch (this.strategy) {
112
- case 'truncate_old':
113
- return truncateOldMessages(messages, this.maxTokens);
114
- case 'summarize':
115
- // Summarization would require LLM call - for now, fall back to truncation
116
- return truncateOldMessages(messages, this.maxTokens);
117
- case 'error':
118
- throw new Error(`Context limit exceeded: ${this.currentTokens} > ${this.maxTokens} tokens`);
262
+ else if (schema.type === 'number') {
263
+ if (typeof value !== 'number') {
264
+ errors.push({
265
+ path,
266
+ message: 'Expected number',
267
+ expected: 'number',
268
+ received: typeof value
269
+ });
270
+ }
271
+ }
272
+ else if (schema.type === 'boolean') {
273
+ if (typeof value !== 'boolean') {
274
+ errors.push({
275
+ path,
276
+ message: 'Expected boolean',
277
+ expected: 'boolean',
278
+ received: typeof value
279
+ });
280
+ }
281
+ }
282
+ else if (schema.type === 'array') {
283
+ if (!Array.isArray(value)) {
284
+ errors.push({
285
+ path,
286
+ message: 'Expected array',
287
+ expected: 'array',
288
+ received: typeof value
289
+ });
290
+ }
291
+ // Could add items validation here if needed
292
+ }
293
+ else if (schema.type === 'object') {
294
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
295
+ errors.push({
296
+ path,
297
+ message: 'Expected object',
298
+ expected: 'object',
299
+ received: value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value
300
+ });
301
+ }
302
+ }
303
+ return errors;
304
+ }
305
+ /**
306
+ * Validate a value using Zod schema
307
+ */
308
+ validateZod(value, zod, basePath) {
309
+ const result = zod.safeParse(value);
310
+ if (result.success) {
311
+ return [];
312
+ }
313
+ return result.error.errors.map(err => ({
314
+ path: err.path.length > 0 ? `${basePath}.${err.path.join('.')}` : basePath,
315
+ message: err.message
316
+ }));
317
+ }
318
+ /**
319
+ * Validate a value against a format extension
320
+ */
321
+ validateFormat(value, format, path) {
322
+ switch (format) {
323
+ case 'json':
324
+ // Must be object or array (valid JSON structure)
325
+ if (typeof value !== 'object' || value === null) {
326
+ return [{
327
+ path,
328
+ message: 'Expected JSON object or array',
329
+ expected: 'object | array',
330
+ received: value === null ? 'null' : typeof value
331
+ }];
332
+ }
333
+ return [];
334
+ case 'md':
335
+ case 'txt':
336
+ // Must be string
337
+ if (typeof value !== 'string') {
338
+ return [{
339
+ path,
340
+ message: `Expected string for .${format}`,
341
+ expected: 'string',
342
+ received: typeof value
343
+ }];
344
+ }
345
+ return [];
346
+ case 'num':
347
+ // Must be number
348
+ if (typeof value !== 'number' || Number.isNaN(value)) {
349
+ return [{
350
+ path,
351
+ message: 'Expected number',
352
+ expected: 'number',
353
+ received: Number.isNaN(value) ? 'NaN' : typeof value
354
+ }];
355
+ }
356
+ return [];
357
+ case 'bool':
358
+ // Must be boolean
359
+ if (typeof value !== 'boolean') {
360
+ return [{
361
+ path,
362
+ message: 'Expected boolean',
363
+ expected: 'boolean',
364
+ received: typeof value
365
+ }];
366
+ }
367
+ return [];
119
368
  default:
120
- return messages;
369
+ return [];
370
+ }
371
+ }
372
+ /**
373
+ * Read a value from the context
374
+ *
375
+ * @param path - Path to read from
376
+ * @returns The value, or undefined if not found
377
+ */
378
+ read(path) {
379
+ return this.data.get(path)?.value;
380
+ }
381
+ /**
382
+ * Check if a path exists in the context
383
+ */
384
+ has(path) {
385
+ return this.data.has(path);
386
+ }
387
+ /**
388
+ * Delete a path from the context
389
+ */
390
+ delete(path) {
391
+ return this.data.delete(path);
392
+ }
393
+ /**
394
+ * List all paths, optionally filtered by prefix
395
+ *
396
+ * @param prefix - Optional prefix to filter paths (e.g., 'meals' lists 'meals.today', 'meals.yesterday')
397
+ * @returns Array of context items with metadata
398
+ */
399
+ list(prefix) {
400
+ const items = [];
401
+ for (const [path, entry] of this.data) {
402
+ if (prefix && !path.startsWith(prefix)) {
403
+ continue;
404
+ }
405
+ items.push({
406
+ path,
407
+ updatedAt: entry.updatedAt,
408
+ writtenBy: entry.writtenBy,
409
+ preview: this.getPreview(entry.value)
410
+ });
411
+ }
412
+ // Sort by path for consistent ordering
413
+ return items.sort((a, b) => a.path.localeCompare(b.path));
414
+ }
415
+ /**
416
+ * Get all paths (just the keys, no metadata)
417
+ */
418
+ keys(prefix) {
419
+ if (!prefix) {
420
+ return Array.from(this.data.keys()).sort();
421
+ }
422
+ return Array.from(this.data.keys())
423
+ .filter(k => k.startsWith(prefix))
424
+ .sort();
425
+ }
426
+ /**
427
+ * Clear all data from the context
428
+ */
429
+ clear() {
430
+ this.data.clear();
431
+ }
432
+ /**
433
+ * Get the full entry with metadata
434
+ */
435
+ getEntry(path) {
436
+ return this.data.get(path);
437
+ }
438
+ /**
439
+ * Export all data as a plain object
440
+ */
441
+ toObject() {
442
+ const obj = {};
443
+ for (const [path, entry] of this.data) {
444
+ obj[path] = entry.value;
445
+ }
446
+ return obj;
447
+ }
448
+ /**
449
+ * Import data from a plain object
450
+ */
451
+ fromObject(obj, writtenBy) {
452
+ for (const [path, value] of Object.entries(obj)) {
453
+ this.write(path, value, writtenBy);
121
454
  }
122
455
  }
456
+ /**
457
+ * Get number of entries
458
+ */
459
+ get size() {
460
+ return this.data.size;
461
+ }
462
+ getPreview(value) {
463
+ if (value === null)
464
+ return 'null';
465
+ if (value === undefined)
466
+ return 'undefined';
467
+ const type = typeof value;
468
+ if (type === 'string') {
469
+ const str = value;
470
+ return str.length > 50 ? `"${str.slice(0, 47)}..."` : `"${str}"`;
471
+ }
472
+ if (type === 'number' || type === 'boolean') {
473
+ return String(value);
474
+ }
475
+ if (Array.isArray(value)) {
476
+ return `Array[${value.length}]`;
477
+ }
478
+ if (type === 'object') {
479
+ const keys = Object.keys(value);
480
+ if (keys.length <= 3) {
481
+ return `{${keys.join(', ')}}`;
482
+ }
483
+ return `{${keys.slice(0, 3).join(', ')}, ...+${keys.length - 3}}`;
484
+ }
485
+ return type;
486
+ }
487
+ }
488
+ /**
489
+ * Create context tools for agents to interact with Context
490
+ */
491
+ export function createContextTools(context, agentName) {
492
+ // Build validator info for tool descriptions
493
+ const validatorPaths = context.getValidatorPaths();
494
+ const validatorInfo = validatorPaths.length > 0
495
+ ? `\n\nPaths with validation:\n${validatorPaths.map(p => {
496
+ const v = context.getValidator(p);
497
+ const desc = v?.description || (v?.type === 'schema' ? 'JSON Schema' : 'custom validator');
498
+ return `- ${p}: ${desc}`;
499
+ }).join('\n')}`
500
+ : '';
501
+ return [
502
+ {
503
+ name: 'context_ls',
504
+ description: `List all paths in the context, optionally filtered by prefix.
505
+
506
+ The context is a virtual filesystem where tools and agents can read/write data.
507
+ Use this to discover what data is available.${validatorInfo}
508
+
509
+ Examples:
510
+ - { } - list all paths
511
+ - { "prefix": "meals" } - list paths starting with "meals"`,
512
+ parameters: {
513
+ type: 'object',
514
+ properties: {
515
+ prefix: {
516
+ type: 'string',
517
+ description: 'Optional prefix to filter paths'
518
+ }
519
+ }
520
+ },
521
+ execute: async (params) => {
522
+ const prefix = params.prefix;
523
+ const items = context.list(prefix);
524
+ // Include validator info in response
525
+ const validators = context.getValidatorPaths()
526
+ .filter(p => !prefix || p.startsWith(prefix))
527
+ .map(p => {
528
+ const v = context.getValidator(p);
529
+ return {
530
+ path: p,
531
+ type: v?.type,
532
+ description: v?.description,
533
+ schema: v?.schema
534
+ };
535
+ });
536
+ if (items.length === 0 && validators.length === 0) {
537
+ return {
538
+ success: true,
539
+ data: {
540
+ message: prefix ? `No entries found with prefix "${prefix}"` : 'Context is empty',
541
+ items: [],
542
+ validators: []
543
+ }
544
+ };
545
+ }
546
+ return {
547
+ success: true,
548
+ data: {
549
+ count: items.length,
550
+ items,
551
+ validators: validators.length > 0 ? validators : undefined
552
+ }
553
+ };
554
+ }
555
+ },
556
+ {
557
+ name: 'context_read',
558
+ description: `Read a value from the shared context.
559
+
560
+ Returns the stored value at the given path, or null if not found.
561
+
562
+ Examples:
563
+ - { "path": "meals.today" }
564
+ - { "path": "user.preferences" }`,
565
+ parameters: {
566
+ type: 'object',
567
+ properties: {
568
+ path: {
569
+ type: 'string',
570
+ description: 'The path to read from'
571
+ }
572
+ },
573
+ required: ['path']
574
+ },
575
+ execute: async (params) => {
576
+ const path = params.path;
577
+ const entry = context.getEntry(path);
578
+ if (!entry) {
579
+ return {
580
+ success: true,
581
+ data: {
582
+ found: false,
583
+ path,
584
+ value: null
585
+ }
586
+ };
587
+ }
588
+ return {
589
+ success: true,
590
+ data: {
591
+ found: true,
592
+ path,
593
+ value: entry.value,
594
+ updatedAt: entry.updatedAt,
595
+ writtenBy: entry.writtenBy
596
+ }
597
+ };
598
+ }
599
+ },
600
+ {
601
+ name: 'context_write',
602
+ description: `Write a value to the context.
603
+
604
+ Use this to store data that should be available to other tools, agents, or after the run completes.
605
+ Some paths have validators - if validation fails, you'll get an error with details.${validatorInfo}
606
+
607
+ Examples:
608
+ - { "path": "meals.today", "value": { "breakfast": "eggs", "calories": 200 } }
609
+ - { "path": "analysis.result", "value": "The data shows positive trends" }`,
610
+ parameters: {
611
+ type: 'object',
612
+ properties: {
613
+ path: {
614
+ type: 'string',
615
+ description: 'The path to write to'
616
+ },
617
+ value: {
618
+ type: 'string',
619
+ description: 'The value to store (any JSON value)'
620
+ }
621
+ },
622
+ required: ['path', 'value']
623
+ },
624
+ execute: async (params) => {
625
+ const path = params.path;
626
+ const value = params.value;
627
+ const result = context.write(path, value, agentName);
628
+ if (!result.success) {
629
+ // Get schema for helpful error message
630
+ const schema = context.getSchema(path);
631
+ return {
632
+ success: false,
633
+ error: 'Validation failed',
634
+ data: {
635
+ path,
636
+ errors: result.errors,
637
+ expectedSchema: schema
638
+ }
639
+ };
640
+ }
641
+ return {
642
+ success: true,
643
+ data: {
644
+ path,
645
+ written: true
646
+ }
647
+ };
648
+ }
649
+ }
650
+ ];
123
651
  }
124
652
  //# sourceMappingURL=context.js.map