mark-improving-agent 2.3.3 → 2.3.5
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/VERSION +1 -1
- package/dist/core/memory/binary-vector.js +405 -0
- package/dist/core/memory/budget-retrieval.js +420 -0
- package/dist/core/memory/index.js +2 -0
- package/dist/core/security/execution-governor.js +436 -0
- package/dist/core/security/index.js +1 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.3.
|
|
1
|
+
2.3.5
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binary Vector Memory Compression
|
|
3
|
+
*
|
|
4
|
+
* High-performance binary vector quantization for memory embeddings.
|
|
5
|
+
* Based on QuIVer: Rethinking ANN Graph Topology via Training-Free Binary Quantization (Xiao et al., 2026)
|
|
6
|
+
*
|
|
7
|
+
* Achieves 32x compression: 1536-dim float32 (6144 bytes) → 192 bytes binary
|
|
8
|
+
* Uses Hamming distance (XOR + POPCOUNT) for fast similarity search.
|
|
9
|
+
*
|
|
10
|
+
* @module core/memory
|
|
11
|
+
* @fileoverview Binary vector quantization for memory compression
|
|
12
|
+
*/
|
|
13
|
+
import { createLogger } from '../../utils/logger.js';
|
|
14
|
+
const logger = createLogger('[BinaryVector]');
|
|
15
|
+
/**
|
|
16
|
+
* Default configuration for 1536-dim embeddings (OpenAI text-embedding-3-small)
|
|
17
|
+
*/
|
|
18
|
+
export const DEFAULT_BINARY_CONFIG = {
|
|
19
|
+
dimension: 1536,
|
|
20
|
+
normalized: true,
|
|
21
|
+
trackStats: true,
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Create a binary vector index
|
|
25
|
+
*
|
|
26
|
+
* @param config - Configuration for the index
|
|
27
|
+
* @returns Empty binary index ready for vectors
|
|
28
|
+
*/
|
|
29
|
+
export function createBinaryIndex(config) {
|
|
30
|
+
logger.info('Creating binary vector index', { dimension: config.dimension });
|
|
31
|
+
return {
|
|
32
|
+
vectors: new Uint8Array(0),
|
|
33
|
+
indices: [],
|
|
34
|
+
metadata: [],
|
|
35
|
+
config,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Quantize a float32 vector to binary using sign-bit quantization
|
|
40
|
+
*
|
|
41
|
+
* @param vector - Float32 array (dimension length)
|
|
42
|
+
* @param normalized - Whether vector is L2 normalized (skip magnitude check if true)
|
|
43
|
+
* @returns Uint8Array of packed binary bits
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```typescript
|
|
47
|
+
* const vector = new Float32Array([0.5, -0.3, 0.1, -0.8]);
|
|
48
|
+
* const binary = quantizeToBinary(vector);
|
|
49
|
+
* // Result: Uint8Array [0b10001001] (4 dimensions = 4 bits = 1 byte)
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export function quantizeToBinary(vector, normalized = true) {
|
|
53
|
+
const dim = vector.length;
|
|
54
|
+
const bytes = Math.ceil(dim / 8);
|
|
55
|
+
const result = new Uint8Array(bytes);
|
|
56
|
+
for (let i = 0; i < dim; i++) {
|
|
57
|
+
// Sign bit: positive = 1, negative = 0
|
|
58
|
+
const bitIndex = i % 8;
|
|
59
|
+
const byteIndex = Math.floor(i / 8);
|
|
60
|
+
if (vector[i] >= 0) {
|
|
61
|
+
result[byteIndex] |= (1 << (7 - bitIndex));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Dequantize binary vector back to approximate float32
|
|
68
|
+
*
|
|
69
|
+
* Note: This is lossy - returns center-of-mass values (±0.5 for normalized vectors)
|
|
70
|
+
*
|
|
71
|
+
* @param binary - Packed binary bits
|
|
72
|
+
* @param dimension - Original dimension
|
|
73
|
+
* @param normalized - Whether to return normalized values
|
|
74
|
+
* @returns Approximate float32 vector
|
|
75
|
+
*/
|
|
76
|
+
export function dequantizeFromBinary(binary, dimension, normalized = true) {
|
|
77
|
+
const result = new Float32Array(dimension);
|
|
78
|
+
for (let i = 0; i < dimension; i++) {
|
|
79
|
+
const bitIndex = i % 8;
|
|
80
|
+
const byteIndex = Math.floor(i / 8);
|
|
81
|
+
const bit = (binary[byteIndex] >> (7 - bitIndex)) & 1;
|
|
82
|
+
// For normalized vectors: 1 → +0.5, 0 → -0.5
|
|
83
|
+
// This gives approximate reconstruction
|
|
84
|
+
result[i] = normalized ? (bit ? 0.5 : -0.5) : (bit ? 0 : 0);
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Calculate Hamming distance between two binary vectors
|
|
90
|
+
*
|
|
91
|
+
* @param a - First binary vector
|
|
92
|
+
* @param b - Second binary vector
|
|
93
|
+
* @returns Hamming distance (count of differing bits)
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```typescript
|
|
97
|
+
* const dist = hammingDistance(binaryA, binaryB);
|
|
98
|
+
* // dist = 0 means identical, dist = dimension means opposite
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export function hammingDistance(a, b) {
|
|
102
|
+
if (a.length !== b.length) {
|
|
103
|
+
throw new Error(`Binary vectors must have same length: ${a.length} vs ${b.length}`);
|
|
104
|
+
}
|
|
105
|
+
let distance = 0;
|
|
106
|
+
for (let i = 0; i < a.length; i++) {
|
|
107
|
+
// XOR gives 1s where bits differ, POPCOUNT counts them
|
|
108
|
+
const xored = a[i] ^ b[i];
|
|
109
|
+
// POPCOUNT via built-in
|
|
110
|
+
let count = xored;
|
|
111
|
+
count = (count & 0x55) + ((count >> 1) & 0x55);
|
|
112
|
+
count = (count & 0x33) + ((count >> 2) & 0x33);
|
|
113
|
+
count = (count & 0x0F) + ((count >> 4) & 0x0F);
|
|
114
|
+
distance += count;
|
|
115
|
+
}
|
|
116
|
+
return distance;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Calculate similarity from Hamming distance
|
|
120
|
+
*
|
|
121
|
+
* @param hammingDist - Hamming distance
|
|
122
|
+
* @param dimension - Vector dimension
|
|
123
|
+
* @returns Similarity score 0-1 (1 = identical)
|
|
124
|
+
*/
|
|
125
|
+
export function hammingToSimilarity(hammingDist, dimension) {
|
|
126
|
+
return 1 - (hammingDist / dimension);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Add vectors to the binary index
|
|
130
|
+
*
|
|
131
|
+
* @param index - Binary index to add to
|
|
132
|
+
* @param vectors - Float32Array vectors (each dimension-long)
|
|
133
|
+
* @param ids - Unique identifiers for each vector
|
|
134
|
+
* @param metadata - Optional metadata (importance, timestamp)
|
|
135
|
+
* @returns Updated index
|
|
136
|
+
*/
|
|
137
|
+
export function addToBinaryIndex(index, vectors, ids, metadata) {
|
|
138
|
+
const dim = index.config.dimension;
|
|
139
|
+
if (vectors.length !== ids.length) {
|
|
140
|
+
throw new Error(`Vectors count (${vectors.length}) must match IDs count (${ids.length})`);
|
|
141
|
+
}
|
|
142
|
+
// Calculate total bytes needed
|
|
143
|
+
const bytesPerVector = Math.ceil(dim / 8);
|
|
144
|
+
const newTotalBytes = index.vectors.length + (vectors.length * bytesPerVector);
|
|
145
|
+
// Create new expanded vectors array
|
|
146
|
+
const newVectors = new Uint8Array(newTotalBytes);
|
|
147
|
+
if (index.vectors.length > 0) {
|
|
148
|
+
newVectors.set(index.vectors);
|
|
149
|
+
}
|
|
150
|
+
// Quantize and add each vector
|
|
151
|
+
let offset = index.vectors.length;
|
|
152
|
+
for (let i = 0; i < vectors.length; i++) {
|
|
153
|
+
if (vectors[i].length !== dim) {
|
|
154
|
+
throw new Error(`Vector dimension ${vectors[i].length} doesn't match index dimension ${dim}`);
|
|
155
|
+
}
|
|
156
|
+
const binary = quantizeToBinary(vectors[i], index.config.normalized);
|
|
157
|
+
newVectors.set(binary, offset);
|
|
158
|
+
offset += bytesPerVector;
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
vectors: newVectors,
|
|
162
|
+
indices: [...index.indices, ...ids.map((_, i) => index.indices.length + i)],
|
|
163
|
+
metadata: [...index.metadata, ...metadata || ids.map((id, i) => ({ id, importance: 0.5, timestamp: Date.now() }))],
|
|
164
|
+
config: index.config,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Search for similar vectors in the binary index using Hamming distance
|
|
169
|
+
*
|
|
170
|
+
* @param index - Binary index to search
|
|
171
|
+
* @param queryVector - Query vector to search for
|
|
172
|
+
* @param k - Number of results to return
|
|
173
|
+
* @returns Top-k results sorted by similarity (highest first)
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* ```typescript
|
|
177
|
+
* const results = searchBinaryIndex(index, queryEmbedding, 10);
|
|
178
|
+
* // Returns top 10 most similar memories
|
|
179
|
+
* ```
|
|
180
|
+
*/
|
|
181
|
+
export function searchBinaryIndex(index, queryVector, k) {
|
|
182
|
+
const dim = index.config.dimension;
|
|
183
|
+
const bytesPerVector = Math.ceil(dim / 8);
|
|
184
|
+
if (queryVector.length !== dim) {
|
|
185
|
+
throw new Error(`Query dimension ${queryVector.length} doesn't match index dimension ${dim}`);
|
|
186
|
+
}
|
|
187
|
+
// Quantize query
|
|
188
|
+
const queryBinary = quantizeToBinary(queryVector, index.config.normalized);
|
|
189
|
+
// Search all vectors
|
|
190
|
+
const results = [];
|
|
191
|
+
let totalHamming = 0;
|
|
192
|
+
for (let i = 0; i < index.indices.length; i++) {
|
|
193
|
+
const offset = i * bytesPerVector;
|
|
194
|
+
const vectorBinary = index.vectors.subarray(offset, offset + bytesPerVector);
|
|
195
|
+
const hDist = hammingDistance(queryBinary, vectorBinary);
|
|
196
|
+
totalHamming += hDist;
|
|
197
|
+
results.push({
|
|
198
|
+
index: i,
|
|
199
|
+
id: index.metadata[i]?.id || String(index.indices[i]),
|
|
200
|
+
hammingDistance: hDist,
|
|
201
|
+
similarity: hammingToSimilarity(hDist, dim),
|
|
202
|
+
metadata: index.metadata[i] || { importance: 0.5, timestamp: Date.now() },
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
// Sort by similarity (highest first)
|
|
206
|
+
results.sort((a, b) => b.similarity - a.similarity);
|
|
207
|
+
// Return top-k
|
|
208
|
+
const topK = results.slice(0, k);
|
|
209
|
+
if (index.config.trackStats) {
|
|
210
|
+
logger.debug(`Search completed: ${index.indices.length} vectors, avg Hamming: ${(totalHamming / index.indices.length).toFixed(2)}`);
|
|
211
|
+
}
|
|
212
|
+
return topK;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Get compression statistics
|
|
216
|
+
*
|
|
217
|
+
* @param index - Binary index
|
|
218
|
+
* @returns Compression statistics
|
|
219
|
+
*/
|
|
220
|
+
export function getCompressionStats(index) {
|
|
221
|
+
const dim = index.config.dimension;
|
|
222
|
+
const float32Bytes = dim * 4; // 4 bytes per float32
|
|
223
|
+
const binaryBytes = Math.ceil(dim / 8);
|
|
224
|
+
const vectorsCount = index.indices.length;
|
|
225
|
+
const originalBytes = vectorsCount * float32Bytes;
|
|
226
|
+
const compressedBytes = vectorsCount * binaryBytes;
|
|
227
|
+
return {
|
|
228
|
+
originalBytes,
|
|
229
|
+
compressedBytes,
|
|
230
|
+
compressionRatio: originalBytes / compressedBytes,
|
|
231
|
+
vectorsStored: vectorsCount,
|
|
232
|
+
dimension: dim,
|
|
233
|
+
avgHammingDistance: 0, // Calculated on demand
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Save binary index to binary format
|
|
238
|
+
*
|
|
239
|
+
* @param index - Binary index to save
|
|
240
|
+
* @returns Buffer containing serialized index
|
|
241
|
+
*/
|
|
242
|
+
export function serializeBinaryIndex(index) {
|
|
243
|
+
const configBytes = JSON.stringify(index.config).length;
|
|
244
|
+
const metadataBytes = JSON.stringify(index.metadata).length;
|
|
245
|
+
const indicesBytes = index.indices.length * 4; // 4 bytes per number
|
|
246
|
+
const totalBytes = 4 + configBytes + 4 + metadataBytes + 4 + indicesBytes + index.vectors.length + 4 + index.indices.length * 8;
|
|
247
|
+
const buffer = new ArrayBuffer(totalBytes);
|
|
248
|
+
const view = new DataView(buffer);
|
|
249
|
+
const uint8 = new Uint8Array(buffer);
|
|
250
|
+
let offset = 0;
|
|
251
|
+
// Config
|
|
252
|
+
const configStr = JSON.stringify(index.config);
|
|
253
|
+
view.setUint32(offset, configStr.length);
|
|
254
|
+
offset += 4;
|
|
255
|
+
uint8.set(new TextEncoder().encode(configStr), offset);
|
|
256
|
+
offset += configStr.length;
|
|
257
|
+
// Metadata
|
|
258
|
+
const metadataStr = JSON.stringify(index.metadata);
|
|
259
|
+
view.setUint32(offset, metadataStr.length);
|
|
260
|
+
offset += 4;
|
|
261
|
+
uint8.set(new TextEncoder().encode(metadataStr), offset);
|
|
262
|
+
offset += metadataStr.length;
|
|
263
|
+
// Indices
|
|
264
|
+
view.setUint32(offset, index.indices.length);
|
|
265
|
+
offset += 4;
|
|
266
|
+
for (const idx of index.indices) {
|
|
267
|
+
view.setUint32(offset, idx);
|
|
268
|
+
offset += 4;
|
|
269
|
+
}
|
|
270
|
+
// Vectors
|
|
271
|
+
uint8.set(index.vectors, offset);
|
|
272
|
+
offset += index.vectors.length;
|
|
273
|
+
// Stored IDs count for compatibility
|
|
274
|
+
view.setUint32(offset, index.metadata.length);
|
|
275
|
+
return buffer;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Load binary index from buffer
|
|
279
|
+
*
|
|
280
|
+
* @param buffer - Serialized index buffer
|
|
281
|
+
* @returns Reconstructed BinaryIndex
|
|
282
|
+
*/
|
|
283
|
+
export function deserializeBinaryIndex(buffer) {
|
|
284
|
+
const view = new DataView(buffer);
|
|
285
|
+
const uint8 = new Uint8Array(buffer);
|
|
286
|
+
let offset = 0;
|
|
287
|
+
// Config
|
|
288
|
+
const configLen = view.getUint32(offset);
|
|
289
|
+
offset += 4;
|
|
290
|
+
const configStr = new TextDecoder().decode(uint8.subarray(offset, offset + configLen));
|
|
291
|
+
offset += configLen;
|
|
292
|
+
const config = JSON.parse(configStr);
|
|
293
|
+
// Metadata
|
|
294
|
+
const metadataLen = view.getUint32(offset);
|
|
295
|
+
offset += 4;
|
|
296
|
+
const metadataStr = new TextDecoder().decode(uint8.subarray(offset, offset + metadataLen));
|
|
297
|
+
offset += metadataLen;
|
|
298
|
+
const metadata = JSON.parse(metadataStr);
|
|
299
|
+
// Indices
|
|
300
|
+
const indicesLen = view.getUint32(offset);
|
|
301
|
+
offset += 4;
|
|
302
|
+
const indices = [];
|
|
303
|
+
for (let i = 0; i < indicesLen; i++) {
|
|
304
|
+
indices.push(view.getUint32(offset));
|
|
305
|
+
offset += 4;
|
|
306
|
+
}
|
|
307
|
+
// Vectors
|
|
308
|
+
const vectorsLen = buffer.byteLength - offset - 4;
|
|
309
|
+
const vectors = uint8.subarray(offset, offset + vectorsLen);
|
|
310
|
+
return {
|
|
311
|
+
vectors,
|
|
312
|
+
indices,
|
|
313
|
+
metadata,
|
|
314
|
+
config,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Create a binary vector compressor for memory embeddings
|
|
319
|
+
* Integrates with existing HeartFlow memory system
|
|
320
|
+
*
|
|
321
|
+
* @param config - Configuration for the compressor
|
|
322
|
+
* @returns Memory compression interface
|
|
323
|
+
*/
|
|
324
|
+
export function createBinaryVectorCompressor(config = {}) {
|
|
325
|
+
const fullConfig = { ...DEFAULT_BINARY_CONFIG, ...config };
|
|
326
|
+
let index = createBinaryIndex(fullConfig);
|
|
327
|
+
logger.info('Binary vector compressor initialized', { ...fullConfig });
|
|
328
|
+
return {
|
|
329
|
+
/**
|
|
330
|
+
* Compress and store an embedding
|
|
331
|
+
*/
|
|
332
|
+
store(id, embedding, importance = 0.5) {
|
|
333
|
+
index = addToBinaryIndex(index, [embedding], [id], [{ id, importance, timestamp: Date.now() }]);
|
|
334
|
+
logger.debug(`Stored embedding ${id}, total: ${index.indices.length}`);
|
|
335
|
+
},
|
|
336
|
+
/**
|
|
337
|
+
* Compress and store multiple embeddings
|
|
338
|
+
*/
|
|
339
|
+
storeBatch(ids, embeddings, importances) {
|
|
340
|
+
const metadata = ids.map((id, i) => ({
|
|
341
|
+
id,
|
|
342
|
+
importance: importances?.[i] ?? 0.5,
|
|
343
|
+
timestamp: Date.now(),
|
|
344
|
+
}));
|
|
345
|
+
index = addToBinaryIndex(index, embeddings, ids, metadata);
|
|
346
|
+
logger.debug(`Stored ${ids.length} embeddings, total: ${index.indices.length}`);
|
|
347
|
+
},
|
|
348
|
+
/**
|
|
349
|
+
* Search for similar embeddings
|
|
350
|
+
*/
|
|
351
|
+
search(queryEmbedding, k = 10) {
|
|
352
|
+
return searchBinaryIndex(index, queryEmbedding, k);
|
|
353
|
+
},
|
|
354
|
+
/**
|
|
355
|
+
* Get compression statistics
|
|
356
|
+
*/
|
|
357
|
+
getStats() {
|
|
358
|
+
const stats = getCompressionStats(index);
|
|
359
|
+
return {
|
|
360
|
+
...stats,
|
|
361
|
+
stored: index.indices.length,
|
|
362
|
+
compressionPercent: `${stats.compressionRatio.toFixed(1)}x`,
|
|
363
|
+
};
|
|
364
|
+
},
|
|
365
|
+
/**
|
|
366
|
+
* Clear all stored vectors
|
|
367
|
+
*/
|
|
368
|
+
clear() {
|
|
369
|
+
index = createBinaryIndex(fullConfig);
|
|
370
|
+
logger.info('Binary vector index cleared');
|
|
371
|
+
},
|
|
372
|
+
/**
|
|
373
|
+
* Export serialized index
|
|
374
|
+
*/
|
|
375
|
+
export() {
|
|
376
|
+
return serializeBinaryIndex(index);
|
|
377
|
+
},
|
|
378
|
+
/**
|
|
379
|
+
* Import serialized index
|
|
380
|
+
*/
|
|
381
|
+
import(buffer) {
|
|
382
|
+
index = deserializeBinaryIndex(buffer);
|
|
383
|
+
logger.info(`Imported binary index with ${index.indices.length} vectors`);
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Common embedding dimensions for major embedding models
|
|
389
|
+
*/
|
|
390
|
+
export const EMBEDDING_DIMENSIONS = {
|
|
391
|
+
/** OpenAI text-embedding-3-small, text-embedding-3-large */
|
|
392
|
+
OPENAI_1536: 1536,
|
|
393
|
+
/** OpenAI text-embedding-ada-002 */
|
|
394
|
+
OPENAI_1536_ADA: 1536,
|
|
395
|
+
/** OpenAI text-embedding-3-large (3072 dim) */
|
|
396
|
+
OPENAI_3072: 3072,
|
|
397
|
+
/** Cohere embed-english-v3.0 */
|
|
398
|
+
COHERE_1024: 1024,
|
|
399
|
+
/** Cohere embed-multilingual-v3.0 */
|
|
400
|
+
COHERE_MULTILINGUAL: 1024,
|
|
401
|
+
/** Vertex AI textembedding-gecko */
|
|
402
|
+
VERTEX_GECKO: 768,
|
|
403
|
+
/** Default dimension */
|
|
404
|
+
DEFAULT: 1536,
|
|
405
|
+
};
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Budget-Aware Memory Retrieval System
|
|
3
|
+
*
|
|
4
|
+
* A deterministic, token-budget-conscious memory retrieval system that:
|
|
5
|
+
* - Respects max token budgets during retrieval
|
|
6
|
+
* - Provides sub-millisecond query latency
|
|
7
|
+
* - Ensures identical queries return identical results
|
|
8
|
+
* - Supports semantic caching for similar queries
|
|
9
|
+
*
|
|
10
|
+
* Based on: Doorman11991/budget-aware-mcp (https://github.com/Doorman11991/budget-aware-mcp)
|
|
11
|
+
*
|
|
12
|
+
* Key concepts:
|
|
13
|
+
* - Hop-based graph walk: expand outward from anchor symbols hop by hop
|
|
14
|
+
* - Token budget: retrieval stops when budget is exhausted
|
|
15
|
+
* - Deterministic ordering: alphabetical within each hop level
|
|
16
|
+
* - Semantic cache: similar queries hit cache instantly
|
|
17
|
+
*
|
|
18
|
+
* @module core/memory
|
|
19
|
+
* @fileoverview Budget-aware memory retrieval with token control
|
|
20
|
+
*/
|
|
21
|
+
import { createLogger } from '../../utils/logger.js';
|
|
22
|
+
import { cosineSimilarity } from './embedder.js';
|
|
23
|
+
const logger = createLogger('[BudgetRetrieval]');
|
|
24
|
+
/**
|
|
25
|
+
* Trigram-based similarity for semantic caching
|
|
26
|
+
*/
|
|
27
|
+
function extractTrigrams(text) {
|
|
28
|
+
const normalized = text.toLowerCase().replace(/[^a-z0-9\s]/g, ' ');
|
|
29
|
+
const words = normalized.split(/\s+/).filter(w => w.length > 0);
|
|
30
|
+
const trigrams = [];
|
|
31
|
+
for (const word of words) {
|
|
32
|
+
if (word.length >= 3) {
|
|
33
|
+
for (let i = 0; i <= word.length - 3; i++) {
|
|
34
|
+
trigrams.push(word.slice(i, i + 3));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return trigrams;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Jaccard similarity between two trigram sets
|
|
42
|
+
*/
|
|
43
|
+
function trigramSimilarity(a, b) {
|
|
44
|
+
if (a.length === 0 || b.length === 0)
|
|
45
|
+
return 0;
|
|
46
|
+
const setA = new Set(a);
|
|
47
|
+
const setB = new Set(b);
|
|
48
|
+
const intersection = new Set([...setA].filter(x => setB.has(x)));
|
|
49
|
+
const union = new Set([...setA, ...setB]);
|
|
50
|
+
return intersection.size / union.size;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Estimate tokens for a memory entry (rough approximation)
|
|
54
|
+
*/
|
|
55
|
+
function estimateTokens(entry) {
|
|
56
|
+
// Rough estimate: ~4 characters per token for typical English text
|
|
57
|
+
// Plus overhead for metadata
|
|
58
|
+
const contentTokens = Math.ceil(entry.content.length / 4);
|
|
59
|
+
const metadataTokens = 20; // tags, id, timestamps, etc.
|
|
60
|
+
return contentTokens + metadataTokens;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Create a deterministic hash from query
|
|
64
|
+
*/
|
|
65
|
+
function hashQuery(query) {
|
|
66
|
+
const parts = [
|
|
67
|
+
query.query.toLowerCase().trim(),
|
|
68
|
+
query.anchorId || '',
|
|
69
|
+
query.tier || '',
|
|
70
|
+
(query.maxTokens || 0).toString(),
|
|
71
|
+
(query.tags || []).sort().join(','),
|
|
72
|
+
(query.importanceThreshold || 0).toString()
|
|
73
|
+
];
|
|
74
|
+
// Simple hash function for determinism
|
|
75
|
+
const str = parts.join('|');
|
|
76
|
+
let hash = 0;
|
|
77
|
+
for (let i = 0; i < str.length; i++) {
|
|
78
|
+
const char = str.charCodeAt(i);
|
|
79
|
+
hash = ((hash << 5) - hash) + char;
|
|
80
|
+
hash = hash & hash; // Convert to 32-bit integer
|
|
81
|
+
}
|
|
82
|
+
return Math.abs(hash).toString(36);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Alphabetical sort for deterministic ordering within hop levels
|
|
86
|
+
*/
|
|
87
|
+
function deterministicSort(entries) {
|
|
88
|
+
return entries.sort((a, b) => {
|
|
89
|
+
// First by hop level
|
|
90
|
+
if (a.hopLevel !== b.hopLevel) {
|
|
91
|
+
return a.hopLevel - b.hopLevel;
|
|
92
|
+
}
|
|
93
|
+
// Then alphabetically by content
|
|
94
|
+
return a.entry.content.localeCompare(b.entry.content);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Default configuration
|
|
99
|
+
*/
|
|
100
|
+
export const DEFAULT_BUDGET_CONFIG = {
|
|
101
|
+
maxTokens: 4000,
|
|
102
|
+
tokensPerEntry: 200,
|
|
103
|
+
maxHops: 3,
|
|
104
|
+
enableCache: true,
|
|
105
|
+
cacheTTLMs: 5 * 60 * 1000, // 5 minutes
|
|
106
|
+
cacheSimilarityThreshold: 0.7,
|
|
107
|
+
enableImportanceFilter: true,
|
|
108
|
+
importanceThreshold: 0.3
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Create a budget-aware retrieval engine
|
|
112
|
+
*/
|
|
113
|
+
export function createBudgetRetrievalEngine(config = {}, embedder) {
|
|
114
|
+
const fullConfig = { ...DEFAULT_BUDGET_CONFIG, ...config };
|
|
115
|
+
logger.info('Creating BudgetRetrievalEngine', { ...fullConfig });
|
|
116
|
+
// Memory storage
|
|
117
|
+
const entries = new Map();
|
|
118
|
+
const entriesByTier = new Map();
|
|
119
|
+
const entriesByTag = new Map();
|
|
120
|
+
// Embedding index (if embedder provided)
|
|
121
|
+
const entryEmbeddings = new Map();
|
|
122
|
+
// Semantic cache
|
|
123
|
+
const cache = new Map();
|
|
124
|
+
let cacheStats = { hits: 0, misses: 0, hitRate: 0, avgSimilarity: 0 };
|
|
125
|
+
/**
|
|
126
|
+
* Check semantic cache for similar query
|
|
127
|
+
*/
|
|
128
|
+
function checkCache(query) {
|
|
129
|
+
if (!fullConfig.enableCache)
|
|
130
|
+
return null;
|
|
131
|
+
const queryTrigrams = extractTrigrams(query.query);
|
|
132
|
+
const queryHash = hashQuery(query);
|
|
133
|
+
// Check exact match first
|
|
134
|
+
const exactEntry = cache.get(queryHash);
|
|
135
|
+
if (exactEntry) {
|
|
136
|
+
const age = Date.now() - exactEntry.timestamp;
|
|
137
|
+
if (age < fullConfig.cacheTTLMs) {
|
|
138
|
+
return exactEntry;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Check similar queries
|
|
142
|
+
let bestMatch = null;
|
|
143
|
+
let bestSimilarity = 0;
|
|
144
|
+
for (const entry of cache.values()) {
|
|
145
|
+
const age = Date.now() - entry.timestamp;
|
|
146
|
+
if (age >= fullConfig.cacheTTLMs)
|
|
147
|
+
continue;
|
|
148
|
+
const similarity = trigramSimilarity(queryTrigrams, entry.trigrams);
|
|
149
|
+
if (similarity >= fullConfig.cacheSimilarityThreshold && similarity > bestSimilarity) {
|
|
150
|
+
bestSimilarity = similarity;
|
|
151
|
+
bestMatch = entry;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return bestMatch;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Store result in cache
|
|
158
|
+
*/
|
|
159
|
+
function storeCache(query, result) {
|
|
160
|
+
if (!fullConfig.enableCache)
|
|
161
|
+
return;
|
|
162
|
+
const queryHash = hashQuery(query);
|
|
163
|
+
const trigrams = extractTrigrams(query.query);
|
|
164
|
+
cache.set(queryHash, {
|
|
165
|
+
queryHash,
|
|
166
|
+
queryText: query.query,
|
|
167
|
+
result,
|
|
168
|
+
timestamp: Date.now(),
|
|
169
|
+
trigrams
|
|
170
|
+
});
|
|
171
|
+
// Cleanup old entries
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
for (const [key, entry] of cache.entries()) {
|
|
174
|
+
if (now - entry.timestamp > fullConfig.cacheTTLMs) {
|
|
175
|
+
cache.delete(key);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Calculate relevance score for an entry
|
|
181
|
+
*/
|
|
182
|
+
function calculateRelevance(entry, query, anchorEntry) {
|
|
183
|
+
let relevance = 0;
|
|
184
|
+
// Base relevance from importance
|
|
185
|
+
relevance += entry.importance * 0.3;
|
|
186
|
+
// Text similarity if query provided
|
|
187
|
+
if (query.query && embedder) {
|
|
188
|
+
const queryEmb = embedder.embed(query.query);
|
|
189
|
+
const entryEmb = entryEmbeddings.get(entry.id);
|
|
190
|
+
if (queryEmb && entryEmb) {
|
|
191
|
+
const sim = cosineSimilarity(queryEmb, entryEmb);
|
|
192
|
+
relevance += sim * 0.4;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Content match
|
|
196
|
+
const queryLower = query.query.toLowerCase();
|
|
197
|
+
const contentLower = entry.content.toLowerCase();
|
|
198
|
+
if (contentLower.includes(queryLower)) {
|
|
199
|
+
relevance += 0.3;
|
|
200
|
+
}
|
|
201
|
+
// Tag match
|
|
202
|
+
if (query.tags && query.tags.length > 0) {
|
|
203
|
+
const entryTags = new Set(entry.tags.map(t => t.toLowerCase()));
|
|
204
|
+
const matchCount = query.tags.filter(t => entryTags.has(t.toLowerCase())).length;
|
|
205
|
+
relevance += (matchCount / query.tags.length) * 0.2;
|
|
206
|
+
}
|
|
207
|
+
// Hop-based relevance from anchor
|
|
208
|
+
if (anchorEntry) {
|
|
209
|
+
// Entries referenced by anchor = hop 1
|
|
210
|
+
const anchor = anchorEntry;
|
|
211
|
+
const ent = entry;
|
|
212
|
+
if (anchor.references?.includes(ent.id)) {
|
|
213
|
+
return relevance + 0.5 - 0.1; // hop 1 bonus
|
|
214
|
+
}
|
|
215
|
+
// Entries referencing anchor = hop 1
|
|
216
|
+
if (ent.references?.includes(anchor.id)) {
|
|
217
|
+
return relevance + 0.5 - 0.1;
|
|
218
|
+
}
|
|
219
|
+
// Same session = hop 2
|
|
220
|
+
if (anchor.sessionId && anchor.sessionId === ent.sessionId) {
|
|
221
|
+
return relevance + 0.4 - 0.2;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return Math.min(1, relevance);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Perform budget-aware retrieval
|
|
228
|
+
*/
|
|
229
|
+
async function retrieve(query) {
|
|
230
|
+
const startMs = Date.now();
|
|
231
|
+
const maxTokens = query.maxTokens || fullConfig.maxTokens;
|
|
232
|
+
logger.debug('Retrieving with budget', { query: query.query, maxTokens });
|
|
233
|
+
// Check cache first
|
|
234
|
+
const cached = checkCache(query);
|
|
235
|
+
if (cached) {
|
|
236
|
+
cacheStats.hits++;
|
|
237
|
+
cacheStats.avgSimilarity = (cacheStats.avgSimilarity * (cacheStats.hits - 1) + (cached.result.cacheSimilarity || 1)) / cacheStats.hits;
|
|
238
|
+
cacheStats.hitRate = cacheStats.hits / (cacheStats.hits + cacheStats.misses);
|
|
239
|
+
logger.debug('Cache hit', { similarity: cached.result.cacheSimilarity });
|
|
240
|
+
return {
|
|
241
|
+
...cached.result,
|
|
242
|
+
cacheHit: true,
|
|
243
|
+
processingMs: Date.now() - startMs
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
cacheStats.misses++;
|
|
247
|
+
cacheStats.hitRate = cacheStats.hits / (cacheStats.hits + cacheStats.misses);
|
|
248
|
+
// Get anchor entry if specified
|
|
249
|
+
const anchorEntry = query.anchorId ? entries.get(query.anchorId) : undefined;
|
|
250
|
+
// Collect candidate entries
|
|
251
|
+
let candidateIds;
|
|
252
|
+
if (query.tier) {
|
|
253
|
+
const tierSet = entriesByTier.get(query.tier);
|
|
254
|
+
candidateIds = tierSet ? Array.from(tierSet) : [];
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
candidateIds = Array.from(entries.keys());
|
|
258
|
+
}
|
|
259
|
+
// Filter by tags if specified
|
|
260
|
+
if (query.tags && query.tags.length > 0) {
|
|
261
|
+
candidateIds = candidateIds.filter(id => {
|
|
262
|
+
const entry = entries.get(id);
|
|
263
|
+
if (!entry)
|
|
264
|
+
return false;
|
|
265
|
+
const entryTags = new Set(entry.tags.map(t => t.toLowerCase()));
|
|
266
|
+
return query.tags.some(t => entryTags.has(t.toLowerCase()));
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
// Score and filter candidates
|
|
270
|
+
const scoredCandidates = [];
|
|
271
|
+
for (const id of candidateIds) {
|
|
272
|
+
const entry = entries.get(id);
|
|
273
|
+
if (!entry)
|
|
274
|
+
continue;
|
|
275
|
+
// Importance filter
|
|
276
|
+
if (fullConfig.enableImportanceFilter && entry.importance < fullConfig.importanceThreshold) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (query.importanceThreshold && entry.importance < query.importanceThreshold) {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const relevance = calculateRelevance(entry, query, anchorEntry);
|
|
283
|
+
const tokenEstimate = estimateTokens(entry);
|
|
284
|
+
// Determine hop level
|
|
285
|
+
let hopLevel = 0;
|
|
286
|
+
if (anchorEntry) {
|
|
287
|
+
if (id === anchorEntry.id) {
|
|
288
|
+
hopLevel = 0;
|
|
289
|
+
}
|
|
290
|
+
else if ((anchorEntry.references?.includes(id)) || (entry.references?.includes(anchorEntry.id))) {
|
|
291
|
+
hopLevel = 1;
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
hopLevel = 2;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
scoredCandidates.push({
|
|
298
|
+
entry,
|
|
299
|
+
relevance,
|
|
300
|
+
hopLevel,
|
|
301
|
+
tokenEstimate,
|
|
302
|
+
cumulativeTokens: 0 // Will be calculated after sorting
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
// Sort deterministically
|
|
306
|
+
const sorted = deterministicSort(scoredCandidates);
|
|
307
|
+
// Build budget-constrained result
|
|
308
|
+
const budget = {
|
|
309
|
+
maxTokens,
|
|
310
|
+
usedTokens: 0,
|
|
311
|
+
remainingTokens: maxTokens,
|
|
312
|
+
entriesSelected: 0
|
|
313
|
+
};
|
|
314
|
+
const selected = [];
|
|
315
|
+
for (const candidate of sorted) {
|
|
316
|
+
if (budget.remainingTokens < candidate.tokenEstimate) {
|
|
317
|
+
continue; // Can't afford this entry
|
|
318
|
+
}
|
|
319
|
+
if (candidate.hopLevel > fullConfig.maxHops) {
|
|
320
|
+
continue; // Exceeded max hops
|
|
321
|
+
}
|
|
322
|
+
candidate.cumulativeTokens = budget.usedTokens + candidate.tokenEstimate;
|
|
323
|
+
budget.usedTokens = candidate.cumulativeTokens;
|
|
324
|
+
budget.remainingTokens = maxTokens - budget.usedTokens;
|
|
325
|
+
budget.entriesSelected++;
|
|
326
|
+
selected.push(candidate);
|
|
327
|
+
}
|
|
328
|
+
const result = {
|
|
329
|
+
entries: selected,
|
|
330
|
+
tokensUsed: budget.usedTokens,
|
|
331
|
+
budgetRemaining: budget.remainingTokens,
|
|
332
|
+
cacheHit: false,
|
|
333
|
+
queryHash: hashQuery(query),
|
|
334
|
+
processingMs: Date.now() - startMs,
|
|
335
|
+
totalAvailable: candidateIds.length
|
|
336
|
+
};
|
|
337
|
+
// Store in cache
|
|
338
|
+
storeCache(query, result);
|
|
339
|
+
logger.debug('Retrieval complete', {
|
|
340
|
+
tokensUsed: result.tokensUsed,
|
|
341
|
+
entriesSelected: result.entries.length,
|
|
342
|
+
processingMs: result.processingMs,
|
|
343
|
+
cacheHit: result.cacheHit
|
|
344
|
+
});
|
|
345
|
+
return result;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Index a memory entry
|
|
349
|
+
*/
|
|
350
|
+
function index(entry) {
|
|
351
|
+
entries.set(entry.id, entry);
|
|
352
|
+
// Index by tier
|
|
353
|
+
if (!entriesByTier.has(entry.tier)) {
|
|
354
|
+
entriesByTier.set(entry.tier, new Set());
|
|
355
|
+
}
|
|
356
|
+
entriesByTier.get(entry.tier).add(entry.id);
|
|
357
|
+
// Index by tags
|
|
358
|
+
for (const tag of entry.tags) {
|
|
359
|
+
if (!entriesByTag.has(tag)) {
|
|
360
|
+
entriesByTag.set(tag, new Set());
|
|
361
|
+
}
|
|
362
|
+
entriesByTag.get(tag).add(entry.id);
|
|
363
|
+
}
|
|
364
|
+
// Embed for semantic search
|
|
365
|
+
if (embedder) {
|
|
366
|
+
entryEmbeddings.set(entry.id, embedder.embed(entry.content));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Remove entry from index
|
|
371
|
+
*/
|
|
372
|
+
function remove(id) {
|
|
373
|
+
const entry = entries.get(id);
|
|
374
|
+
if (!entry)
|
|
375
|
+
return;
|
|
376
|
+
entries.delete(id);
|
|
377
|
+
// Remove from tier index
|
|
378
|
+
entriesByTier.get(entry.tier)?.delete(id);
|
|
379
|
+
// Remove from tag indices
|
|
380
|
+
for (const tag of entry.tags) {
|
|
381
|
+
entriesByTag.get(tag)?.delete(id);
|
|
382
|
+
}
|
|
383
|
+
// Remove embedding
|
|
384
|
+
entryEmbeddings.delete(id);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Clear all entries
|
|
388
|
+
*/
|
|
389
|
+
function clear() {
|
|
390
|
+
entries.clear();
|
|
391
|
+
entriesByTier.clear();
|
|
392
|
+
entriesByTag.clear();
|
|
393
|
+
entryEmbeddings.clear();
|
|
394
|
+
cache.clear();
|
|
395
|
+
logger.info('Budget retrieval engine cleared');
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Get cache statistics
|
|
399
|
+
*/
|
|
400
|
+
function getCacheStats() {
|
|
401
|
+
return { ...cacheStats };
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Update configuration
|
|
405
|
+
*/
|
|
406
|
+
function updateConfig(config) {
|
|
407
|
+
Object.assign(fullConfig, config);
|
|
408
|
+
logger.debug('Config updated', config);
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
retrieve,
|
|
412
|
+
index,
|
|
413
|
+
remove,
|
|
414
|
+
clear,
|
|
415
|
+
getCacheStats,
|
|
416
|
+
updateConfig
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
// Export utilities for external use
|
|
420
|
+
export { extractTrigrams, trigramSimilarity, estimateTokens, hashQuery };
|
|
@@ -11,3 +11,5 @@ export { createContextFragmentationEngine } from './context-fragmentation.js';
|
|
|
11
11
|
export { createHybridSearchEngine, createBM25Index, bm25Score, normalizeBM25Scores, DEFAULT_HYBRID_CONFIG } from './hybrid-search.js';
|
|
12
12
|
export { createPatternRecognizer } from './pattern-recognizer.js';
|
|
13
13
|
export { createMemoryObserver } from './observer.js';
|
|
14
|
+
export { createBinaryVectorCompressor, quantizeToBinary, hammingDistance, hammingToSimilarity, createBinaryIndex, addToBinaryIndex, searchBinaryIndex, getCompressionStats, serializeBinaryIndex, deserializeBinaryIndex, DEFAULT_BINARY_CONFIG, EMBEDDING_DIMENSIONS } from './binary-vector.js';
|
|
15
|
+
export { createBudgetRetrievalEngine, DEFAULT_BUDGET_CONFIG, extractTrigrams, trigramSimilarity, estimateTokens, hashQuery } from './budget-retrieval.js';
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execution Governor - Cross-Context Tool Call Governance
|
|
3
|
+
*
|
|
4
|
+
* Inspired by chitin (Execution kernel for AI coding agents)
|
|
5
|
+
* Provides declarative policy enforcement for tool calls across different execution contexts.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Gating tool calls against typed policies
|
|
9
|
+
* - Severity ladder with lockdown counter
|
|
10
|
+
* - Tamper-evident audit chain
|
|
11
|
+
* - Bounds enforcement on push-shaped actions
|
|
12
|
+
* - Heuristic signals for blast-radius, floundering, drift
|
|
13
|
+
*
|
|
14
|
+
* @module core/security
|
|
15
|
+
* @fileoverview Execution governance for HeartFlow
|
|
16
|
+
*/
|
|
17
|
+
import { createLogger } from '../../utils/logger.js';
|
|
18
|
+
import * as crypto from 'crypto';
|
|
19
|
+
const logger = createLogger('[ExecutionGovernor]');
|
|
20
|
+
// ============================================================
|
|
21
|
+
// Default Configuration
|
|
22
|
+
// ============================================================
|
|
23
|
+
const DEFAULT_CONFIG = {
|
|
24
|
+
enableAuditChain: true,
|
|
25
|
+
enableSeverityLockdown: true,
|
|
26
|
+
maxLockdownCount: 5,
|
|
27
|
+
severityThresholds: {
|
|
28
|
+
low: 3,
|
|
29
|
+
medium: 5,
|
|
30
|
+
high: 10,
|
|
31
|
+
critical: 20,
|
|
32
|
+
},
|
|
33
|
+
policies: [
|
|
34
|
+
{
|
|
35
|
+
id: 'default-read',
|
|
36
|
+
action: 'read',
|
|
37
|
+
allow: true,
|
|
38
|
+
severity: 'info',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'default-write',
|
|
42
|
+
action: 'write',
|
|
43
|
+
allow: true,
|
|
44
|
+
conditions: {
|
|
45
|
+
bounds: {
|
|
46
|
+
maxLinesChanged: 500,
|
|
47
|
+
maxFilesAffected: 10,
|
|
48
|
+
},
|
|
49
|
+
requireConfirmation: true,
|
|
50
|
+
},
|
|
51
|
+
severity: 'medium',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'default-delete',
|
|
55
|
+
action: 'delete',
|
|
56
|
+
allow: true,
|
|
57
|
+
conditions: {
|
|
58
|
+
bounds: {
|
|
59
|
+
maxFilesAffected: 1,
|
|
60
|
+
},
|
|
61
|
+
requireConfirmation: true,
|
|
62
|
+
},
|
|
63
|
+
severity: 'high',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: 'default-execute',
|
|
67
|
+
action: 'execute',
|
|
68
|
+
allow: false,
|
|
69
|
+
severity: 'critical',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'default-network',
|
|
73
|
+
action: 'network',
|
|
74
|
+
allow: true,
|
|
75
|
+
conditions: {
|
|
76
|
+
maxPerHour: 50,
|
|
77
|
+
},
|
|
78
|
+
severity: 'medium',
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
defaultPolicy: {
|
|
82
|
+
id: 'default-unknown',
|
|
83
|
+
action: 'unknown',
|
|
84
|
+
allow: false,
|
|
85
|
+
severity: 'high',
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
// ============================================================
|
|
89
|
+
// Helper Functions
|
|
90
|
+
// ============================================================
|
|
91
|
+
/**
|
|
92
|
+
* Classify a tool call into a canonical action
|
|
93
|
+
*/
|
|
94
|
+
function classifyAction(toolName, args) {
|
|
95
|
+
const lowerTool = toolName.toLowerCase();
|
|
96
|
+
// Read actions
|
|
97
|
+
if (lowerTool.includes('read') || lowerTool.includes('get') || lowerTool.includes('fetch') ||
|
|
98
|
+
lowerTool.includes('search') || lowerTool.includes('query') || lowerTool.includes('select')) {
|
|
99
|
+
return 'read';
|
|
100
|
+
}
|
|
101
|
+
// Write actions
|
|
102
|
+
if (lowerTool.includes('write') || lowerTool.includes('create') || lowerTool.includes('add') ||
|
|
103
|
+
lowerTool.includes('insert') || lowerTool.includes('post') || lowerTool.includes('put')) {
|
|
104
|
+
return 'write';
|
|
105
|
+
}
|
|
106
|
+
// Delete actions
|
|
107
|
+
if (lowerTool.includes('delete') || lowerTool.includes('remove') || lowerTool.includes('drop') ||
|
|
108
|
+
lowerTool.includes('unlink') || lowerTool.includes('rm')) {
|
|
109
|
+
return 'delete';
|
|
110
|
+
}
|
|
111
|
+
// Execute actions
|
|
112
|
+
if (lowerTool.includes('exec') || lowerTool.includes('run') || lowerTool.includes('execute') ||
|
|
113
|
+
lowerTool.includes('spawn') || lowerTool.includes('fork') || lowerTool.includes('shell')) {
|
|
114
|
+
return 'execute';
|
|
115
|
+
}
|
|
116
|
+
// Network actions
|
|
117
|
+
if (lowerTool.includes('http') || lowerTool.includes('fetch') || lowerTool.includes('request') ||
|
|
118
|
+
lowerTool.includes('send') || lowerTool.includes('post') || lowerTool.includes('url')) {
|
|
119
|
+
return 'network';
|
|
120
|
+
}
|
|
121
|
+
// Filesystem actions
|
|
122
|
+
if (lowerTool.includes('file') || lowerTool.includes('dir') || lowerTool.includes('path') ||
|
|
123
|
+
lowerTool.includes('mkdir') || lowerTool.includes('stat') || lowerTool.includes('ls')) {
|
|
124
|
+
return 'filesystem';
|
|
125
|
+
}
|
|
126
|
+
// Process actions
|
|
127
|
+
if (lowerTool.includes('process') || lowerTool.includes('pid') || lowerTool.includes('kill') ||
|
|
128
|
+
lowerTool.includes('signal')) {
|
|
129
|
+
return 'process';
|
|
130
|
+
}
|
|
131
|
+
// Memory actions
|
|
132
|
+
if (lowerTool.includes('memory') || lowerTool.includes('store') || lowerTool.includes('recall') ||
|
|
133
|
+
lowerTool.includes('embed')) {
|
|
134
|
+
return 'memory';
|
|
135
|
+
}
|
|
136
|
+
// Agent actions
|
|
137
|
+
if (lowerTool.includes('agent') || lowerTool.includes('spawn') || lowerTool.includes('delegate') ||
|
|
138
|
+
lowerTool.includes('subagent')) {
|
|
139
|
+
return 'agent';
|
|
140
|
+
}
|
|
141
|
+
return 'unknown';
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Calculate heuristic signals from envelope context
|
|
145
|
+
*/
|
|
146
|
+
function calculateHeuristics(envelope) {
|
|
147
|
+
const context = envelope.context;
|
|
148
|
+
return {
|
|
149
|
+
blastRadius: context.blastRadius || 'low',
|
|
150
|
+
floundering: context.floundering || false,
|
|
151
|
+
drift: context.drift || 0,
|
|
152
|
+
repetitionCount: 0, // Tracked in agent state
|
|
153
|
+
urgency: 0.5, // Default
|
|
154
|
+
stakes: envelope.action === 'delete' || envelope.action === 'execute' ? 0.9 : 0.3,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Hash data for audit chain
|
|
159
|
+
*/
|
|
160
|
+
function hashData(data, prevHash) {
|
|
161
|
+
const payload = JSON.stringify({ data, prevHash, timestamp: Date.now() });
|
|
162
|
+
return crypto.createHash('sha256').update(payload).digest('hex');
|
|
163
|
+
}
|
|
164
|
+
// ============================================================
|
|
165
|
+
// Execution Governor Class
|
|
166
|
+
// ============================================================
|
|
167
|
+
export class ExecutionGovernor {
|
|
168
|
+
config;
|
|
169
|
+
agentStates = new Map();
|
|
170
|
+
auditChain = [];
|
|
171
|
+
decisionHistory = new Map();
|
|
172
|
+
constructor(config = {}) {
|
|
173
|
+
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
174
|
+
logger.info('ExecutionGovernor initialized', {
|
|
175
|
+
policiesCount: this.config.policies.length,
|
|
176
|
+
auditChainEnabled: this.config.enableAuditChain,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Process a tool call and return governance decision
|
|
181
|
+
*/
|
|
182
|
+
async gate(envelope) {
|
|
183
|
+
// Auto-classify action if not provided
|
|
184
|
+
if (envelope.action === 'unknown') {
|
|
185
|
+
envelope.action = classifyAction(envelope.toolName, envelope.args);
|
|
186
|
+
}
|
|
187
|
+
// Calculate heuristic signals
|
|
188
|
+
const signals = calculateHeuristics(envelope);
|
|
189
|
+
// Get or create agent state
|
|
190
|
+
let state = this.agentStates.get(envelope.context.sessionId);
|
|
191
|
+
if (!state) {
|
|
192
|
+
state = this.createAgentState(envelope.context.sessionId);
|
|
193
|
+
this.agentStates.set(envelope.context.sessionId, state);
|
|
194
|
+
}
|
|
195
|
+
// Check for lockdown
|
|
196
|
+
if (state.locked && this.config.enableSeverityLockdown) {
|
|
197
|
+
const decision = this.createDecision(envelope, 'deny', 'Agent is in lockdown due to severity threshold breach');
|
|
198
|
+
this.appendAudit('decision', decision);
|
|
199
|
+
return decision;
|
|
200
|
+
}
|
|
201
|
+
// Check severity thresholds
|
|
202
|
+
if (this.config.enableSeverityLockdown) {
|
|
203
|
+
const severityLevel = this.checkSeverityThreshold(state);
|
|
204
|
+
if (severityLevel) {
|
|
205
|
+
state.lockdownCount++;
|
|
206
|
+
if (state.lockdownCount >= this.config.maxLockdownCount) {
|
|
207
|
+
state.locked = true;
|
|
208
|
+
this.appendAudit('lockdown', { sessionId: envelope.context.sessionId, count: state.lockdownCount });
|
|
209
|
+
logger.warn('Agent locked due to severity threshold', { sessionId: envelope.context.sessionId });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Find matching policy
|
|
214
|
+
const policy = this.findMatchingPolicy(envelope.action);
|
|
215
|
+
// Evaluate bounds if present
|
|
216
|
+
let reason = 'Policy evaluation';
|
|
217
|
+
let decision = 'allow';
|
|
218
|
+
if (policy) {
|
|
219
|
+
if (!policy.allow) {
|
|
220
|
+
decision = 'deny';
|
|
221
|
+
reason = `Policy ${policy.id} explicitly denies ${envelope.action}`;
|
|
222
|
+
}
|
|
223
|
+
else if (policy.conditions?.requireConfirmation) {
|
|
224
|
+
decision = 'escalate';
|
|
225
|
+
reason = `${envelope.action} requires operator confirmation`;
|
|
226
|
+
}
|
|
227
|
+
else if (policy.conditions?.bounds) {
|
|
228
|
+
const boundCheck = this.checkBounds(envelope, policy.conditions.bounds);
|
|
229
|
+
if (!boundCheck.allowed) {
|
|
230
|
+
decision = 'deny';
|
|
231
|
+
reason = boundCheck.reason || 'Bounds check failed';
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Update severity counts
|
|
235
|
+
if (this.config.enableSeverityLockdown && decision !== 'allow') {
|
|
236
|
+
state.severityCounts[policy.severity] = (state.severityCounts[policy.severity] || 0) + 1;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
decision = this.config.defaultPolicy.allow ? 'allow' : 'deny';
|
|
241
|
+
reason = `No matching policy, using default: ${this.config.defaultPolicy.allow ? 'allow' : 'deny'}`;
|
|
242
|
+
}
|
|
243
|
+
// Update state
|
|
244
|
+
state.totalToolCalls++;
|
|
245
|
+
state.lastActivity = Date.now();
|
|
246
|
+
// Create decision
|
|
247
|
+
const governanceDecision = this.createDecision(envelope, decision, reason, policy ?? undefined);
|
|
248
|
+
this.decisionHistory.set(envelope.id, governanceDecision);
|
|
249
|
+
this.appendAudit('decision', governanceDecision);
|
|
250
|
+
if (decision === 'deny') {
|
|
251
|
+
logger.info('Tool call denied', { envelopeId: envelope.id, action: envelope.action, reason });
|
|
252
|
+
}
|
|
253
|
+
else if (decision === 'escalate') {
|
|
254
|
+
logger.info('Tool call escalated', { envelopeId: envelope.id, action: envelope.action, reason });
|
|
255
|
+
}
|
|
256
|
+
return governanceDecision;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Create a new tool call envelope
|
|
260
|
+
*/
|
|
261
|
+
createEnvelope(toolName, args, context) {
|
|
262
|
+
return {
|
|
263
|
+
id: crypto.randomUUID(),
|
|
264
|
+
action: classifyAction(toolName, args),
|
|
265
|
+
toolName,
|
|
266
|
+
args,
|
|
267
|
+
context,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Unlock a locked agent
|
|
272
|
+
*/
|
|
273
|
+
unlock(sessionId) {
|
|
274
|
+
const state = this.agentStates.get(sessionId);
|
|
275
|
+
if (!state)
|
|
276
|
+
return false;
|
|
277
|
+
state.locked = false;
|
|
278
|
+
state.lockdownCount = 0;
|
|
279
|
+
state.severityCounts = {};
|
|
280
|
+
this.appendAudit('unlock', { sessionId });
|
|
281
|
+
logger.info('Agent unlocked', { sessionId });
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Reset agent state
|
|
286
|
+
*/
|
|
287
|
+
resetState(sessionId) {
|
|
288
|
+
const state = this.agentStates.get(sessionId);
|
|
289
|
+
if (!state)
|
|
290
|
+
return false;
|
|
291
|
+
const newState = this.createAgentState(sessionId);
|
|
292
|
+
this.agentStates.set(sessionId, newState);
|
|
293
|
+
this.appendAudit('state_reset', { sessionId });
|
|
294
|
+
logger.info('Agent state reset', { sessionId });
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Get agent state
|
|
299
|
+
*/
|
|
300
|
+
getAgentState(sessionId) {
|
|
301
|
+
return this.agentStates.get(sessionId) || null;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Get audit chain
|
|
305
|
+
*/
|
|
306
|
+
getAuditChain() {
|
|
307
|
+
return [...this.auditChain];
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Get decision from history
|
|
311
|
+
*/
|
|
312
|
+
getDecision(envelopeId) {
|
|
313
|
+
return this.decisionHistory.get(envelopeId) || null;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Verify audit chain integrity
|
|
317
|
+
*/
|
|
318
|
+
verifyAuditChain() {
|
|
319
|
+
for (let i = 1; i < this.auditChain.length; i++) {
|
|
320
|
+
if (this.auditChain[i].prevHash !== this.auditChain[i - 1].hash) {
|
|
321
|
+
return { valid: false, brokenAt: i };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return { valid: true };
|
|
325
|
+
}
|
|
326
|
+
// Private helper methods
|
|
327
|
+
createAgentState(sessionId) {
|
|
328
|
+
return {
|
|
329
|
+
sessionId,
|
|
330
|
+
severityCounts: {},
|
|
331
|
+
lockdownCount: 0,
|
|
332
|
+
totalToolCalls: 0,
|
|
333
|
+
lastActivity: Date.now(),
|
|
334
|
+
locked: false,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
checkSeverityThreshold(state) {
|
|
338
|
+
const counts = state.severityCounts;
|
|
339
|
+
if ((counts['critical'] || 0) >= this.config.severityThresholds.critical)
|
|
340
|
+
return 'critical';
|
|
341
|
+
if ((counts['high'] || 0) >= this.config.severityThresholds.high)
|
|
342
|
+
return 'high';
|
|
343
|
+
if ((counts['medium'] || 0) >= this.config.severityThresholds.medium)
|
|
344
|
+
return 'medium';
|
|
345
|
+
if ((counts['low'] || 0) >= this.config.severityThresholds.low)
|
|
346
|
+
return 'low';
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
findMatchingPolicy(action) {
|
|
350
|
+
return this.config.policies.find(p => p.action === action) || null;
|
|
351
|
+
}
|
|
352
|
+
checkBounds(envelope, bounds) {
|
|
353
|
+
if (!bounds)
|
|
354
|
+
return { allowed: true };
|
|
355
|
+
// Check filesystem bounds
|
|
356
|
+
if (bounds.allowedPaths || bounds.deniedPaths) {
|
|
357
|
+
const path = this.extractPath(envelope.args);
|
|
358
|
+
if (path) {
|
|
359
|
+
if (bounds.deniedPaths?.some(p => path.includes(p))) {
|
|
360
|
+
return { allowed: false, reason: `Path ${path} is in denied list` };
|
|
361
|
+
}
|
|
362
|
+
if (bounds.allowedPaths && !bounds.allowedPaths.some(p => path.includes(p))) {
|
|
363
|
+
return { allowed: false, reason: `Path ${path} is not in allowed list` };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// Check data size
|
|
368
|
+
if (bounds.maxDataSize) {
|
|
369
|
+
const size = this.estimateDataSize(envelope.args);
|
|
370
|
+
if (size > bounds.maxDataSize) {
|
|
371
|
+
return { allowed: false, reason: `Data size ${size} exceeds limit ${bounds.maxDataSize}` };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return { allowed: true };
|
|
375
|
+
}
|
|
376
|
+
extractPath(args) {
|
|
377
|
+
for (const key of ['path', 'file', 'filePath', 'target', 'destination']) {
|
|
378
|
+
if (args[key] && typeof args[key] === 'string') {
|
|
379
|
+
return args[key];
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
estimateDataSize(args) {
|
|
385
|
+
try {
|
|
386
|
+
return JSON.stringify(args).length;
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return 0;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
createDecision(envelope, decision, reason, policy) {
|
|
393
|
+
const decisionData = {
|
|
394
|
+
envelopeId: envelope.id,
|
|
395
|
+
action: envelope.action,
|
|
396
|
+
decision,
|
|
397
|
+
reason,
|
|
398
|
+
timestamp: Date.now(),
|
|
399
|
+
};
|
|
400
|
+
return {
|
|
401
|
+
...decisionData,
|
|
402
|
+
matchedPolicy: policy,
|
|
403
|
+
hash: hashData(decisionData, this.auditChain.length > 0 ? this.auditChain[this.auditChain.length - 1].hash : 'genesis'),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
appendAudit(type, payload) {
|
|
407
|
+
if (!this.config.enableAuditChain)
|
|
408
|
+
return;
|
|
409
|
+
const event = {
|
|
410
|
+
type,
|
|
411
|
+
payload,
|
|
412
|
+
timestamp: Date.now(),
|
|
413
|
+
hash: '',
|
|
414
|
+
prevHash: this.auditChain.length > 0 ? this.auditChain[this.auditChain.length - 1].hash : 'genesis',
|
|
415
|
+
};
|
|
416
|
+
event.hash = hashData({ type, payload, timestamp: event.timestamp }, event.prevHash);
|
|
417
|
+
this.auditChain.push(event);
|
|
418
|
+
// Keep chain bounded to last 10000 events
|
|
419
|
+
if (this.auditChain.length > 10000) {
|
|
420
|
+
this.auditChain = this.auditChain.slice(-5000);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
// ============================================================
|
|
425
|
+
// Factory Function
|
|
426
|
+
// ============================================================
|
|
427
|
+
/**
|
|
428
|
+
* Create an ExecutionGovernor instance with optional custom config
|
|
429
|
+
*/
|
|
430
|
+
export function createExecutionGovernor(config) {
|
|
431
|
+
return new ExecutionGovernor(config);
|
|
432
|
+
}
|
|
433
|
+
// ============================================================
|
|
434
|
+
// Export default config for customization
|
|
435
|
+
// ============================================================
|
|
436
|
+
export { DEFAULT_CONFIG };
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = '2.3.
|
|
1
|
+
export const VERSION = '2.3.5';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mark-improving-agent",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.5",
|
|
4
4
|
"description": "Self-evolving AI agent with permanent memory, identity continuity, and self-evolution — for AI agents that need to remember, learn, and evolve across sessions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|