@eddacraft/anvil-kindling-integration 0.1.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/LICENSE +14 -0
- package/README.md +542 -0
- package/dist/adapter.d.ts +49 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +100 -0
- package/dist/config.d.ts +89 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +173 -0
- package/dist/emitters/action-emitter.d.ts +40 -0
- package/dist/emitters/action-emitter.d.ts.map +1 -0
- package/dist/emitters/action-emitter.js +52 -0
- package/dist/emitters/constraint-emitter.d.ts +32 -0
- package/dist/emitters/constraint-emitter.d.ts.map +1 -0
- package/dist/emitters/constraint-emitter.js +41 -0
- package/dist/emitters/error-emitter.d.ts +33 -0
- package/dist/emitters/error-emitter.d.ts.map +1 -0
- package/dist/emitters/error-emitter.js +50 -0
- package/dist/emitters/gate-emitter.d.ts +37 -0
- package/dist/emitters/gate-emitter.d.ts.map +1 -0
- package/dist/emitters/gate-emitter.js +53 -0
- package/dist/emitters/human-input-emitter.d.ts +30 -0
- package/dist/emitters/human-input-emitter.d.ts.map +1 -0
- package/dist/emitters/human-input-emitter.js +38 -0
- package/dist/emitters/index.d.ts +13 -0
- package/dist/emitters/index.d.ts.map +1 -0
- package/dist/emitters/index.js +19 -0
- package/dist/emitters/plan-emitter.d.ts +75 -0
- package/dist/emitters/plan-emitter.d.ts.map +1 -0
- package/dist/emitters/plan-emitter.js +116 -0
- package/dist/emitters/session-emitter.d.ts +57 -0
- package/dist/emitters/session-emitter.d.ts.map +1 -0
- package/dist/emitters/session-emitter.js +80 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +111 -0
- package/dist/kindling-service.d.ts +122 -0
- package/dist/kindling-service.d.ts.map +1 -0
- package/dist/kindling-service.js +203 -0
- package/dist/observation-contract.d.ts +561 -0
- package/dist/observation-contract.d.ts.map +1 -0
- package/dist/observation-contract.js +391 -0
- package/dist/query-contract.d.ts +463 -0
- package/dist/query-contract.d.ts.map +1 -0
- package/dist/query-contract.js +314 -0
- package/dist/query-limits.d.ts +40 -0
- package/dist/query-limits.d.ts.map +1 -0
- package/dist/query-limits.js +79 -0
- package/dist/query-service.d.ts +109 -0
- package/dist/query-service.d.ts.map +1 -0
- package/dist/query-service.js +140 -0
- package/dist/retention.d.ts +79 -0
- package/dist/retention.d.ts.map +1 -0
- package/dist/retention.js +81 -0
- package/dist/sensitive-data-validator.d.ts +47 -0
- package/dist/sensitive-data-validator.d.ts.map +1 -0
- package/dist/sensitive-data-validator.js +135 -0
- package/dist/status.d.ts +104 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +136 -0
- package/dist/utils/debug.d.ts +9 -0
- package/dist/utils/debug.d.ts.map +1 -0
- package/dist/utils/debug.js +55 -0
- package/package.json +114 -0
- package/src/adapter.ts +117 -0
- package/src/config.ts +202 -0
- package/src/emitters/action-emitter.ts +90 -0
- package/src/emitters/constraint-emitter.ts +73 -0
- package/src/emitters/error-emitter.ts +86 -0
- package/src/emitters/gate-emitter.ts +87 -0
- package/src/emitters/human-input-emitter.ts +71 -0
- package/src/emitters/index.ts +40 -0
- package/src/emitters/plan-emitter.ts +183 -0
- package/src/emitters/session-emitter.ts +131 -0
- package/src/index.ts +254 -0
- package/src/kindling-service.ts +272 -0
- package/src/malicious-ai.test.ts +949 -0
- package/src/observation-contract.ts +500 -0
- package/src/query-contract.ts +389 -0
- package/src/query-limits.ts +106 -0
- package/src/query-service.ts +217 -0
- package/src/retention.ts +153 -0
- package/src/sensitive-data-validator.ts +167 -0
- package/src/status.ts +221 -0
- package/src/utils/debug.ts +65 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kindling Query Contract (v1)
|
|
3
|
+
*
|
|
4
|
+
* Defines the read-only, bounded query surface for Kindling observations.
|
|
5
|
+
* This is a system of record, not a reasoning engine.
|
|
6
|
+
*
|
|
7
|
+
* GOVERNING RULE:
|
|
8
|
+
* Queries may retrieve facts; interpretation is the caller's responsibility.
|
|
9
|
+
* User-supplied AI may read, but may not mutate, infer, or generalise via Kindling.
|
|
10
|
+
*
|
|
11
|
+
* @see plans/modules/kindling-integration.aps.md for integration plan
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// Schema Version
|
|
18
|
+
// =============================================================================
|
|
19
|
+
|
|
20
|
+
export const KINDLING_QUERY_CONTRACT_VERSION = '1.0.0';
|
|
21
|
+
|
|
22
|
+
// =============================================================================
|
|
23
|
+
// Query Scopes (Mandatory Boundary)
|
|
24
|
+
// =============================================================================
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Every query must specify exactly one scope.
|
|
28
|
+
* No free-text search. No global scans. No cross-project reads.
|
|
29
|
+
*/
|
|
30
|
+
export const QueryScopeSchema = z.enum([
|
|
31
|
+
'session', // "What happened in this run?"
|
|
32
|
+
'plan', // "What happened because of this plan?"
|
|
33
|
+
'gate', // "Why did this gate pass/fail?"
|
|
34
|
+
'action', // "What exactly did this action do?"
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export type QueryScope = z.infer<typeof QueryScopeSchema>;
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// Result Shape
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* How results should be structured
|
|
45
|
+
*/
|
|
46
|
+
export const ResultShapeSchema = z.enum([
|
|
47
|
+
'timeline', // Ordered observations grouped by phase
|
|
48
|
+
'list', // Flat list of observations
|
|
49
|
+
'entity', // Single entity with metadata
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
export type ResultShape = z.infer<typeof ResultShapeSchema>;
|
|
53
|
+
|
|
54
|
+
// =============================================================================
|
|
55
|
+
// Output Format
|
|
56
|
+
// =============================================================================
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Result serialisation format
|
|
60
|
+
*/
|
|
61
|
+
export const OutputFormatSchema = z.enum([
|
|
62
|
+
'json', // Machine-readable
|
|
63
|
+
'text', // Human-readable (for CLI)
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
export type OutputFormat = z.infer<typeof OutputFormatSchema>;
|
|
67
|
+
|
|
68
|
+
// =============================================================================
|
|
69
|
+
// Query Request (Base)
|
|
70
|
+
// =============================================================================
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Base query request with mandatory constraints
|
|
74
|
+
*/
|
|
75
|
+
export const QueryRequestBaseSchema = z.object({
|
|
76
|
+
scope: QueryScopeSchema.describe('Query scope (mandatory)'),
|
|
77
|
+
shape: ResultShapeSchema.describe('Result structure (mandatory)'),
|
|
78
|
+
format: OutputFormatSchema.default('json').describe('Output format'),
|
|
79
|
+
|
|
80
|
+
// Time bounds (optional but encouraged)
|
|
81
|
+
time_after: z.string().datetime().optional().describe('Include observations after this time'),
|
|
82
|
+
time_before: z.string().datetime().optional().describe('Include observations before this time'),
|
|
83
|
+
|
|
84
|
+
// Result limits (anti-vacuum-cleaner)
|
|
85
|
+
max_results: z
|
|
86
|
+
.number()
|
|
87
|
+
.int()
|
|
88
|
+
.positive()
|
|
89
|
+
.max(1000)
|
|
90
|
+
.default(100)
|
|
91
|
+
.describe('Maximum observations to return'),
|
|
92
|
+
max_payload_bytes: z
|
|
93
|
+
.number()
|
|
94
|
+
.int()
|
|
95
|
+
.positive()
|
|
96
|
+
.max(10 * 1024 * 1024) // 10MB
|
|
97
|
+
.default(1024 * 1024) // 1MB
|
|
98
|
+
.describe('Maximum total payload size'),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export type QueryRequestBase = z.infer<typeof QueryRequestBaseSchema>;
|
|
102
|
+
|
|
103
|
+
// =============================================================================
|
|
104
|
+
// Session Query
|
|
105
|
+
// =============================================================================
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Query: "What happened in this run?"
|
|
109
|
+
*
|
|
110
|
+
* Returns ordered observations grouped by phase (plan / gate / action / outcome).
|
|
111
|
+
* Raw payloads only, no summaries.
|
|
112
|
+
*/
|
|
113
|
+
export const SessionQuerySchema = QueryRequestBaseSchema.extend({
|
|
114
|
+
scope: z.literal('session'),
|
|
115
|
+
session_id: z.string().uuid().describe('Session/run ID (mandatory)'),
|
|
116
|
+
include_phases: z
|
|
117
|
+
.array(z.enum(['plan', 'gate', 'action', 'outcome', 'error']))
|
|
118
|
+
.optional()
|
|
119
|
+
.describe('Filter to specific phases'),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
export type SessionQuery = z.infer<typeof SessionQuerySchema>;
|
|
123
|
+
|
|
124
|
+
// =============================================================================
|
|
125
|
+
// Plan Query
|
|
126
|
+
// =============================================================================
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Query: "What happened because of this plan?"
|
|
130
|
+
*
|
|
131
|
+
* Returns plan metadata, versions, and linked executions.
|
|
132
|
+
* This is the ONLY cross-session read allowed, via explicit plan_id.
|
|
133
|
+
*/
|
|
134
|
+
export const PlanQuerySchema = QueryRequestBaseSchema.extend({
|
|
135
|
+
scope: z.literal('plan'),
|
|
136
|
+
plan_id: z.string().describe('Plan ID (mandatory)'),
|
|
137
|
+
include_executions: z.boolean().default(true).describe('Include linked execution run IDs'),
|
|
138
|
+
include_versions: z.boolean().default(true).describe('Include plan version history'),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
export type PlanQuery = z.infer<typeof PlanQuerySchema>;
|
|
142
|
+
|
|
143
|
+
// =============================================================================
|
|
144
|
+
// Gate Query
|
|
145
|
+
// =============================================================================
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Query: "Why did this gate pass/fail?"
|
|
149
|
+
*
|
|
150
|
+
* Returns gate evaluation details with rule IDs, inputs (sanitised), and outcomes.
|
|
151
|
+
* No prose. No explanation layer.
|
|
152
|
+
*/
|
|
153
|
+
export const GateQuerySchema = QueryRequestBaseSchema.extend({
|
|
154
|
+
scope: z.literal('gate'),
|
|
155
|
+
gate_eval_id: z.string().describe('Gate evaluation ID (mandatory)'),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
export type GateQuery = z.infer<typeof GateQuerySchema>;
|
|
159
|
+
|
|
160
|
+
// =============================================================================
|
|
161
|
+
// Action Query
|
|
162
|
+
// =============================================================================
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Query: "What exactly did this action do?"
|
|
166
|
+
*
|
|
167
|
+
* Returns action details with redacted command, environment, and linked governance.
|
|
168
|
+
* This is the atomic unit of accountability.
|
|
169
|
+
*/
|
|
170
|
+
export const ActionQuerySchema = QueryRequestBaseSchema.extend({
|
|
171
|
+
scope: z.literal('action'),
|
|
172
|
+
action_id: z.string().describe('Action ID (mandatory)'),
|
|
173
|
+
include_approval_chain: z
|
|
174
|
+
.boolean()
|
|
175
|
+
.default(true)
|
|
176
|
+
.describe('Include approval requirements and state'),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
export type ActionQuery = z.infer<typeof ActionQuerySchema>;
|
|
180
|
+
|
|
181
|
+
// =============================================================================
|
|
182
|
+
// Query Request (Union)
|
|
183
|
+
// =============================================================================
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* All query types (discriminated union)
|
|
187
|
+
*/
|
|
188
|
+
export const QueryRequestSchema = z.discriminatedUnion('scope', [
|
|
189
|
+
SessionQuerySchema,
|
|
190
|
+
PlanQuerySchema,
|
|
191
|
+
GateQuerySchema,
|
|
192
|
+
ActionQuerySchema,
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
export type QueryRequest = z.infer<typeof QueryRequestSchema>;
|
|
196
|
+
|
|
197
|
+
// =============================================================================
|
|
198
|
+
// Query Response (Output Guarantees)
|
|
199
|
+
// =============================================================================
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Standard metadata for all responses
|
|
203
|
+
*/
|
|
204
|
+
export const QueryResponseMetadataSchema = z.object({
|
|
205
|
+
query_id: z.string().uuid().describe('Unique query identifier (for debugging)'),
|
|
206
|
+
executed_at: z.string().datetime().describe('When query was executed'),
|
|
207
|
+
contract_version: z.string().describe('Query contract version used'),
|
|
208
|
+
result_count: z.number().int().nonnegative().describe('Number of observations returned'),
|
|
209
|
+
truncated: z.boolean().describe('Whether results were truncated (hit limits)'),
|
|
210
|
+
truncation_reason: z
|
|
211
|
+
.enum(['max_results', 'max_payload_bytes', 'none'])
|
|
212
|
+
.optional()
|
|
213
|
+
.describe('Why truncation occurred'),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
export type QueryResponseMetadata = z.infer<typeof QueryResponseMetadataSchema>;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Standard provenance link
|
|
220
|
+
*/
|
|
221
|
+
export const ProvenanceLinkSchema = z.object({
|
|
222
|
+
type: z.enum(['caused_by', 'governed_by', 'approved_by', 'linked_to']).describe('Link type'),
|
|
223
|
+
entity_type: z
|
|
224
|
+
.enum(['session', 'plan', 'gate', 'action', 'human'])
|
|
225
|
+
.describe('Target entity type'),
|
|
226
|
+
entity_id: z.string().describe('Target entity ID'),
|
|
227
|
+
timestamp: z.string().datetime().describe('When link was created'),
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
export type ProvenanceLink = z.infer<typeof ProvenanceLinkSchema>;
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Observation (base type for all returned data)
|
|
234
|
+
*/
|
|
235
|
+
export const ObservationSchema = z.object({
|
|
236
|
+
id: z.string().uuid().describe('Observation ID'),
|
|
237
|
+
kind: z
|
|
238
|
+
.enum([
|
|
239
|
+
'session_start',
|
|
240
|
+
'session_end',
|
|
241
|
+
'plan_created',
|
|
242
|
+
'plan_edited',
|
|
243
|
+
'plan_approved',
|
|
244
|
+
'plan_rejected',
|
|
245
|
+
'gate_evaluated',
|
|
246
|
+
'action_executed',
|
|
247
|
+
'constraint_applied',
|
|
248
|
+
'human_input',
|
|
249
|
+
'error',
|
|
250
|
+
])
|
|
251
|
+
.describe('Observation kind'),
|
|
252
|
+
timestamp: z.string().datetime().describe('When observation was recorded'),
|
|
253
|
+
session_id: z.string().uuid().describe('Session this observation belongs to'),
|
|
254
|
+
|
|
255
|
+
// Provenance (explicit links)
|
|
256
|
+
provenance: z.array(ProvenanceLinkSchema).describe('Explicit links to other entities'),
|
|
257
|
+
|
|
258
|
+
// Payload (fact data, no inference)
|
|
259
|
+
payload: z.record(z.string(), z.unknown()).describe('Observation-specific data (raw facts only)'),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
export type Observation = z.infer<typeof ObservationSchema>;
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Query response
|
|
266
|
+
*/
|
|
267
|
+
export const QueryResponseSchema = z.object({
|
|
268
|
+
metadata: QueryResponseMetadataSchema.describe('Query execution metadata'),
|
|
269
|
+
observations: z.array(ObservationSchema).describe('Ordered observations (facts only)'),
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
export type QueryResponse = z.infer<typeof QueryResponseSchema>;
|
|
273
|
+
|
|
274
|
+
// =============================================================================
|
|
275
|
+
// Output Guarantees (Documented Requirements)
|
|
276
|
+
// =============================================================================
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Every Kindling response guarantees:
|
|
280
|
+
*
|
|
281
|
+
* 1. Stable field names - No field names change between queries
|
|
282
|
+
* 2. Explicit timestamps - Every observation has ISO8601 timestamp
|
|
283
|
+
* 3. Explicit links - Provenance via typed links (caused_by, governed_by, approved_by)
|
|
284
|
+
* 4. No hidden inference - Payload contains only raw facts
|
|
285
|
+
* 5. No reordered history - Observations returned in recorded order
|
|
286
|
+
*
|
|
287
|
+
* This makes Kindling LLM-safe by construction.
|
|
288
|
+
*
|
|
289
|
+
* AI can:
|
|
290
|
+
* - Narrate events
|
|
291
|
+
* - Summarise outcomes
|
|
292
|
+
* - Explain facts
|
|
293
|
+
*
|
|
294
|
+
* But AI will always be explaining facts, not ghosts.
|
|
295
|
+
*/
|
|
296
|
+
|
|
297
|
+
// =============================================================================
|
|
298
|
+
// Read-Only Enforcement (Anti-Pattern Markers)
|
|
299
|
+
// =============================================================================
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Operations that MUST NOT exist in the query API:
|
|
303
|
+
*
|
|
304
|
+
* ❌ write()
|
|
305
|
+
* ❌ update()
|
|
306
|
+
* ❌ delete()
|
|
307
|
+
* ❌ annotate()
|
|
308
|
+
* ❌ tag()
|
|
309
|
+
* ❌ learn()
|
|
310
|
+
* ❌ embed()
|
|
311
|
+
* ❌ infer()
|
|
312
|
+
*
|
|
313
|
+
* If user AI wants memory, it must bring its own store.
|
|
314
|
+
*/
|
|
315
|
+
|
|
316
|
+
// =============================================================================
|
|
317
|
+
// Explicit Non-Goals (v1 Boundary)
|
|
318
|
+
// =============================================================================
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The following are explicitly OUT OF SCOPE for v1:
|
|
322
|
+
*
|
|
323
|
+
* ❌ Semantic search
|
|
324
|
+
* ❌ Similarity queries
|
|
325
|
+
* ❌ Embeddings
|
|
326
|
+
* ❌ Cross-plan discovery
|
|
327
|
+
* ❌ Learned relevance
|
|
328
|
+
* ❌ Auto-summaries (stored in Kindling)
|
|
329
|
+
* ❌ AI-generated annotations stored in Kindling
|
|
330
|
+
*
|
|
331
|
+
* These belong to Edda / Ember, not Kindling v1.
|
|
332
|
+
*/
|
|
333
|
+
|
|
334
|
+
// =============================================================================
|
|
335
|
+
// Validation Utilities
|
|
336
|
+
// =============================================================================
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Validate a query request
|
|
340
|
+
*/
|
|
341
|
+
export function validateQueryRequest(data: unknown): {
|
|
342
|
+
success: boolean;
|
|
343
|
+
data?: QueryRequest;
|
|
344
|
+
error?: string;
|
|
345
|
+
} {
|
|
346
|
+
const result = QueryRequestSchema.safeParse(data);
|
|
347
|
+
if (result.success) {
|
|
348
|
+
return { success: true, data: result.data };
|
|
349
|
+
}
|
|
350
|
+
return { success: false, error: result.error.format()._errors.join(', ') };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Validate a query response
|
|
355
|
+
*/
|
|
356
|
+
export function validateQueryResponse(data: unknown): {
|
|
357
|
+
success: boolean;
|
|
358
|
+
data?: QueryResponse;
|
|
359
|
+
error?: string;
|
|
360
|
+
} {
|
|
361
|
+
const result = QueryResponseSchema.safeParse(data);
|
|
362
|
+
if (result.success) {
|
|
363
|
+
return { success: true, data: result.data };
|
|
364
|
+
}
|
|
365
|
+
return { success: false, error: result.error.format()._errors.join(', ') };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// =============================================================================
|
|
369
|
+
// CLI Command Mapping (Human-First, AI-Compatible)
|
|
370
|
+
// =============================================================================
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* All queries have CLI equivalents:
|
|
374
|
+
*
|
|
375
|
+
* anvil run show <run_id> --json
|
|
376
|
+
* → SessionQuery { scope: 'session', session_id: run_id, shape: 'timeline' }
|
|
377
|
+
*
|
|
378
|
+
* anvil plan trace <plan_id> --json
|
|
379
|
+
* → PlanQuery { scope: 'plan', plan_id: plan_id, shape: 'entity' }
|
|
380
|
+
*
|
|
381
|
+
* anvil gate show <gate_eval_id> --json
|
|
382
|
+
* → GateQuery { scope: 'gate', gate_eval_id: gate_eval_id, shape: 'entity' }
|
|
383
|
+
*
|
|
384
|
+
* anvil action show <action_id> --json
|
|
385
|
+
* → ActionQuery { scope: 'action', action_id: action_id, shape: 'entity' }
|
|
386
|
+
*
|
|
387
|
+
* The CLI is a thin wrapper over this query surface.
|
|
388
|
+
* That symmetry is intentional.
|
|
389
|
+
*/
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query Limits Enforcement (KINDLING-010)
|
|
3
|
+
*
|
|
4
|
+
* Provides post-query truncation and metadata flagging.
|
|
5
|
+
* Used by KindlingQueryService before returning results to callers,
|
|
6
|
+
* as a defense-in-depth layer on top of store-level limits.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { QueryResponse, QueryResponseMetadata } from './query-contract.js';
|
|
10
|
+
import type { QueryLimitConfig } from './config.js';
|
|
11
|
+
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// Types
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Limits to enforce on a query response
|
|
18
|
+
*/
|
|
19
|
+
export interface QueryLimits {
|
|
20
|
+
/** Maximum number of observations to return */
|
|
21
|
+
max_results: number;
|
|
22
|
+
/** Maximum total payload size in bytes */
|
|
23
|
+
max_payload_bytes: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// =============================================================================
|
|
27
|
+
// Enforcement
|
|
28
|
+
// =============================================================================
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Enforce query limits on a response by truncating results if necessary.
|
|
32
|
+
*
|
|
33
|
+
* This is applied after the store returns results, as a safety net.
|
|
34
|
+
* If the store already respects limits, this is a no-op.
|
|
35
|
+
*
|
|
36
|
+
* Sets the `truncated` and `truncation_reason` metadata flags when
|
|
37
|
+
* results are truncated.
|
|
38
|
+
*
|
|
39
|
+
* @param response - The query response from the store
|
|
40
|
+
* @param limits - The limits to enforce
|
|
41
|
+
* @returns A new QueryResponse with limits enforced
|
|
42
|
+
*/
|
|
43
|
+
export function enforceQueryLimits(response: QueryResponse, limits: QueryLimits): QueryResponse {
|
|
44
|
+
let observations = response.observations;
|
|
45
|
+
let truncated = response.metadata.truncated;
|
|
46
|
+
let truncationReason = response.metadata.truncation_reason;
|
|
47
|
+
|
|
48
|
+
// Check max_results
|
|
49
|
+
if (observations.length > limits.max_results) {
|
|
50
|
+
observations = observations.slice(0, limits.max_results);
|
|
51
|
+
truncated = true;
|
|
52
|
+
truncationReason = 'max_results';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Check max_payload_bytes
|
|
56
|
+
const serialized = JSON.stringify(observations);
|
|
57
|
+
const payloadBytes = new TextEncoder().encode(serialized).byteLength;
|
|
58
|
+
|
|
59
|
+
if (payloadBytes > limits.max_payload_bytes) {
|
|
60
|
+
// Binary search for the maximum number of observations that fit
|
|
61
|
+
let lo = 0;
|
|
62
|
+
let hi = observations.length;
|
|
63
|
+
|
|
64
|
+
while (lo < hi) {
|
|
65
|
+
const mid = Math.floor((lo + hi + 1) / 2);
|
|
66
|
+
const slice = observations.slice(0, mid);
|
|
67
|
+
const sliceBytes = new TextEncoder().encode(JSON.stringify(slice)).byteLength;
|
|
68
|
+
|
|
69
|
+
if (sliceBytes <= limits.max_payload_bytes) {
|
|
70
|
+
lo = mid;
|
|
71
|
+
} else {
|
|
72
|
+
hi = mid - 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
observations = observations.slice(0, lo);
|
|
77
|
+
truncated = true;
|
|
78
|
+
// Only override reason if not already set (max_results takes precedence)
|
|
79
|
+
truncationReason = truncationReason === 'max_results' ? 'max_results' : 'max_payload_bytes';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const metadata: QueryResponseMetadata = {
|
|
83
|
+
...response.metadata,
|
|
84
|
+
result_count: observations.length,
|
|
85
|
+
truncated,
|
|
86
|
+
truncation_reason: truncated ? truncationReason : 'none',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
metadata,
|
|
91
|
+
observations,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Create QueryLimits from a QueryLimitConfig.
|
|
97
|
+
*
|
|
98
|
+
* @param config - Query limit configuration
|
|
99
|
+
* @returns QueryLimits object
|
|
100
|
+
*/
|
|
101
|
+
export function limitsFromConfig(config: QueryLimitConfig): QueryLimits {
|
|
102
|
+
return {
|
|
103
|
+
max_results: config.max_results,
|
|
104
|
+
max_payload_bytes: config.max_payload_bytes,
|
|
105
|
+
};
|
|
106
|
+
}
|