zouroboros-core 2.0.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 +21 -0
- package/README.md +85 -0
- package/dist/backup.d.ts +65 -0
- package/dist/backup.js +203 -0
- package/dist/commands.d.ts +69 -0
- package/dist/commands.js +307 -0
- package/dist/config/loader.d.ts +48 -0
- package/dist/config/loader.js +145 -0
- package/dist/config/schema.d.ts +597 -0
- package/dist/config/schema.js +151 -0
- package/dist/constants.d.ts +57 -0
- package/dist/constants.js +225 -0
- package/dist/errors.d.ts +79 -0
- package/dist/errors.js +171 -0
- package/dist/hooks.d.ts +81 -0
- package/dist/hooks.js +231 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +21 -0
- package/dist/instincts.d.ts +117 -0
- package/dist/instincts.js +428 -0
- package/dist/migrations.d.ts +56 -0
- package/dist/migrations.js +123 -0
- package/dist/sessions.d.ts +88 -0
- package/dist/sessions.js +275 -0
- package/dist/token-budget.d.ts +76 -0
- package/dist/token-budget.js +196 -0
- package/dist/types.d.ts +406 -0
- package/dist/types.js +6 -0
- package/dist/utils/index.d.ts +55 -0
- package/dist/utils/index.js +132 -0
- package/package.json +50 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ECC-004: Instincts — Pattern Auto-Extraction
|
|
3
|
+
*
|
|
4
|
+
* Automatic extraction of behavioral patterns from sessions.
|
|
5
|
+
* Detects recurring patterns, scores confidence, and extracts
|
|
6
|
+
* hot-loadable instinct files for future sessions.
|
|
7
|
+
* Persists to disk (JSON file) matching selfheal persistence pattern.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
const CONFIDENCE_THRESHOLD = 0.6;
|
|
12
|
+
const MIN_EVIDENCE_FOR_ACTIVE = 3;
|
|
13
|
+
export class InstinctEngine {
|
|
14
|
+
instincts = new Map();
|
|
15
|
+
evidence = new Map();
|
|
16
|
+
lastFired = new Map();
|
|
17
|
+
dataFile = null;
|
|
18
|
+
hooks = null;
|
|
19
|
+
pendingObservations = [];
|
|
20
|
+
constructor(dataDir) {
|
|
21
|
+
if (dataDir) {
|
|
22
|
+
mkdirSync(dataDir, { recursive: true });
|
|
23
|
+
this.dataFile = join(dataDir, 'instincts.json');
|
|
24
|
+
this.load();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
wireHooks(hooks) {
|
|
28
|
+
this.hooks = hooks;
|
|
29
|
+
// Auto-extract from task failures
|
|
30
|
+
hooks.on('task.fail', (payload) => {
|
|
31
|
+
const context = String(payload.data.error || payload.data.detail || '');
|
|
32
|
+
if (context.length > 0) {
|
|
33
|
+
this.recordObservation({
|
|
34
|
+
context,
|
|
35
|
+
outcome: String(payload.data.resolution || 'task failed'),
|
|
36
|
+
timestamp: payload.timestamp,
|
|
37
|
+
sessionId: String(payload.data.sessionId || ''),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}, { priority: 50, description: 'Instinct auto-extraction from task failures' });
|
|
41
|
+
// Auto-extract from error recovery
|
|
42
|
+
hooks.on('error.recovery', (payload) => {
|
|
43
|
+
const context = String(payload.data.error || '');
|
|
44
|
+
const outcome = String(payload.data.recovery || 'recovered');
|
|
45
|
+
if (context.length > 0) {
|
|
46
|
+
this.recordObservation({ context, outcome, timestamp: payload.timestamp });
|
|
47
|
+
}
|
|
48
|
+
}, { priority: 50, description: 'Instinct auto-extraction from error recovery' });
|
|
49
|
+
}
|
|
50
|
+
/** Record a single observation for later batch extraction */
|
|
51
|
+
recordObservation(obs) {
|
|
52
|
+
this.pendingObservations.push(obs);
|
|
53
|
+
// Auto-extract when enough observations accumulate
|
|
54
|
+
if (this.pendingObservations.length >= 5) {
|
|
55
|
+
this.extract(this.pendingObservations.splice(0));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
register(instinct) {
|
|
59
|
+
this.instincts.set(instinct.id, instinct);
|
|
60
|
+
if (!this.evidence.has(instinct.id)) {
|
|
61
|
+
this.evidence.set(instinct.id, []);
|
|
62
|
+
}
|
|
63
|
+
this.save();
|
|
64
|
+
}
|
|
65
|
+
get(id) {
|
|
66
|
+
return this.instincts.get(id) || null;
|
|
67
|
+
}
|
|
68
|
+
list(filter) {
|
|
69
|
+
let results = [...this.instincts.values()];
|
|
70
|
+
if (filter?.status) {
|
|
71
|
+
results = results.filter(i => i.status === filter.status);
|
|
72
|
+
}
|
|
73
|
+
if (filter?.minConfidence !== undefined) {
|
|
74
|
+
results = results.filter(i => i.confidence >= filter.minConfidence);
|
|
75
|
+
}
|
|
76
|
+
if (filter?.tag) {
|
|
77
|
+
results = results.filter(i => i.tags.includes(filter.tag));
|
|
78
|
+
}
|
|
79
|
+
return results.sort((a, b) => b.confidence - a.confidence);
|
|
80
|
+
}
|
|
81
|
+
addEvidence(instinctId, ev) {
|
|
82
|
+
const instinct = this.instincts.get(instinctId);
|
|
83
|
+
if (!instinct)
|
|
84
|
+
return false;
|
|
85
|
+
const evidenceList = this.evidence.get(instinctId) || [];
|
|
86
|
+
evidenceList.push(ev);
|
|
87
|
+
// Keep last 100 evidence items
|
|
88
|
+
if (evidenceList.length > 100) {
|
|
89
|
+
evidenceList.splice(0, evidenceList.length - 100);
|
|
90
|
+
}
|
|
91
|
+
this.evidence.set(instinctId, evidenceList);
|
|
92
|
+
// Update instinct
|
|
93
|
+
instinct.evidenceCount = evidenceList.length;
|
|
94
|
+
instinct.lastSeen = ev.timestamp;
|
|
95
|
+
instinct.pattern.frequency = evidenceList.length;
|
|
96
|
+
// Recalculate confidence
|
|
97
|
+
instinct.confidence = this.calculateConfidence(evidenceList);
|
|
98
|
+
// Auto-promote to active if enough evidence
|
|
99
|
+
if (instinct.status === 'candidate' &&
|
|
100
|
+
instinct.confidence >= CONFIDENCE_THRESHOLD &&
|
|
101
|
+
instinct.evidenceCount >= MIN_EVIDENCE_FOR_ACTIVE) {
|
|
102
|
+
instinct.status = 'active';
|
|
103
|
+
}
|
|
104
|
+
this.save();
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
/** Record a negative match — the instinct fired but was wrong */
|
|
108
|
+
rejectMatch(instinctId) {
|
|
109
|
+
const instinct = this.instincts.get(instinctId);
|
|
110
|
+
if (!instinct)
|
|
111
|
+
return false;
|
|
112
|
+
this.addEvidence(instinctId, {
|
|
113
|
+
timestamp: new Date().toISOString(),
|
|
114
|
+
context: 'rejected by user',
|
|
115
|
+
matchStrength: -0.5,
|
|
116
|
+
});
|
|
117
|
+
// Auto-suspend if confidence drops too low
|
|
118
|
+
if (instinct.confidence < 0.3 && instinct.status === 'active') {
|
|
119
|
+
instinct.status = 'suspended';
|
|
120
|
+
}
|
|
121
|
+
this.save();
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
match(context, event) {
|
|
125
|
+
const matches = [];
|
|
126
|
+
for (const instinct of this.instincts.values()) {
|
|
127
|
+
if (instinct.status !== 'active')
|
|
128
|
+
continue;
|
|
129
|
+
// Check cooldown
|
|
130
|
+
const lastFired = this.lastFired.get(instinct.id) || 0;
|
|
131
|
+
if (Date.now() - lastFired < instinct.trigger.cooldownMs)
|
|
132
|
+
continue;
|
|
133
|
+
// Check event match
|
|
134
|
+
if (event && instinct.trigger.event !== '*' && instinct.trigger.event !== event)
|
|
135
|
+
continue;
|
|
136
|
+
// Check pattern signature match
|
|
137
|
+
const matchStrength = this.computeMatch(instinct, context);
|
|
138
|
+
if (matchStrength > 0.3) {
|
|
139
|
+
const evidence = this.evidence.get(instinct.id) || [];
|
|
140
|
+
matches.push({
|
|
141
|
+
instinct,
|
|
142
|
+
confidence: matchStrength * instinct.confidence,
|
|
143
|
+
evidence: evidence.slice(-3),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return matches.sort((a, b) => b.confidence - a.confidence);
|
|
148
|
+
}
|
|
149
|
+
fire(instinctId) {
|
|
150
|
+
const instinct = this.instincts.get(instinctId);
|
|
151
|
+
if (!instinct || instinct.status !== 'active')
|
|
152
|
+
return false;
|
|
153
|
+
this.lastFired.set(instinctId, Date.now());
|
|
154
|
+
// Emit hook event
|
|
155
|
+
if (this.hooks) {
|
|
156
|
+
this.hooks.emit('instinct.fired', {
|
|
157
|
+
instinctId,
|
|
158
|
+
name: instinct.name,
|
|
159
|
+
confidence: instinct.confidence,
|
|
160
|
+
resolution: instinct.resolution,
|
|
161
|
+
}, 'instinct-engine').catch(() => { });
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
extract(observations) {
|
|
166
|
+
const result = {
|
|
167
|
+
patternsDetected: 0,
|
|
168
|
+
instinctsCreated: 0,
|
|
169
|
+
instinctsUpdated: 0,
|
|
170
|
+
details: [],
|
|
171
|
+
};
|
|
172
|
+
// Group by context similarity using Jaccard index
|
|
173
|
+
const groups = this.groupByContextSimilarity(observations);
|
|
174
|
+
result.patternsDetected = groups.length;
|
|
175
|
+
for (const group of groups) {
|
|
176
|
+
if (group.items.length < 2) {
|
|
177
|
+
result.details.push({ id: '', action: 'skipped', reason: 'insufficient observations' });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const signature = this.generateSignature(group.items);
|
|
181
|
+
const existingId = this.findBySignature(signature);
|
|
182
|
+
if (existingId) {
|
|
183
|
+
for (const item of group.items) {
|
|
184
|
+
this.addEvidence(existingId, {
|
|
185
|
+
timestamp: item.timestamp,
|
|
186
|
+
sessionId: item.sessionId,
|
|
187
|
+
context: item.context.slice(0, 200),
|
|
188
|
+
matchStrength: 0.8,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
result.instinctsUpdated++;
|
|
192
|
+
result.details.push({ id: existingId, action: 'updated' });
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
const id = `instinct-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
196
|
+
const instinct = {
|
|
197
|
+
id,
|
|
198
|
+
name: `Auto: ${signature.slice(0, 40)}`,
|
|
199
|
+
description: `Pattern detected from ${group.items.length} observations`,
|
|
200
|
+
confidence: group.items.length >= MIN_EVIDENCE_FOR_ACTIVE ? 0.7 : 0.4,
|
|
201
|
+
pattern: {
|
|
202
|
+
type: 'custom',
|
|
203
|
+
signature,
|
|
204
|
+
frequency: group.items.length,
|
|
205
|
+
windowSize: group.items.length,
|
|
206
|
+
},
|
|
207
|
+
trigger: {
|
|
208
|
+
event: '*',
|
|
209
|
+
condition: signature,
|
|
210
|
+
cooldownMs: 60000,
|
|
211
|
+
},
|
|
212
|
+
resolution: group.items[0].outcome,
|
|
213
|
+
evidenceCount: group.items.length,
|
|
214
|
+
lastSeen: group.items[group.items.length - 1].timestamp,
|
|
215
|
+
createdAt: new Date().toISOString(),
|
|
216
|
+
status: group.items.length >= MIN_EVIDENCE_FOR_ACTIVE ? 'active' : 'candidate',
|
|
217
|
+
tags: ['auto-extracted'],
|
|
218
|
+
};
|
|
219
|
+
this.instincts.set(id, instinct);
|
|
220
|
+
this.evidence.set(id, []);
|
|
221
|
+
for (const item of group.items) {
|
|
222
|
+
this.addEvidence(id, {
|
|
223
|
+
timestamp: item.timestamp,
|
|
224
|
+
sessionId: item.sessionId,
|
|
225
|
+
context: item.context.slice(0, 200),
|
|
226
|
+
matchStrength: 0.8,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
result.instinctsCreated++;
|
|
230
|
+
result.details.push({ id, action: 'created' });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
this.save();
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
suspend(id) {
|
|
237
|
+
const instinct = this.instincts.get(id);
|
|
238
|
+
if (!instinct)
|
|
239
|
+
return false;
|
|
240
|
+
instinct.status = 'suspended';
|
|
241
|
+
this.save();
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
activate(id) {
|
|
245
|
+
const instinct = this.instincts.get(id);
|
|
246
|
+
if (!instinct)
|
|
247
|
+
return false;
|
|
248
|
+
instinct.status = 'active';
|
|
249
|
+
this.save();
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
retire(id) {
|
|
253
|
+
const instinct = this.instincts.get(id);
|
|
254
|
+
if (!instinct)
|
|
255
|
+
return false;
|
|
256
|
+
instinct.status = 'retired';
|
|
257
|
+
this.save();
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
remove(id) {
|
|
261
|
+
this.evidence.delete(id);
|
|
262
|
+
this.lastFired.delete(id);
|
|
263
|
+
const deleted = this.instincts.delete(id);
|
|
264
|
+
if (deleted)
|
|
265
|
+
this.save();
|
|
266
|
+
return deleted;
|
|
267
|
+
}
|
|
268
|
+
getStats() {
|
|
269
|
+
const all = [...this.instincts.values()];
|
|
270
|
+
const byStatus = { candidate: 0, active: 0, suspended: 0, retired: 0 };
|
|
271
|
+
let totalConfidence = 0;
|
|
272
|
+
let totalEvidence = 0;
|
|
273
|
+
for (const inst of all) {
|
|
274
|
+
byStatus[inst.status] = (byStatus[inst.status] || 0) + 1;
|
|
275
|
+
totalConfidence += inst.confidence;
|
|
276
|
+
totalEvidence += inst.evidenceCount;
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
total: all.length,
|
|
280
|
+
byStatus: byStatus,
|
|
281
|
+
avgConfidence: all.length > 0 ? totalConfidence / all.length : 0,
|
|
282
|
+
totalEvidence,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
exportInstinct(id) {
|
|
286
|
+
const instinct = this.instincts.get(id);
|
|
287
|
+
if (!instinct)
|
|
288
|
+
return null;
|
|
289
|
+
return {
|
|
290
|
+
...instinct,
|
|
291
|
+
evidence: this.evidence.get(id) || [],
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
importInstinct(data) {
|
|
295
|
+
const { evidence: evidenceData, ...instinct } = data;
|
|
296
|
+
this.instincts.set(instinct.id, instinct);
|
|
297
|
+
if (evidenceData) {
|
|
298
|
+
this.evidence.set(instinct.id, evidenceData);
|
|
299
|
+
}
|
|
300
|
+
if (!this.evidence.has(instinct.id)) {
|
|
301
|
+
this.evidence.set(instinct.id, []);
|
|
302
|
+
}
|
|
303
|
+
this.save();
|
|
304
|
+
}
|
|
305
|
+
calculateConfidence(evidenceList) {
|
|
306
|
+
if (evidenceList.length === 0)
|
|
307
|
+
return 0;
|
|
308
|
+
const now = Date.now();
|
|
309
|
+
let weightedSum = 0;
|
|
310
|
+
let totalWeight = 0;
|
|
311
|
+
for (const ev of evidenceList) {
|
|
312
|
+
const age = now - new Date(ev.timestamp).getTime();
|
|
313
|
+
const recencyWeight = Math.exp(-age / (7 * 24 * 3600 * 1000)); // 7-day half-life
|
|
314
|
+
const weight = recencyWeight + 0.1;
|
|
315
|
+
weightedSum += ev.matchStrength * weight;
|
|
316
|
+
totalWeight += weight;
|
|
317
|
+
}
|
|
318
|
+
const avgStrength = weightedSum / totalWeight;
|
|
319
|
+
const countFactor = Math.min(evidenceList.length / 10, 1);
|
|
320
|
+
return Math.max(Math.min(avgStrength * (0.5 + 0.5 * countFactor), 1.0), 0);
|
|
321
|
+
}
|
|
322
|
+
computeMatch(instinct, context) {
|
|
323
|
+
const sigWords = instinct.pattern.signature.toLowerCase().split(/\s+/).filter(w => w.length >= 3);
|
|
324
|
+
const contextLower = context.toLowerCase();
|
|
325
|
+
if (sigWords.length === 0)
|
|
326
|
+
return 0;
|
|
327
|
+
// Use substring matching for better accuracy
|
|
328
|
+
let matches = 0;
|
|
329
|
+
for (const word of sigWords) {
|
|
330
|
+
if (contextLower.includes(word))
|
|
331
|
+
matches++;
|
|
332
|
+
}
|
|
333
|
+
return matches / sigWords.length;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Group observations by context similarity using Jaccard index on word sets.
|
|
337
|
+
*/
|
|
338
|
+
groupByContextSimilarity(observations) {
|
|
339
|
+
if (observations.length === 0)
|
|
340
|
+
return [];
|
|
341
|
+
const wordSets = observations.map(obs => new Set(obs.context.toLowerCase().split(/\s+/).filter(w => w.length >= 3)));
|
|
342
|
+
const assigned = new Array(observations.length).fill(false);
|
|
343
|
+
const groups = [];
|
|
344
|
+
for (let i = 0; i < observations.length; i++) {
|
|
345
|
+
if (assigned[i])
|
|
346
|
+
continue;
|
|
347
|
+
assigned[i] = true;
|
|
348
|
+
const group = [observations[i]];
|
|
349
|
+
for (let j = i + 1; j < observations.length; j++) {
|
|
350
|
+
if (assigned[j])
|
|
351
|
+
continue;
|
|
352
|
+
const jaccard = this.jaccardSimilarity(wordSets[i], wordSets[j]);
|
|
353
|
+
if (jaccard >= 0.4) {
|
|
354
|
+
group.push(observations[j]);
|
|
355
|
+
assigned[j] = true;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
groups.push({ items: group });
|
|
359
|
+
}
|
|
360
|
+
return groups;
|
|
361
|
+
}
|
|
362
|
+
jaccardSimilarity(a, b) {
|
|
363
|
+
if (a.size === 0 && b.size === 0)
|
|
364
|
+
return 1;
|
|
365
|
+
let intersection = 0;
|
|
366
|
+
for (const word of a) {
|
|
367
|
+
if (b.has(word))
|
|
368
|
+
intersection++;
|
|
369
|
+
}
|
|
370
|
+
const union = a.size + b.size - intersection;
|
|
371
|
+
return union > 0 ? intersection / union : 0;
|
|
372
|
+
}
|
|
373
|
+
generateSignature(items) {
|
|
374
|
+
const wordCounts = new Map();
|
|
375
|
+
for (const item of items) {
|
|
376
|
+
const words = new Set(item.context.toLowerCase().split(/\s+/).filter(w => w.length >= 3));
|
|
377
|
+
for (const word of words) {
|
|
378
|
+
wordCounts.set(word, (wordCounts.get(word) || 0) + 1);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const threshold = Math.ceil(items.length * 0.5);
|
|
382
|
+
const commonWords = [...wordCounts.entries()]
|
|
383
|
+
.filter(([, count]) => count >= threshold)
|
|
384
|
+
.sort((a, b) => b[1] - a[1])
|
|
385
|
+
.slice(0, 10)
|
|
386
|
+
.map(([word]) => word)
|
|
387
|
+
.sort(); // Sort alphabetically for stable signatures
|
|
388
|
+
return commonWords.join(' ') || items[0].context.slice(0, 50);
|
|
389
|
+
}
|
|
390
|
+
findBySignature(signature) {
|
|
391
|
+
const sigWords = new Set(signature.split(' '));
|
|
392
|
+
for (const [id, instinct] of this.instincts) {
|
|
393
|
+
const existingWords = new Set(instinct.pattern.signature.split(' '));
|
|
394
|
+
const jaccard = this.jaccardSimilarity(sigWords, existingWords);
|
|
395
|
+
if (jaccard >= 0.7) {
|
|
396
|
+
return id;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
load() {
|
|
402
|
+
if (!this.dataFile || !existsSync(this.dataFile))
|
|
403
|
+
return;
|
|
404
|
+
try {
|
|
405
|
+
const store = JSON.parse(readFileSync(this.dataFile, 'utf-8'));
|
|
406
|
+
for (const instinct of store.instincts) {
|
|
407
|
+
this.instincts.set(instinct.id, instinct);
|
|
408
|
+
}
|
|
409
|
+
for (const [id, evidenceList] of Object.entries(store.evidence)) {
|
|
410
|
+
this.evidence.set(id, evidenceList);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
catch { /* start fresh if corrupt */ }
|
|
414
|
+
}
|
|
415
|
+
save() {
|
|
416
|
+
if (!this.dataFile)
|
|
417
|
+
return;
|
|
418
|
+
const store = {
|
|
419
|
+
version: '1.0.0',
|
|
420
|
+
instincts: [...this.instincts.values()],
|
|
421
|
+
evidence: Object.fromEntries(this.evidence),
|
|
422
|
+
};
|
|
423
|
+
writeFileSync(this.dataFile, JSON.stringify(store, null, 2));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
export function createInstinctEngine(dataDir) {
|
|
427
|
+
return new InstinctEngine(dataDir);
|
|
428
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Versioned database migration system for Zouroboros.
|
|
3
|
+
*
|
|
4
|
+
* Migrations are defined as numbered entries with up/down SQL.
|
|
5
|
+
* The system tracks which migrations have been applied in a
|
|
6
|
+
* `_migrations` table and applies them in order.
|
|
7
|
+
*/
|
|
8
|
+
export interface Migration {
|
|
9
|
+
id: number;
|
|
10
|
+
name: string;
|
|
11
|
+
up: string;
|
|
12
|
+
down: string;
|
|
13
|
+
}
|
|
14
|
+
export interface MigrationRecord {
|
|
15
|
+
id: number;
|
|
16
|
+
name: string;
|
|
17
|
+
applied_at: number;
|
|
18
|
+
}
|
|
19
|
+
export interface MigrationStatus {
|
|
20
|
+
applied: MigrationRecord[];
|
|
21
|
+
pending: Migration[];
|
|
22
|
+
current: number;
|
|
23
|
+
}
|
|
24
|
+
export interface MigrationResult {
|
|
25
|
+
applied: string[];
|
|
26
|
+
skipped: string[];
|
|
27
|
+
errors: {
|
|
28
|
+
name: string;
|
|
29
|
+
error: string;
|
|
30
|
+
}[];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Built-in migrations registry.
|
|
34
|
+
* New migrations are appended here with incrementing IDs.
|
|
35
|
+
* IDs must be sequential and never reused.
|
|
36
|
+
*/
|
|
37
|
+
export declare const MIGRATIONS: Migration[];
|
|
38
|
+
export interface MigrationRunner {
|
|
39
|
+
ensureMigrationsTable(): void;
|
|
40
|
+
getApplied(): MigrationRecord[];
|
|
41
|
+
getStatus(): MigrationStatus;
|
|
42
|
+
migrate(targetId?: number): MigrationResult;
|
|
43
|
+
rollback(targetId: number): MigrationResult;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Create a migration runner for a given database.
|
|
47
|
+
* The `db` parameter must support `.exec(sql)`, `.query(sql).all()`,
|
|
48
|
+
* and `.run(sql, params)` — matching bun:sqlite's Database API.
|
|
49
|
+
*/
|
|
50
|
+
export declare function createMigrationRunner(db: {
|
|
51
|
+
exec(sql: string): void;
|
|
52
|
+
query(sql: string): {
|
|
53
|
+
all(): unknown[];
|
|
54
|
+
};
|
|
55
|
+
run(sql: string, params?: unknown[]): void;
|
|
56
|
+
}): MigrationRunner;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Versioned database migration system for Zouroboros.
|
|
3
|
+
*
|
|
4
|
+
* Migrations are defined as numbered entries with up/down SQL.
|
|
5
|
+
* The system tracks which migrations have been applied in a
|
|
6
|
+
* `_migrations` table and applies them in order.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Built-in migrations registry.
|
|
10
|
+
* New migrations are appended here with incrementing IDs.
|
|
11
|
+
* IDs must be sequential and never reused.
|
|
12
|
+
*/
|
|
13
|
+
export const MIGRATIONS = [
|
|
14
|
+
{
|
|
15
|
+
id: 1,
|
|
16
|
+
name: '001_add_migrations_table',
|
|
17
|
+
up: `
|
|
18
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
19
|
+
id INTEGER PRIMARY KEY,
|
|
20
|
+
name TEXT NOT NULL UNIQUE,
|
|
21
|
+
applied_at INTEGER DEFAULT (strftime('%s', 'now'))
|
|
22
|
+
);
|
|
23
|
+
`,
|
|
24
|
+
down: `DROP TABLE IF EXISTS _migrations;`,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 2,
|
|
28
|
+
name: '002_add_facts_persona_index',
|
|
29
|
+
up: `CREATE INDEX IF NOT EXISTS idx_facts_persona ON facts(persona);`,
|
|
30
|
+
down: `DROP INDEX IF EXISTS idx_facts_persona;`,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 3,
|
|
34
|
+
name: '003_add_facts_confidence_index',
|
|
35
|
+
up: `CREATE INDEX IF NOT EXISTS idx_facts_confidence ON facts(confidence);`,
|
|
36
|
+
down: `DROP INDEX IF EXISTS idx_facts_confidence;`,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 4,
|
|
40
|
+
name: '004_add_episodes_procedure_index',
|
|
41
|
+
up: `CREATE INDEX IF NOT EXISTS idx_episodes_procedure ON episodes(procedure_id);`,
|
|
42
|
+
down: `DROP INDEX IF EXISTS idx_episodes_procedure;`,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 5,
|
|
46
|
+
name: '005_add_open_loops_priority_index',
|
|
47
|
+
up: `CREATE INDEX IF NOT EXISTS idx_open_loops_priority ON open_loops(priority, status);`,
|
|
48
|
+
down: `DROP INDEX IF EXISTS idx_open_loops_priority;`,
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
/**
|
|
52
|
+
* Create a migration runner for a given database.
|
|
53
|
+
* The `db` parameter must support `.exec(sql)`, `.query(sql).all()`,
|
|
54
|
+
* and `.run(sql, params)` — matching bun:sqlite's Database API.
|
|
55
|
+
*/
|
|
56
|
+
export function createMigrationRunner(db) {
|
|
57
|
+
function ensureMigrationsTable() {
|
|
58
|
+
db.exec(MIGRATIONS[0].up);
|
|
59
|
+
}
|
|
60
|
+
function getApplied() {
|
|
61
|
+
ensureMigrationsTable();
|
|
62
|
+
return db.query('SELECT id, name, applied_at FROM _migrations ORDER BY id')
|
|
63
|
+
.all();
|
|
64
|
+
}
|
|
65
|
+
function getStatus() {
|
|
66
|
+
const applied = getApplied();
|
|
67
|
+
const appliedIds = new Set(applied.map((m) => m.id));
|
|
68
|
+
const pending = MIGRATIONS.filter((m) => !appliedIds.has(m.id));
|
|
69
|
+
const current = applied.length > 0 ? Math.max(...applied.map((m) => m.id)) : 0;
|
|
70
|
+
return { applied, pending, current };
|
|
71
|
+
}
|
|
72
|
+
function migrate(targetId) {
|
|
73
|
+
const { pending } = getStatus();
|
|
74
|
+
const target = targetId ?? Math.max(...MIGRATIONS.map((m) => m.id));
|
|
75
|
+
const toApply = pending
|
|
76
|
+
.filter((m) => m.id <= target)
|
|
77
|
+
.sort((a, b) => a.id - b.id);
|
|
78
|
+
const result = { applied: [], skipped: [], errors: [] };
|
|
79
|
+
for (const migration of toApply) {
|
|
80
|
+
try {
|
|
81
|
+
db.exec(migration.up);
|
|
82
|
+
db.run('INSERT OR IGNORE INTO _migrations (id, name) VALUES (?, ?)', [migration.id, migration.name]);
|
|
83
|
+
result.applied.push(migration.name);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
result.errors.push({
|
|
87
|
+
name: migration.name,
|
|
88
|
+
error: err instanceof Error ? err.message : String(err),
|
|
89
|
+
});
|
|
90
|
+
break; // stop on first error
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
function rollback(targetId) {
|
|
96
|
+
const { applied } = getStatus();
|
|
97
|
+
const toRollback = applied
|
|
98
|
+
.filter((m) => m.id > targetId)
|
|
99
|
+
.sort((a, b) => b.id - a.id); // reverse order
|
|
100
|
+
const result = { applied: [], skipped: [], errors: [] };
|
|
101
|
+
for (const record of toRollback) {
|
|
102
|
+
const migration = MIGRATIONS.find((m) => m.id === record.id);
|
|
103
|
+
if (!migration) {
|
|
104
|
+
result.skipped.push(`${record.name} (no migration definition found)`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
db.exec(migration.down);
|
|
109
|
+
db.run('DELETE FROM _migrations WHERE id = ?', [migration.id]);
|
|
110
|
+
result.applied.push(migration.name);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
result.errors.push({
|
|
114
|
+
name: migration.name,
|
|
115
|
+
error: err instanceof Error ? err.message : String(err),
|
|
116
|
+
});
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
return { ensureMigrationsTable, getApplied, getStatus, migrate, rollback };
|
|
123
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ECC-003: Session Management
|
|
3
|
+
*
|
|
4
|
+
* Active session capabilities: branching, search, compaction, and metrics.
|
|
5
|
+
* Persists to disk (JSON file) matching selfheal persistence pattern.
|
|
6
|
+
* Integrates with hook system for lifecycle events.
|
|
7
|
+
*/
|
|
8
|
+
import type { HookSystem } from './hooks.js';
|
|
9
|
+
export interface Session {
|
|
10
|
+
id: string;
|
|
11
|
+
parentId?: string;
|
|
12
|
+
name: string;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
updatedAt: string;
|
|
15
|
+
status: SessionStatus;
|
|
16
|
+
metadata: Record<string, unknown>;
|
|
17
|
+
metrics: SessionMetrics;
|
|
18
|
+
entries: SessionEntry[];
|
|
19
|
+
}
|
|
20
|
+
export type SessionStatus = 'active' | 'paused' | 'completed' | 'archived' | 'branched';
|
|
21
|
+
export interface SessionEntry {
|
|
22
|
+
id: string;
|
|
23
|
+
timestamp: string;
|
|
24
|
+
type: 'message' | 'tool_call' | 'tool_result' | 'checkpoint' | 'note';
|
|
25
|
+
content: string;
|
|
26
|
+
tokens?: number;
|
|
27
|
+
tags?: string[];
|
|
28
|
+
}
|
|
29
|
+
export interface SessionMetrics {
|
|
30
|
+
totalTokens: number;
|
|
31
|
+
entryCount: number;
|
|
32
|
+
toolCalls: number;
|
|
33
|
+
duration: number;
|
|
34
|
+
checkpoints: number;
|
|
35
|
+
}
|
|
36
|
+
export interface SessionSearchResult {
|
|
37
|
+
sessionId: string;
|
|
38
|
+
entryId: string;
|
|
39
|
+
content: string;
|
|
40
|
+
score: number;
|
|
41
|
+
timestamp: string;
|
|
42
|
+
}
|
|
43
|
+
export interface CompactionResult {
|
|
44
|
+
sessionId: string;
|
|
45
|
+
entriesBefore: number;
|
|
46
|
+
entriesAfter: number;
|
|
47
|
+
tokensBefore: number;
|
|
48
|
+
tokensAfter: number;
|
|
49
|
+
summary: string;
|
|
50
|
+
}
|
|
51
|
+
export declare class SessionManager {
|
|
52
|
+
private sessions;
|
|
53
|
+
private dataFile;
|
|
54
|
+
private hooks;
|
|
55
|
+
constructor(dataDir?: string);
|
|
56
|
+
wireHooks(hooks: HookSystem): void;
|
|
57
|
+
create(name: string, metadata?: Record<string, unknown>): Session;
|
|
58
|
+
get(sessionId: string): Session | null;
|
|
59
|
+
list(filter?: {
|
|
60
|
+
status?: SessionStatus;
|
|
61
|
+
}): Session[];
|
|
62
|
+
addEntry(sessionId: string, entry: Omit<SessionEntry, 'id'>): SessionEntry | null;
|
|
63
|
+
branch(sessionId: string, branchName: string, options?: {
|
|
64
|
+
fromEntryIndex?: number;
|
|
65
|
+
freezeParent?: boolean;
|
|
66
|
+
}): Session | null;
|
|
67
|
+
search(query: string, options?: {
|
|
68
|
+
sessionId?: string;
|
|
69
|
+
limit?: number;
|
|
70
|
+
}): SessionSearchResult[];
|
|
71
|
+
compact(sessionId: string, summarizer?: (entries: SessionEntry[]) => string): CompactionResult | null;
|
|
72
|
+
getMetrics(sessionId: string): SessionMetrics | null;
|
|
73
|
+
getAggregateMetrics(): {
|
|
74
|
+
totalSessions: number;
|
|
75
|
+
activeSessions: number;
|
|
76
|
+
totalTokens: number;
|
|
77
|
+
totalEntries: number;
|
|
78
|
+
avgTokensPerSession: number;
|
|
79
|
+
avgEntriesPerSession: number;
|
|
80
|
+
};
|
|
81
|
+
updateStatus(sessionId: string, status: SessionStatus): boolean;
|
|
82
|
+
delete(sessionId: string): boolean;
|
|
83
|
+
clear(): void;
|
|
84
|
+
private defaultSummarize;
|
|
85
|
+
private load;
|
|
86
|
+
private save;
|
|
87
|
+
}
|
|
88
|
+
export declare function createSessionManager(dataDir?: string): SessionManager;
|