ninja-terminals 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/CLAUDE.md +121 -0
- package/ORCHESTRATOR-PROMPT.md +295 -0
- package/cli.js +117 -0
- package/lib/analyze-session.js +92 -0
- package/lib/evolution-writer.js +27 -0
- package/lib/permissions.js +311 -0
- package/lib/playbook-tracker.js +85 -0
- package/lib/resilience.js +458 -0
- package/lib/ring-buffer.js +125 -0
- package/lib/safe-file-writer.js +51 -0
- package/lib/scheduler.js +212 -0
- package/lib/settings-gen.js +159 -0
- package/lib/sse.js +103 -0
- package/lib/status-detect.js +229 -0
- package/lib/task-dag.js +547 -0
- package/lib/tool-rater.js +63 -0
- package/orchestrator/evolution-log.md +33 -0
- package/orchestrator/identity.md +60 -0
- package/orchestrator/metrics/.gitkeep +0 -0
- package/orchestrator/metrics/raw/.gitkeep +0 -0
- package/orchestrator/metrics/session-2026-03-23-setup.md +54 -0
- package/orchestrator/metrics/session-2026-03-24-appcast-build.md +55 -0
- package/orchestrator/playbooks.md +71 -0
- package/orchestrator/security-protocol.md +69 -0
- package/orchestrator/tool-registry.md +96 -0
- package/package.json +46 -0
- package/public/app.js +860 -0
- package/public/index.html +60 -0
- package/public/style.css +678 -0
- package/server.js +695 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Maximum reset timeout for circuit breaker doubling (5 minutes).
|
|
5
|
+
*/
|
|
6
|
+
const MAX_RESET_TIMEOUT = 5 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Circuit breaker states.
|
|
10
|
+
*/
|
|
11
|
+
const STATE_CLOSED = 'CLOSED';
|
|
12
|
+
const STATE_OPEN = 'OPEN';
|
|
13
|
+
const STATE_HALF_OPEN = 'HALF_OPEN';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Per-terminal circuit breaker implementing the standard three-state pattern.
|
|
17
|
+
*
|
|
18
|
+
* State machine:
|
|
19
|
+
* CLOSED --[threshold failures]--> OPEN
|
|
20
|
+
* OPEN --[resetTimeout elapsed]--> HALF_OPEN
|
|
21
|
+
* HALF_OPEN --[success]--> CLOSED
|
|
22
|
+
* HALF_OPEN --[failure]--> OPEN (with doubled resetTimeout, max 5 min)
|
|
23
|
+
*/
|
|
24
|
+
class CircuitBreaker {
|
|
25
|
+
/**
|
|
26
|
+
* @param {number} terminalId - The terminal this breaker protects
|
|
27
|
+
* @param {object} [options]
|
|
28
|
+
* @param {number} [options.threshold=3] - Consecutive failures before opening
|
|
29
|
+
* @param {number} [options.resetTimeout=60000] - Ms before OPEN transitions to HALF_OPEN
|
|
30
|
+
*/
|
|
31
|
+
constructor(terminalId, { threshold = 3, resetTimeout = 60000 } = {}) {
|
|
32
|
+
if (typeof terminalId !== 'number') {
|
|
33
|
+
throw new Error('terminalId must be a number');
|
|
34
|
+
}
|
|
35
|
+
if (typeof threshold !== 'number' || threshold < 1) {
|
|
36
|
+
throw new Error('threshold must be a positive number');
|
|
37
|
+
}
|
|
38
|
+
if (typeof resetTimeout !== 'number' || resetTimeout < 0) {
|
|
39
|
+
throw new Error('resetTimeout must be a non-negative number');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
this._terminalId = terminalId;
|
|
43
|
+
this._threshold = threshold;
|
|
44
|
+
this._resetTimeout = resetTimeout;
|
|
45
|
+
this._currentResetTimeout = resetTimeout;
|
|
46
|
+
this._state = STATE_CLOSED;
|
|
47
|
+
this._failures = 0;
|
|
48
|
+
this._lastFailureAt = null;
|
|
49
|
+
this._openedAt = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Record a failure. Increments failure count and opens the circuit
|
|
54
|
+
* if the threshold is reached.
|
|
55
|
+
* In HALF_OPEN state, a failure reopens the circuit with doubled timeout.
|
|
56
|
+
*/
|
|
57
|
+
recordFailure() {
|
|
58
|
+
this._failures++;
|
|
59
|
+
this._lastFailureAt = Date.now();
|
|
60
|
+
|
|
61
|
+
if (this._state === STATE_HALF_OPEN) {
|
|
62
|
+
// Failure during probe — reopen with doubled timeout
|
|
63
|
+
this._state = STATE_OPEN;
|
|
64
|
+
this._openedAt = Date.now();
|
|
65
|
+
this._currentResetTimeout = Math.min(
|
|
66
|
+
this._currentResetTimeout * 2,
|
|
67
|
+
MAX_RESET_TIMEOUT
|
|
68
|
+
);
|
|
69
|
+
} else if (this._state === STATE_CLOSED && this._failures >= this._threshold) {
|
|
70
|
+
this._state = STATE_OPEN;
|
|
71
|
+
this._openedAt = Date.now();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Record a success. Resets failure count and closes the circuit.
|
|
77
|
+
*/
|
|
78
|
+
recordSuccess() {
|
|
79
|
+
this._failures = 0;
|
|
80
|
+
this._state = STATE_CLOSED;
|
|
81
|
+
this._currentResetTimeout = this._resetTimeout;
|
|
82
|
+
this._openedAt = null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check whether this terminal can accept a new task.
|
|
87
|
+
*
|
|
88
|
+
* - CLOSED: always true
|
|
89
|
+
* - OPEN: if resetTimeout has elapsed, transition to HALF_OPEN and return true (allow one probe)
|
|
90
|
+
* - HALF_OPEN: false (a probe task is already in flight)
|
|
91
|
+
*
|
|
92
|
+
* @returns {boolean}
|
|
93
|
+
*/
|
|
94
|
+
canAcceptTask() {
|
|
95
|
+
if (this._state === STATE_CLOSED) return true;
|
|
96
|
+
|
|
97
|
+
if (this._state === STATE_OPEN) {
|
|
98
|
+
const elapsed = Date.now() - this._openedAt;
|
|
99
|
+
if (elapsed >= this._currentResetTimeout) {
|
|
100
|
+
this._state = STATE_HALF_OPEN;
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// HALF_OPEN — one test task already in flight
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Current circuit breaker state.
|
|
112
|
+
* @returns {'CLOSED'|'OPEN'|'HALF_OPEN'}
|
|
113
|
+
*/
|
|
114
|
+
get state() {
|
|
115
|
+
return this._state;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Current consecutive failure count.
|
|
120
|
+
* @returns {number}
|
|
121
|
+
*/
|
|
122
|
+
get failureCount() {
|
|
123
|
+
return this._failures;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Serialize circuit breaker state.
|
|
128
|
+
* @returns {object}
|
|
129
|
+
*/
|
|
130
|
+
toJSON() {
|
|
131
|
+
return {
|
|
132
|
+
terminalId: this._terminalId,
|
|
133
|
+
state: this._state,
|
|
134
|
+
failures: this._failures,
|
|
135
|
+
threshold: this._threshold,
|
|
136
|
+
resetTimeout: this._resetTimeout,
|
|
137
|
+
currentResetTimeout: this._currentResetTimeout,
|
|
138
|
+
lastFailureAt: this._lastFailureAt,
|
|
139
|
+
openedAt: this._openedAt,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Global retry budget that limits retries within a sliding time window.
|
|
146
|
+
* Prevents retry storms from overwhelming the system.
|
|
147
|
+
*/
|
|
148
|
+
class RetryBudget {
|
|
149
|
+
/**
|
|
150
|
+
* @param {object} [options]
|
|
151
|
+
* @param {number} [options.maxRetries=10] - Maximum retries allowed within the window
|
|
152
|
+
* @param {number} [options.windowMs=600000] - Sliding window duration (default 10 min)
|
|
153
|
+
*/
|
|
154
|
+
constructor({ maxRetries = 10, windowMs = 600000 } = {}) {
|
|
155
|
+
if (typeof maxRetries !== 'number' || maxRetries < 0) {
|
|
156
|
+
throw new Error('maxRetries must be a non-negative number');
|
|
157
|
+
}
|
|
158
|
+
if (typeof windowMs !== 'number' || windowMs < 0) {
|
|
159
|
+
throw new Error('windowMs must be a non-negative number');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
this._maxRetries = maxRetries;
|
|
163
|
+
this._windowMs = windowMs;
|
|
164
|
+
/** @type {number[]} */
|
|
165
|
+
this._timestamps = [];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Prune expired timestamps and check if a retry is allowed.
|
|
170
|
+
*
|
|
171
|
+
* @returns {boolean} true if under budget
|
|
172
|
+
*/
|
|
173
|
+
canRetry() {
|
|
174
|
+
this._prune();
|
|
175
|
+
return this._timestamps.length < this._maxRetries;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Record a retry. Adds the current timestamp to the window.
|
|
180
|
+
*/
|
|
181
|
+
recordRetry() {
|
|
182
|
+
this._timestamps.push(Date.now());
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Number of retries remaining in the current window.
|
|
187
|
+
* @returns {number}
|
|
188
|
+
*/
|
|
189
|
+
get remaining() {
|
|
190
|
+
this._prune();
|
|
191
|
+
return Math.max(0, this._maxRetries - this._timestamps.length);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Serialize retry budget state.
|
|
196
|
+
* @returns {object}
|
|
197
|
+
*/
|
|
198
|
+
toJSON() {
|
|
199
|
+
this._prune();
|
|
200
|
+
return {
|
|
201
|
+
maxRetries: this._maxRetries,
|
|
202
|
+
windowMs: this._windowMs,
|
|
203
|
+
timestamps: [...this._timestamps],
|
|
204
|
+
remaining: this.remaining,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Remove timestamps older than the sliding window.
|
|
210
|
+
* @private
|
|
211
|
+
*/
|
|
212
|
+
_prune() {
|
|
213
|
+
const cutoff = Date.now() - this._windowMs;
|
|
214
|
+
this._timestamps = this._timestamps.filter((ts) => ts > cutoff);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Supervisor that manages terminal restart decisions using Erlang-style strategies.
|
|
220
|
+
* Enforces: "no more than maxRestarts within withinSeconds seconds."
|
|
221
|
+
*
|
|
222
|
+
* Strategies (informational — the caller decides what to do):
|
|
223
|
+
* - one_for_one: only the failed terminal is restarted
|
|
224
|
+
* - one_for_all: all terminals are restarted when one fails
|
|
225
|
+
* - rest_for_one: the failed terminal and all started after it are restarted
|
|
226
|
+
*/
|
|
227
|
+
class Supervisor {
|
|
228
|
+
/**
|
|
229
|
+
* @param {object} [options]
|
|
230
|
+
* @param {'one_for_one'|'one_for_all'|'rest_for_one'} [options.strategy='one_for_one']
|
|
231
|
+
* @param {number} [options.maxRestarts=3] - Maximum restarts within the window
|
|
232
|
+
* @param {number} [options.withinSeconds=300] - Window duration in seconds (default 5 min)
|
|
233
|
+
*/
|
|
234
|
+
constructor({ strategy = 'one_for_one', maxRestarts = 3, withinSeconds = 300 } = {}) {
|
|
235
|
+
const validStrategies = ['one_for_one', 'one_for_all', 'rest_for_one'];
|
|
236
|
+
if (!validStrategies.includes(strategy)) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`Invalid strategy "${strategy}". Must be one of: ${validStrategies.join(', ')}`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (typeof maxRestarts !== 'number' || maxRestarts < 0) {
|
|
242
|
+
throw new Error('maxRestarts must be a non-negative number');
|
|
243
|
+
}
|
|
244
|
+
if (typeof withinSeconds !== 'number' || withinSeconds < 0) {
|
|
245
|
+
throw new Error('withinSeconds must be a non-negative number');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
this._strategy = strategy;
|
|
249
|
+
this._maxRestarts = maxRestarts;
|
|
250
|
+
this._withinMs = withinSeconds * 1000;
|
|
251
|
+
/** @type {Map<number, number[]>} terminalId -> [restart timestamps] */
|
|
252
|
+
this._restarts = new Map();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Record a restart for a terminal and check if it's within the allowed budget.
|
|
257
|
+
*
|
|
258
|
+
* @param {number} terminalId - Terminal being restarted
|
|
259
|
+
* @returns {boolean} true if restart is allowed, false if max exceeded
|
|
260
|
+
*/
|
|
261
|
+
recordRestart(terminalId) {
|
|
262
|
+
if (typeof terminalId !== 'number') {
|
|
263
|
+
throw new Error('terminalId must be a number');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!this._restarts.has(terminalId)) {
|
|
267
|
+
this._restarts.set(terminalId, []);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const timestamps = this._restarts.get(terminalId);
|
|
271
|
+
this._pruneTimestamps(timestamps);
|
|
272
|
+
|
|
273
|
+
if (timestamps.length >= this._maxRestarts) {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
timestamps.push(Date.now());
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Check if a restart would be allowed without actually recording it.
|
|
283
|
+
*
|
|
284
|
+
* @param {number} terminalId - Terminal to check
|
|
285
|
+
* @returns {boolean} true if a restart is currently allowed
|
|
286
|
+
*/
|
|
287
|
+
shouldRestart(terminalId) {
|
|
288
|
+
if (typeof terminalId !== 'number') {
|
|
289
|
+
throw new Error('terminalId must be a number');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!this._restarts.has(terminalId)) return true;
|
|
293
|
+
|
|
294
|
+
const timestamps = [...this._restarts.get(terminalId)];
|
|
295
|
+
const cutoff = Date.now() - this._withinMs;
|
|
296
|
+
const recent = timestamps.filter((ts) => ts > cutoff);
|
|
297
|
+
return recent.length < this._maxRestarts;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Clear restart history for a specific terminal.
|
|
302
|
+
*
|
|
303
|
+
* @param {number} terminalId - Terminal to reset
|
|
304
|
+
*/
|
|
305
|
+
reset(terminalId) {
|
|
306
|
+
if (typeof terminalId !== 'number') {
|
|
307
|
+
throw new Error('terminalId must be a number');
|
|
308
|
+
}
|
|
309
|
+
this._restarts.delete(terminalId);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Clear restart history for all terminals.
|
|
314
|
+
*/
|
|
315
|
+
resetAll() {
|
|
316
|
+
this._restarts.clear();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Get the current restart count within the window for a terminal.
|
|
321
|
+
*
|
|
322
|
+
* @param {number} terminalId - Terminal to check
|
|
323
|
+
* @returns {number}
|
|
324
|
+
*/
|
|
325
|
+
getRestartCount(terminalId) {
|
|
326
|
+
if (typeof terminalId !== 'number') {
|
|
327
|
+
throw new Error('terminalId must be a number');
|
|
328
|
+
}
|
|
329
|
+
if (!this._restarts.has(terminalId)) return 0;
|
|
330
|
+
|
|
331
|
+
const timestamps = this._restarts.get(terminalId);
|
|
332
|
+
this._pruneTimestamps(timestamps);
|
|
333
|
+
return timestamps.length;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Serialize supervisor state.
|
|
338
|
+
* @returns {object}
|
|
339
|
+
*/
|
|
340
|
+
toJSON() {
|
|
341
|
+
const restarts = {};
|
|
342
|
+
for (const [id, timestamps] of this._restarts) {
|
|
343
|
+
this._pruneTimestamps(timestamps);
|
|
344
|
+
restarts[id] = [...timestamps];
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
strategy: this._strategy,
|
|
348
|
+
maxRestarts: this._maxRestarts,
|
|
349
|
+
withinSeconds: this._withinMs / 1000,
|
|
350
|
+
restarts,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Remove timestamps outside the current window (mutates array in place).
|
|
356
|
+
* @private
|
|
357
|
+
* @param {number[]} timestamps
|
|
358
|
+
*/
|
|
359
|
+
_pruneTimestamps(timestamps) {
|
|
360
|
+
const cutoff = Date.now() - this._withinMs;
|
|
361
|
+
let i = 0;
|
|
362
|
+
while (i < timestamps.length) {
|
|
363
|
+
if (timestamps[i] <= cutoff) {
|
|
364
|
+
timestamps.splice(i, 1);
|
|
365
|
+
} else {
|
|
366
|
+
i++;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Error type definitions with retryable flag and recommended action.
|
|
374
|
+
* @typedef {{ type: string, retryable: boolean, action: string }} ClassifiedError
|
|
375
|
+
*/
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Parse a STATUS: ERROR line and classify it into a typed error
|
|
379
|
+
* with retry eligibility and recommended action.
|
|
380
|
+
*
|
|
381
|
+
* @param {string} errorText - Raw error text from a terminal
|
|
382
|
+
* @returns {ClassifiedError} Classified error with type, retryable, and action
|
|
383
|
+
*/
|
|
384
|
+
function classifyError(errorText) {
|
|
385
|
+
if (typeof errorText !== 'string') {
|
|
386
|
+
return { type: 'TOOL_FAIL', retryable: true, action: 'retry' };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const lower = errorText.toLowerCase();
|
|
390
|
+
|
|
391
|
+
// Context full / compaction needed
|
|
392
|
+
if (
|
|
393
|
+
(lower.includes('context') && lower.includes('full')) ||
|
|
394
|
+
lower.includes('compac')
|
|
395
|
+
) {
|
|
396
|
+
return { type: 'CONTEXT_FULL', retryable: false, action: 'restart' };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Dependency / waiting for another task
|
|
400
|
+
if (
|
|
401
|
+
lower.includes('need:') ||
|
|
402
|
+
lower.includes('dependency') ||
|
|
403
|
+
lower.includes('waiting for')
|
|
404
|
+
) {
|
|
405
|
+
return { type: 'DEPENDENCY', retryable: false, action: 'route' };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Rate limiting
|
|
409
|
+
if (
|
|
410
|
+
lower.includes('rate limit') ||
|
|
411
|
+
lower.includes('429') ||
|
|
412
|
+
lower.includes('too many requests')
|
|
413
|
+
) {
|
|
414
|
+
return { type: 'RATE_LIMIT', retryable: true, action: 'wait' };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Timeout
|
|
418
|
+
if (lower.includes('timeout') || lower.includes('timed out')) {
|
|
419
|
+
return { type: 'TIMEOUT', retryable: true, action: 'retry' };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Stuck / loop
|
|
423
|
+
if (
|
|
424
|
+
lower.includes('stuck') ||
|
|
425
|
+
lower.includes('loop') ||
|
|
426
|
+
lower.includes('same output')
|
|
427
|
+
) {
|
|
428
|
+
return { type: 'STUCK', retryable: false, action: 'escalate' };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Validation errors
|
|
432
|
+
if (
|
|
433
|
+
lower.includes('validation') ||
|
|
434
|
+
(lower.includes('expected') && lower.includes('actual'))
|
|
435
|
+
) {
|
|
436
|
+
return { type: 'VALIDATION', retryable: true, action: 'retry' };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Crash / process exit
|
|
440
|
+
if (
|
|
441
|
+
lower.includes('crash') ||
|
|
442
|
+
lower.includes('exit') ||
|
|
443
|
+
lower.includes('sigterm') ||
|
|
444
|
+
lower.includes('sigkill')
|
|
445
|
+
) {
|
|
446
|
+
return { type: 'CRASH', retryable: false, action: 'restart' };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Default: assume tool failure, retryable
|
|
450
|
+
return { type: 'TOOL_FAIL', retryable: true, action: 'retry' };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
module.exports = {
|
|
454
|
+
CircuitBreaker,
|
|
455
|
+
RetryBudget,
|
|
456
|
+
Supervisor,
|
|
457
|
+
classifyError,
|
|
458
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Circular buffer of ANSI-stripped text lines.
|
|
5
|
+
* Overwrites oldest entries when capacity is reached.
|
|
6
|
+
*/
|
|
7
|
+
class LineBuffer {
|
|
8
|
+
/**
|
|
9
|
+
* @param {number} [maxLines=1000] - Maximum number of lines to retain
|
|
10
|
+
*/
|
|
11
|
+
constructor(maxLines = 1000) {
|
|
12
|
+
this._maxLines = maxLines;
|
|
13
|
+
this._buf = new Array(maxLines);
|
|
14
|
+
this._head = 0; // next write position
|
|
15
|
+
this._count = 0; // total lines stored (capped at maxLines)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Append a single line to the buffer. Overwrites the oldest line when full.
|
|
20
|
+
* @param {string} line
|
|
21
|
+
*/
|
|
22
|
+
push(line) {
|
|
23
|
+
this._buf[this._head] = line;
|
|
24
|
+
this._head = (this._head + 1) % this._maxLines;
|
|
25
|
+
if (this._count < this._maxLines) this._count++;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Return the last n lines (most recent first order is NOT used;
|
|
30
|
+
* lines are returned in chronological order, oldest-to-newest).
|
|
31
|
+
* @param {number} n - Number of recent lines to retrieve
|
|
32
|
+
* @returns {string[]}
|
|
33
|
+
*/
|
|
34
|
+
last(n) {
|
|
35
|
+
const count = Math.min(n, this._count);
|
|
36
|
+
if (count === 0) return [];
|
|
37
|
+
const result = new Array(count);
|
|
38
|
+
// start = position of the (count)-th line from the end
|
|
39
|
+
const start = (this._head - count + this._maxLines) % this._maxLines;
|
|
40
|
+
for (let i = 0; i < count; i++) {
|
|
41
|
+
result[i] = this._buf[(start + i) % this._maxLines];
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Return a window of lines from the buffer.
|
|
48
|
+
* @param {number} offset - Number of lines from the end to start (0 = most recent)
|
|
49
|
+
* @param {number} limit - Maximum number of lines to return
|
|
50
|
+
* @returns {{ lines: string[], total: number, truncated: boolean }}
|
|
51
|
+
*/
|
|
52
|
+
slice(offset, limit) {
|
|
53
|
+
const total = this._count;
|
|
54
|
+
if (total === 0 || offset >= total) {
|
|
55
|
+
return { lines: [], total, truncated: false };
|
|
56
|
+
}
|
|
57
|
+
// offset 0 means "start from the newest line going backwards"
|
|
58
|
+
// We want to return `limit` lines ending at `total - offset`
|
|
59
|
+
const end = total - offset; // exclusive upper bound (in logical order)
|
|
60
|
+
const start = Math.max(0, end - limit); // inclusive lower bound
|
|
61
|
+
const count = end - start;
|
|
62
|
+
|
|
63
|
+
const result = new Array(count);
|
|
64
|
+
const bufStart = (this._head - total + start + this._maxLines) % this._maxLines;
|
|
65
|
+
for (let i = 0; i < count; i++) {
|
|
66
|
+
result[i] = this._buf[(bufStart + i) % this._maxLines];
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
lines: result,
|
|
70
|
+
total,
|
|
71
|
+
truncated: end < total,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Remove all stored lines. */
|
|
76
|
+
clear() {
|
|
77
|
+
this._head = 0;
|
|
78
|
+
this._count = 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @returns {number} Number of lines currently stored */
|
|
82
|
+
get length() {
|
|
83
|
+
return this._count;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Circular byte buffer that retains raw PTY output (ANSI codes intact).
|
|
89
|
+
* Trims from the front when the maximum size is exceeded.
|
|
90
|
+
*/
|
|
91
|
+
class RawBuffer {
|
|
92
|
+
/**
|
|
93
|
+
* @param {number} [maxBytes=65536] - Maximum buffer size in bytes
|
|
94
|
+
*/
|
|
95
|
+
constructor(maxBytes = 65536) {
|
|
96
|
+
this._maxBytes = maxBytes;
|
|
97
|
+
this._data = '';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Append string data to the buffer, trimming from the front if needed.
|
|
102
|
+
* @param {string} data - Raw PTY output
|
|
103
|
+
*/
|
|
104
|
+
push(data) {
|
|
105
|
+
this._data += data;
|
|
106
|
+
if (this._data.length > this._maxBytes) {
|
|
107
|
+
this._data = this._data.slice(this._data.length - this._maxBytes);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Return the full buffer contents as a string.
|
|
113
|
+
* @returns {string}
|
|
114
|
+
*/
|
|
115
|
+
getAll() {
|
|
116
|
+
return this._data;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Clear the buffer. */
|
|
120
|
+
clear() {
|
|
121
|
+
this._data = '';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { LineBuffer, RawBuffer };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
/** Files that must NEVER be modified by the self-improvement system. */
|
|
7
|
+
const IMMUTABLE_FILES = ['identity.md', 'security-protocol.md'];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Check if a file path points to an immutable file.
|
|
11
|
+
* @param {string} filePath
|
|
12
|
+
* @returns {boolean}
|
|
13
|
+
*/
|
|
14
|
+
function isImmutable(filePath) {
|
|
15
|
+
return IMMUTABLE_FILES.includes(path.basename(filePath));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Atomically write content to a file (temp + rename).
|
|
20
|
+
* Throws if the file is immutable.
|
|
21
|
+
* @param {string} filePath
|
|
22
|
+
* @param {string} content
|
|
23
|
+
*/
|
|
24
|
+
function safeWrite(filePath, content) {
|
|
25
|
+
if (isImmutable(filePath)) {
|
|
26
|
+
throw new Error(`Cannot write to immutable file: ${path.basename(filePath)}`);
|
|
27
|
+
}
|
|
28
|
+
const dir = path.dirname(filePath);
|
|
29
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
30
|
+
const tmp = `${filePath}.tmp.${process.pid}`;
|
|
31
|
+
fs.writeFileSync(tmp, content, 'utf8');
|
|
32
|
+
fs.renameSync(tmp, filePath);
|
|
33
|
+
return filePath;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Append a line to a file. Creates the file and directory if needed.
|
|
38
|
+
* Throws if the file is immutable.
|
|
39
|
+
* @param {string} filePath
|
|
40
|
+
* @param {string} line
|
|
41
|
+
*/
|
|
42
|
+
function safeAppend(filePath, line) {
|
|
43
|
+
if (isImmutable(filePath)) {
|
|
44
|
+
throw new Error(`Cannot append to immutable file: ${path.basename(filePath)}`);
|
|
45
|
+
}
|
|
46
|
+
const dir = path.dirname(filePath);
|
|
47
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
48
|
+
fs.appendFileSync(filePath, line + '\n', 'utf8');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { isImmutable, safeWrite, safeAppend };
|