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.
@@ -0,0 +1,275 @@
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 { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
9
+ import { join } from 'path';
10
+ export class SessionManager {
11
+ sessions = new Map();
12
+ dataFile = null;
13
+ hooks = null;
14
+ constructor(dataDir) {
15
+ if (dataDir) {
16
+ mkdirSync(dataDir, { recursive: true });
17
+ this.dataFile = join(dataDir, 'sessions.json');
18
+ this.load();
19
+ }
20
+ }
21
+ wireHooks(hooks) {
22
+ this.hooks = hooks;
23
+ }
24
+ create(name, metadata = {}) {
25
+ const id = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
26
+ const session = {
27
+ id,
28
+ name,
29
+ createdAt: new Date().toISOString(),
30
+ updatedAt: new Date().toISOString(),
31
+ status: 'active',
32
+ metadata,
33
+ metrics: { totalTokens: 0, entryCount: 0, toolCalls: 0, duration: 0, checkpoints: 0 },
34
+ entries: [],
35
+ };
36
+ this.sessions.set(id, session);
37
+ this.save();
38
+ return session;
39
+ }
40
+ get(sessionId) {
41
+ return this.sessions.get(sessionId) || null;
42
+ }
43
+ list(filter) {
44
+ let sessions = [...this.sessions.values()];
45
+ if (filter?.status) {
46
+ sessions = sessions.filter(s => s.status === filter.status);
47
+ }
48
+ return sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
49
+ }
50
+ addEntry(sessionId, entry) {
51
+ const session = this.sessions.get(sessionId);
52
+ if (!session)
53
+ return null;
54
+ const fullEntry = {
55
+ id: `entry-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
56
+ ...entry,
57
+ };
58
+ session.entries.push(fullEntry);
59
+ session.updatedAt = new Date().toISOString();
60
+ // Update metrics
61
+ session.metrics.entryCount = session.entries.length;
62
+ if (entry.tokens)
63
+ session.metrics.totalTokens += entry.tokens;
64
+ if (entry.type === 'tool_call')
65
+ session.metrics.toolCalls++;
66
+ if (entry.type === 'checkpoint')
67
+ session.metrics.checkpoints++;
68
+ if (session.entries.length >= 2) {
69
+ const first = new Date(session.entries[0].timestamp).getTime();
70
+ const last = new Date(fullEntry.timestamp).getTime();
71
+ session.metrics.duration = last - first;
72
+ }
73
+ this.save();
74
+ return fullEntry;
75
+ }
76
+ branch(sessionId, branchName, options) {
77
+ const parent = this.sessions.get(sessionId);
78
+ if (!parent)
79
+ return null;
80
+ const fromEntryIndex = options?.fromEntryIndex;
81
+ const freezeParent = options?.freezeParent ?? false;
82
+ const entrySlice = fromEntryIndex !== undefined
83
+ ? parent.entries.slice(0, fromEntryIndex)
84
+ : [...parent.entries];
85
+ const branchId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
86
+ const branch = {
87
+ id: branchId,
88
+ parentId: sessionId,
89
+ name: branchName,
90
+ createdAt: new Date().toISOString(),
91
+ updatedAt: new Date().toISOString(),
92
+ status: 'active',
93
+ metadata: { ...parent.metadata, branchedFrom: sessionId },
94
+ metrics: { ...parent.metrics },
95
+ entries: entrySlice.map(e => ({ ...e })),
96
+ };
97
+ // Recalculate metrics for branch
98
+ branch.metrics.entryCount = branch.entries.length;
99
+ branch.metrics.totalTokens = branch.entries.reduce((sum, e) => sum + (e.tokens || 0), 0);
100
+ branch.metrics.toolCalls = branch.entries.filter(e => e.type === 'tool_call').length;
101
+ branch.metrics.checkpoints = branch.entries.filter(e => e.type === 'checkpoint').length;
102
+ // Only freeze parent if explicitly requested
103
+ if (freezeParent) {
104
+ parent.status = 'branched';
105
+ }
106
+ this.sessions.set(branchId, branch);
107
+ this.save();
108
+ // Emit hook event
109
+ if (this.hooks) {
110
+ this.hooks.emit('session.branch', {
111
+ parentId: sessionId,
112
+ branchId,
113
+ branchName,
114
+ entryCount: branch.entries.length,
115
+ }, 'session-manager').catch(() => { });
116
+ }
117
+ return branch;
118
+ }
119
+ search(query, options) {
120
+ const queryWords = query.toLowerCase().replace(/[^\w\s]/g, ' ').split(/\s+/).filter(w => w.length >= 2);
121
+ if (queryWords.length === 0)
122
+ return [];
123
+ const limit = options?.limit || 20;
124
+ const results = [];
125
+ const sessionsToSearch = options?.sessionId
126
+ ? [options.sessionId]
127
+ : [...this.sessions.keys()];
128
+ for (const sid of sessionsToSearch) {
129
+ const session = this.sessions.get(sid);
130
+ if (!session)
131
+ continue;
132
+ for (const entry of session.entries) {
133
+ const entryText = entry.content.toLowerCase();
134
+ const matchCount = queryWords.filter(qw => entryText.includes(qw)).length;
135
+ if (matchCount > 0) {
136
+ const score = matchCount / queryWords.length;
137
+ results.push({
138
+ sessionId: sid,
139
+ entryId: entry.id,
140
+ content: entry.content.slice(0, 200),
141
+ score,
142
+ timestamp: entry.timestamp,
143
+ });
144
+ }
145
+ }
146
+ }
147
+ return results
148
+ .sort((a, b) => b.score - a.score)
149
+ .slice(0, limit);
150
+ }
151
+ compact(sessionId, summarizer) {
152
+ const session = this.sessions.get(sessionId);
153
+ if (!session || session.entries.length === 0)
154
+ return null;
155
+ const entriesBefore = session.entries.length;
156
+ const tokensBefore = session.metrics.totalTokens;
157
+ // Keep last 20% of entries, summarize older ones
158
+ const keepCount = Math.max(Math.ceil(session.entries.length * 0.2), 1);
159
+ const oldEntries = session.entries.slice(0, -keepCount);
160
+ const keptEntries = session.entries.slice(-keepCount);
161
+ if (oldEntries.length === 0)
162
+ return null;
163
+ const summary = summarizer
164
+ ? summarizer(oldEntries)
165
+ : this.defaultSummarize(oldEntries);
166
+ const summaryEntry = {
167
+ id: `entry-compact-${Date.now()}`,
168
+ timestamp: oldEntries[0]?.timestamp || new Date().toISOString(),
169
+ type: 'note',
170
+ content: `[Compacted ${oldEntries.length} entries] ${summary}`,
171
+ tokens: Math.ceil(summary.length / 4),
172
+ };
173
+ session.entries = [summaryEntry, ...keptEntries];
174
+ session.metrics.entryCount = session.entries.length;
175
+ session.metrics.totalTokens = session.entries.reduce((sum, e) => sum + (e.tokens || 0), 0);
176
+ session.updatedAt = new Date().toISOString();
177
+ this.save();
178
+ const result = {
179
+ sessionId,
180
+ entriesBefore,
181
+ entriesAfter: session.entries.length,
182
+ tokensBefore,
183
+ tokensAfter: session.metrics.totalTokens,
184
+ summary,
185
+ };
186
+ // Emit hook event
187
+ if (this.hooks) {
188
+ this.hooks.emit('session.compact', {
189
+ sessionId,
190
+ entriesBefore,
191
+ entriesAfter: result.entriesAfter,
192
+ tokensSaved: tokensBefore - result.tokensAfter,
193
+ }, 'session-manager').catch(() => { });
194
+ }
195
+ return result;
196
+ }
197
+ getMetrics(sessionId) {
198
+ const session = this.sessions.get(sessionId);
199
+ return session ? { ...session.metrics } : null;
200
+ }
201
+ getAggregateMetrics() {
202
+ const sessions = [...this.sessions.values()];
203
+ const totalTokens = sessions.reduce((sum, s) => sum + s.metrics.totalTokens, 0);
204
+ const totalEntries = sessions.reduce((sum, s) => sum + s.metrics.entryCount, 0);
205
+ return {
206
+ totalSessions: sessions.length,
207
+ activeSessions: sessions.filter(s => s.status === 'active').length,
208
+ totalTokens,
209
+ totalEntries,
210
+ avgTokensPerSession: sessions.length > 0 ? totalTokens / sessions.length : 0,
211
+ avgEntriesPerSession: sessions.length > 0 ? totalEntries / sessions.length : 0,
212
+ };
213
+ }
214
+ updateStatus(sessionId, status) {
215
+ const session = this.sessions.get(sessionId);
216
+ if (!session)
217
+ return false;
218
+ session.status = status;
219
+ session.updatedAt = new Date().toISOString();
220
+ this.save();
221
+ return true;
222
+ }
223
+ delete(sessionId) {
224
+ const deleted = this.sessions.delete(sessionId);
225
+ if (deleted)
226
+ this.save();
227
+ return deleted;
228
+ }
229
+ clear() {
230
+ this.sessions.clear();
231
+ this.save();
232
+ }
233
+ defaultSummarize(entries) {
234
+ const types = new Map();
235
+ for (const e of entries) {
236
+ types.set(e.type, (types.get(e.type) || 0) + 1);
237
+ }
238
+ const parts = [...types.entries()]
239
+ .map(([type, count]) => `${count} ${type}${count > 1 ? 's' : ''}`)
240
+ .join(', ');
241
+ const totalTokens = entries.reduce((sum, e) => sum + (e.tokens || 0), 0);
242
+ // Include content snippets from key entries
243
+ const keyEntries = entries
244
+ .filter(e => e.type === 'message' || e.type === 'tool_call')
245
+ .slice(0, 3)
246
+ .map(e => e.content.slice(0, 60));
247
+ const snippets = keyEntries.length > 0
248
+ ? ` Key items: ${keyEntries.join('; ')}`
249
+ : '';
250
+ return `${parts}. ${totalTokens} tokens total.${snippets}`;
251
+ }
252
+ load() {
253
+ if (!this.dataFile || !existsSync(this.dataFile))
254
+ return;
255
+ try {
256
+ const store = JSON.parse(readFileSync(this.dataFile, 'utf-8'));
257
+ for (const session of store.sessions) {
258
+ this.sessions.set(session.id, session);
259
+ }
260
+ }
261
+ catch { /* start fresh if corrupt */ }
262
+ }
263
+ save() {
264
+ if (!this.dataFile)
265
+ return;
266
+ const store = {
267
+ version: '1.0.0',
268
+ sessions: [...this.sessions.values()],
269
+ };
270
+ writeFileSync(this.dataFile, JSON.stringify(store, null, 2));
271
+ }
272
+ }
273
+ export function createSessionManager(dataDir) {
274
+ return new SessionManager(dataDir);
275
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * ECC-005: Token Budget Hook Wiring
3
+ *
4
+ * Systematic token optimization with proactive checkpointing.
5
+ * Monitors context usage and triggers compression/checkpoint hooks.
6
+ */
7
+ import type { HookSystem } from './hooks.js';
8
+ export interface TokenBudgetConfig {
9
+ maxTokens: number;
10
+ warningThreshold: number;
11
+ criticalThreshold: number;
12
+ emergencyThreshold: number;
13
+ compressionStrategy: CompressionStrategy;
14
+ }
15
+ export type CompressionStrategy = 'progressive' | 'aggressive' | 'selective';
16
+ export interface TokenState {
17
+ currentTokens: number;
18
+ maxTokens: number;
19
+ utilizationPercent: number;
20
+ level: 'normal' | 'warning' | 'critical' | 'emergency';
21
+ lastCheckpoint?: string;
22
+ compressionCount: number;
23
+ savedTokens: number;
24
+ }
25
+ export interface CompressionRecord {
26
+ strategy: CompressionStrategy;
27
+ tokensBefore: number;
28
+ tokensAfter: number;
29
+ saved: number;
30
+ sections: string[];
31
+ timestamp: string;
32
+ }
33
+ export interface CheckpointData {
34
+ timestamp: string;
35
+ tokenState: TokenState;
36
+ activeTaskId?: string;
37
+ context: Record<string, unknown>;
38
+ }
39
+ export declare class TokenBudgetManager {
40
+ private config;
41
+ private hooks;
42
+ private state;
43
+ private checkpoints;
44
+ private compressionHistory;
45
+ constructor(config?: Partial<TokenBudgetConfig>, hooks?: HookSystem);
46
+ update(currentTokens: number): TokenState;
47
+ getState(): TokenState;
48
+ checkpoint(context?: Record<string, unknown>, activeTaskId?: string): CheckpointData;
49
+ /**
50
+ * Record a compression that was performed externally.
51
+ * The caller is responsible for the actual context reduction —
52
+ * this method tracks the bookkeeping and updates token state.
53
+ */
54
+ recordCompression(actualTokensAfter: number, sections: string[]): CompressionRecord;
55
+ /**
56
+ * Get the recommended compression target based on current strategy and state.
57
+ * Returns the number of tokens that should remain after compression.
58
+ */
59
+ getCompressionTarget(): {
60
+ targetTokens: number;
61
+ reductionPercent: number;
62
+ };
63
+ getRecommendation(): {
64
+ action: string;
65
+ urgency: 'none' | 'low' | 'medium' | 'high';
66
+ details: string;
67
+ };
68
+ getCheckpoints(): CheckpointData[];
69
+ getCompressionHistory(): CompressionRecord[];
70
+ shouldPauseSwarm(): boolean;
71
+ shouldCompress(): boolean;
72
+ wireHooks(hooks: HookSystem): void;
73
+ private computeLevel;
74
+ private onLevelChange;
75
+ }
76
+ export declare function createTokenBudget(config?: Partial<TokenBudgetConfig>, hooks?: HookSystem): TokenBudgetManager;
@@ -0,0 +1,196 @@
1
+ /**
2
+ * ECC-005: Token Budget Hook Wiring
3
+ *
4
+ * Systematic token optimization with proactive checkpointing.
5
+ * Monitors context usage and triggers compression/checkpoint hooks.
6
+ */
7
+ const DEFAULT_CONFIG = {
8
+ maxTokens: 200000,
9
+ warningThreshold: 0.70,
10
+ criticalThreshold: 0.85,
11
+ emergencyThreshold: 0.95,
12
+ compressionStrategy: 'progressive',
13
+ };
14
+ export class TokenBudgetManager {
15
+ config;
16
+ hooks;
17
+ state;
18
+ checkpoints = [];
19
+ compressionHistory = [];
20
+ constructor(config, hooks) {
21
+ this.config = { ...DEFAULT_CONFIG, ...config };
22
+ this.hooks = hooks || null;
23
+ this.state = {
24
+ currentTokens: 0,
25
+ maxTokens: this.config.maxTokens,
26
+ utilizationPercent: 0,
27
+ level: 'normal',
28
+ compressionCount: 0,
29
+ savedTokens: 0,
30
+ };
31
+ }
32
+ update(currentTokens) {
33
+ this.state.currentTokens = currentTokens;
34
+ this.state.maxTokens = this.config.maxTokens;
35
+ this.state.utilizationPercent = currentTokens / this.config.maxTokens;
36
+ const prevLevel = this.state.level;
37
+ this.state.level = this.computeLevel();
38
+ // Fire hooks on level transitions
39
+ if (this.hooks && prevLevel !== this.state.level) {
40
+ this.onLevelChange(prevLevel, this.state.level);
41
+ }
42
+ return { ...this.state };
43
+ }
44
+ getState() {
45
+ return { ...this.state };
46
+ }
47
+ checkpoint(context = {}, activeTaskId) {
48
+ const cp = {
49
+ timestamp: new Date().toISOString(),
50
+ tokenState: { ...this.state },
51
+ activeTaskId,
52
+ context,
53
+ };
54
+ this.checkpoints.push(cp);
55
+ if (this.checkpoints.length > 50) {
56
+ this.checkpoints = this.checkpoints.slice(-50);
57
+ }
58
+ this.state.lastCheckpoint = cp.timestamp;
59
+ return cp;
60
+ }
61
+ /**
62
+ * Record a compression that was performed externally.
63
+ * The caller is responsible for the actual context reduction —
64
+ * this method tracks the bookkeeping and updates token state.
65
+ */
66
+ recordCompression(actualTokensAfter, sections) {
67
+ const tokensBefore = this.state.currentTokens;
68
+ const saved = tokensBefore - actualTokensAfter;
69
+ const record = {
70
+ strategy: this.config.compressionStrategy,
71
+ tokensBefore,
72
+ tokensAfter: actualTokensAfter,
73
+ saved: Math.max(saved, 0),
74
+ sections,
75
+ timestamp: new Date().toISOString(),
76
+ };
77
+ this.state.currentTokens = actualTokensAfter;
78
+ this.state.compressionCount++;
79
+ this.state.savedTokens += record.saved;
80
+ this.state.utilizationPercent = this.state.currentTokens / this.config.maxTokens;
81
+ this.state.level = this.computeLevel();
82
+ this.compressionHistory.push(record);
83
+ if (this.compressionHistory.length > 20) {
84
+ this.compressionHistory = this.compressionHistory.slice(-20);
85
+ }
86
+ return record;
87
+ }
88
+ /**
89
+ * Get the recommended compression target based on current strategy and state.
90
+ * Returns the number of tokens that should remain after compression.
91
+ */
92
+ getCompressionTarget() {
93
+ const current = this.state.currentTokens;
94
+ let reductionPercent;
95
+ switch (this.config.compressionStrategy) {
96
+ case 'progressive':
97
+ reductionPercent = 0.20;
98
+ break;
99
+ case 'aggressive':
100
+ reductionPercent = 0.40;
101
+ break;
102
+ case 'selective':
103
+ reductionPercent = 0.15;
104
+ break;
105
+ }
106
+ return {
107
+ targetTokens: Math.floor(current * (1 - reductionPercent)),
108
+ reductionPercent,
109
+ };
110
+ }
111
+ getRecommendation() {
112
+ switch (this.state.level) {
113
+ case 'emergency':
114
+ return {
115
+ action: 'immediate_checkpoint_and_compress',
116
+ urgency: 'high',
117
+ details: `At ${(this.state.utilizationPercent * 100).toFixed(1)}% — checkpoint now and aggressively compress. Pause swarm waves.`,
118
+ };
119
+ case 'critical':
120
+ return {
121
+ action: 'checkpoint_and_compress',
122
+ urgency: 'medium',
123
+ details: `At ${(this.state.utilizationPercent * 100).toFixed(1)}% — create checkpoint, apply progressive compression.`,
124
+ };
125
+ case 'warning':
126
+ return {
127
+ action: 'prepare_checkpoint',
128
+ urgency: 'low',
129
+ details: `At ${(this.state.utilizationPercent * 100).toFixed(1)}% — prepare checkpoint data, summarize old context.`,
130
+ };
131
+ default:
132
+ return { action: 'none', urgency: 'none', details: 'Token budget healthy.' };
133
+ }
134
+ }
135
+ getCheckpoints() {
136
+ return [...this.checkpoints];
137
+ }
138
+ getCompressionHistory() {
139
+ return [...this.compressionHistory];
140
+ }
141
+ shouldPauseSwarm() {
142
+ return this.state.level === 'emergency' || this.state.level === 'critical';
143
+ }
144
+ shouldCompress() {
145
+ return this.state.level === 'critical' || this.state.level === 'emergency';
146
+ }
147
+ wireHooks(hooks) {
148
+ this.hooks = hooks;
149
+ // Auto-checkpoint on conversation end
150
+ hooks.on('conversation.end', (payload) => {
151
+ this.checkpoint({ reason: 'conversation_end', ...payload.data });
152
+ }, { priority: 10, description: 'Token budget auto-checkpoint on conversation end' });
153
+ // Monitor context warnings — don't overwrite actual token state,
154
+ // only trigger a checkpoint if we're already at warning level
155
+ hooks.on('context.warning', () => {
156
+ if (this.shouldCompress()) {
157
+ this.checkpoint({ reason: 'context_warning_auto' });
158
+ }
159
+ }, { priority: 5, description: 'Token budget context warning handler' });
160
+ hooks.on('context.critical', () => {
161
+ this.checkpoint({ reason: 'context_critical_auto' });
162
+ }, { priority: 5, description: 'Token budget context critical handler' });
163
+ }
164
+ computeLevel() {
165
+ const util = this.state.utilizationPercent;
166
+ if (util >= this.config.emergencyThreshold)
167
+ return 'emergency';
168
+ if (util >= this.config.criticalThreshold)
169
+ return 'critical';
170
+ if (util >= this.config.warningThreshold)
171
+ return 'warning';
172
+ return 'normal';
173
+ }
174
+ onLevelChange(from, to) {
175
+ if (!this.hooks)
176
+ return;
177
+ const eventMap = {
178
+ warning: 'context.warning',
179
+ critical: 'context.critical',
180
+ emergency: 'context.emergency',
181
+ };
182
+ const event = eventMap[to];
183
+ if (event) {
184
+ this.hooks.emit(event, {
185
+ from,
186
+ to,
187
+ utilization: this.state.utilizationPercent,
188
+ currentTokens: this.state.currentTokens,
189
+ maxTokens: this.config.maxTokens,
190
+ }, 'token-budget');
191
+ }
192
+ }
193
+ }
194
+ export function createTokenBudget(config, hooks) {
195
+ return new TokenBudgetManager(config, hooks);
196
+ }