@monotykamary/pi-loop 0.1.12

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +285 -0
  4. package/media/demo.mp4 +0 -0
  5. package/media/pi-loop.jpg +0 -0
  6. package/media/screenshot.png +0 -0
  7. package/package.json +89 -0
  8. package/src/core/analyzer.ts +51 -0
  9. package/src/core/content-extractor.ts +79 -0
  10. package/src/core/inference.ts +137 -0
  11. package/src/core/prompt-builder.ts +217 -0
  12. package/src/core/prompt-loader.ts +126 -0
  13. package/src/core/reframe.ts +30 -0
  14. package/src/core/snapshot-builder.ts +252 -0
  15. package/src/global-config.ts +38 -0
  16. package/src/index.ts +532 -0
  17. package/src/session/client.ts +47 -0
  18. package/src/session/loop-session.ts +102 -0
  19. package/src/session/response-parser.ts +37 -0
  20. package/src/state/manager.ts +164 -0
  21. package/src/state/patterns.ts +81 -0
  22. package/src/state/reframe.ts +33 -0
  23. package/src/subagent-detector.ts +94 -0
  24. package/src/types.ts +83 -0
  25. package/src/ui/animations.ts +70 -0
  26. package/src/ui/model-picker.ts +79 -0
  27. package/src/ui/renderer.ts +257 -0
  28. package/src/ui/status-widget.ts +30 -0
  29. package/src/ui/types.ts +48 -0
  30. package/tests/compaction.test.ts +754 -0
  31. package/tests/continue-action-regression.test.ts +456 -0
  32. package/tests/engine.test.ts +770 -0
  33. package/tests/ephemeral-supervision.test.ts +391 -0
  34. package/tests/full-fidelity-snapshot.test.ts +843 -0
  35. package/tests/parsing.test.ts +303 -0
  36. package/tests/state.test.ts +525 -0
  37. package/tests/status-widget.test.ts +703 -0
  38. package/tests/subagent-detector.test.ts +191 -0
  39. package/tests/supervise-command.test.ts +381 -0
  40. package/tsconfig.json +14 -0
  41. package/vitest.config.ts +15 -0
@@ -0,0 +1,525 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { LoopStateManager } from '../src/state/manager.js';
3
+
4
+ // Mock ExtensionAPI
5
+ function createMockApi() {
6
+ return {
7
+ appendEntry: vi.fn(),
8
+ on: vi.fn(),
9
+ registerCommand: vi.fn(),
10
+ registerTool: vi.fn(),
11
+ sendUserMessage: vi.fn(),
12
+ sendMessage: vi.fn(),
13
+ events: { emit: vi.fn(), on: vi.fn() },
14
+ } as any;
15
+ }
16
+
17
+ describe('LoopStateManager', () => {
18
+ describe('reframe tier management', () => {
19
+ it('initializes reframe tier to 0 on start', () => {
20
+ const api = createMockApi();
21
+ const state = new LoopStateManager(api);
22
+ state.start('Test goal', 'anthropic', 'claude-haiku');
23
+
24
+ expect(state.getReframeTier()).toBe(0);
25
+ expect(state.getState()!.reframeTier).toBe(0);
26
+ expect(state.getState()!.lastSteerTurn).toBe(-1);
27
+ });
28
+
29
+ it('escalates reframe tier up to max of 4', () => {
30
+ const api = createMockApi();
31
+ const state = new LoopStateManager(api);
32
+ state.start('Test goal', 'anthropic', 'claude-haiku');
33
+
34
+ expect(state.getReframeTier()).toBe(0);
35
+
36
+ state.escalateReframeTier();
37
+ expect(state.getReframeTier()).toBe(1);
38
+
39
+ state.escalateReframeTier();
40
+ expect(state.getReframeTier()).toBe(2);
41
+
42
+ state.escalateReframeTier();
43
+ expect(state.getReframeTier()).toBe(3);
44
+
45
+ state.escalateReframeTier();
46
+ expect(state.getReframeTier()).toBe(4);
47
+
48
+ // Should not go above 4
49
+ state.escalateReframeTier();
50
+ expect(state.getReframeTier()).toBe(4);
51
+ });
52
+
53
+ it('resets reframe tier to 0', () => {
54
+ const api = createMockApi();
55
+ const state = new LoopStateManager(api);
56
+ state.start('Test goal', 'anthropic', 'claude-haiku');
57
+
58
+ state.escalateReframeTier();
59
+ state.escalateReframeTier();
60
+ expect(state.getReframeTier()).toBe(2);
61
+
62
+ state.resetReframeTier();
63
+ expect(state.getReframeTier()).toBe(0);
64
+ });
65
+
66
+ it('returns 0 when not active', () => {
67
+ const api = createMockApi();
68
+ const state = new LoopStateManager(api);
69
+ expect(state.getReframeTier()).toBe(0);
70
+ });
71
+
72
+ it('persists reframe tier changes', () => {
73
+ const api = createMockApi();
74
+ const state = new LoopStateManager(api);
75
+ state.start('Test goal', 'anthropic', 'claude-haiku');
76
+
77
+ state.escalateReframeTier();
78
+
79
+ expect(api.appendEntry).toHaveBeenLastCalledWith(
80
+ 'loop-state',
81
+ expect.objectContaining({ reframeTier: 1 })
82
+ );
83
+ });
84
+
85
+ it('tracks lastSteerTurn when adding intervention', () => {
86
+ const api = createMockApi();
87
+ const state = new LoopStateManager(api);
88
+ state.start('Test goal', 'anthropic', 'claude-haiku');
89
+ state.incrementTurnCount();
90
+ state.incrementTurnCount();
91
+
92
+ state.addIntervention({
93
+ turnCount: 2,
94
+ message: 'Please focus on X',
95
+ reasoning: 'Agent drifted',
96
+ timestamp: Date.now(),
97
+ });
98
+
99
+ expect(state.getState()!.lastSteerTurn).toBe(2);
100
+ });
101
+ });
102
+
103
+ describe('ineffective pattern detection', () => {
104
+ it('returns no pattern with less than 2 interventions', () => {
105
+ const api = createMockApi();
106
+ const state = new LoopStateManager(api);
107
+ state.start('Test goal', 'anthropic', 'claude-haiku');
108
+
109
+ const pattern = state.detectIneffectivePattern();
110
+ expect(pattern.detected).toBe(false);
111
+ expect(pattern.similarCount).toBe(0);
112
+ });
113
+
114
+ it('detects similar messages', () => {
115
+ const api = createMockApi();
116
+ const state = new LoopStateManager(api);
117
+ state.start('Test goal', 'anthropic', 'claude-haiku');
118
+
119
+ // Add similar interventions
120
+ state.incrementTurnCount();
121
+ state.addIntervention({
122
+ turnCount: 1,
123
+ message: 'Please implement the auth middleware',
124
+ reasoning: 'Not done yet',
125
+ timestamp: Date.now(),
126
+ });
127
+
128
+ state.incrementTurnCount();
129
+ state.addIntervention({
130
+ turnCount: 2,
131
+ message: 'Please implement the auth middleware now',
132
+ reasoning: 'Still not done',
133
+ timestamp: Date.now(),
134
+ });
135
+
136
+ const pattern = state.detectIneffectivePattern();
137
+ expect(pattern.detected).toBe(true);
138
+ expect(pattern.similarCount).toBe(2);
139
+ });
140
+
141
+ it('detects lack of progress (3+ turns since steer)', () => {
142
+ const api = createMockApi();
143
+ const state = new LoopStateManager(api);
144
+ state.start('Test goal', 'anthropic', 'claude-haiku');
145
+
146
+ // Add intervention
147
+ state.incrementTurnCount();
148
+ state.addIntervention({
149
+ turnCount: 1,
150
+ message: 'Focus on X',
151
+ reasoning: 'Test',
152
+ timestamp: Date.now(),
153
+ });
154
+
155
+ // Advance 3 turns without steering
156
+ state.incrementTurnCount(); // turn 2
157
+ state.incrementTurnCount(); // turn 3
158
+ state.incrementTurnCount(); // turn 4
159
+
160
+ const pattern = state.detectIneffectivePattern();
161
+ expect(pattern.detected).toBe(true);
162
+ expect(pattern.turnsSinceLastSteer).toBe(3);
163
+ });
164
+
165
+ it('does not detect pattern when progress is being made', () => {
166
+ const api = createMockApi();
167
+ const state = new LoopStateManager(api);
168
+ state.start('Test goal', 'anthropic', 'claude-haiku');
169
+
170
+ // Add intervention
171
+ state.incrementTurnCount();
172
+ state.addIntervention({
173
+ turnCount: 1,
174
+ message: 'Focus on X',
175
+ reasoning: 'Test',
176
+ timestamp: Date.now(),
177
+ });
178
+
179
+ // Only 2 turns since steer
180
+ state.incrementTurnCount();
181
+ state.incrementTurnCount();
182
+
183
+ const pattern = state.detectIneffectivePattern();
184
+ expect(pattern.detected).toBe(false);
185
+ expect(pattern.turnsSinceLastSteer).toBe(2);
186
+ });
187
+
188
+ it('detects dissimilar messages as different', () => {
189
+ const api = createMockApi();
190
+ const state = new LoopStateManager(api);
191
+ state.start('Test goal', 'anthropic', 'claude-haiku');
192
+
193
+ state.incrementTurnCount();
194
+ state.addIntervention({
195
+ turnCount: 1,
196
+ message: 'Implement the database layer',
197
+ reasoning: 'Need DB',
198
+ timestamp: Date.now(),
199
+ });
200
+
201
+ state.incrementTurnCount();
202
+ state.addIntervention({
203
+ turnCount: 2,
204
+ message: 'Now create the API endpoints',
205
+ reasoning: 'Need API',
206
+ timestamp: Date.now(),
207
+ });
208
+
209
+ const pattern = state.detectIneffectivePattern();
210
+ expect(pattern.detected).toBe(false);
211
+ expect(pattern.similarCount).toBe(1);
212
+ });
213
+ });
214
+
215
+ describe('basic lifecycle', () => {
216
+ it('starts inactive', () => {
217
+ const api = createMockApi();
218
+ const state = new LoopStateManager(api);
219
+ expect(state.isActive()).toBe(false);
220
+ expect(state.getState()).toBeNull();
221
+ });
222
+
223
+ it('starts supervision with correct initial state', () => {
224
+ const api = createMockApi();
225
+ const state = new LoopStateManager(api);
226
+
227
+ state.start('Test goal', 'anthropic', 'claude-haiku');
228
+
229
+ expect(state.isActive()).toBe(true);
230
+ const s = state.getState();
231
+ expect(s).not.toBeNull();
232
+ expect(s!.outcome).toBe('Test goal');
233
+ expect(s!.provider).toBe('anthropic');
234
+ expect(s!.modelId).toBe('claude-haiku');
235
+ expect(s!.interventions).toEqual([]);
236
+ expect(s!.turnCount).toBe(0);
237
+ expect(s!.snapshotBuffer).toEqual([]);
238
+ expect(s!.justSteered).toBe(false);
239
+ });
240
+
241
+ it('stops supervision and marks inactive and clears outcome', () => {
242
+ const api = createMockApi();
243
+ const state = new LoopStateManager(api);
244
+
245
+ state.start('Test goal', 'anthropic', 'claude-haiku');
246
+ expect(state.isActive()).toBe(true);
247
+ expect(state.getState()!.outcome).toBe('Test goal');
248
+
249
+ state.stop();
250
+ expect(state.isActive()).toBe(false);
251
+ expect(state.getState()!.active).toBe(false);
252
+ expect(state.getState()!.outcome).toBe(''); // Goal cleared for fresh start
253
+ });
254
+
255
+ it('persists state on start and stop with cleared outcome', () => {
256
+ const api = createMockApi();
257
+ const state = new LoopStateManager(api);
258
+
259
+ state.start('Test goal', 'anthropic', 'claude-haiku');
260
+ expect(api.appendEntry).toHaveBeenCalledTimes(1);
261
+ expect(api.appendEntry).toHaveBeenCalledWith(
262
+ 'loop-state',
263
+ expect.objectContaining({
264
+ active: true,
265
+ outcome: 'Test goal',
266
+ })
267
+ );
268
+
269
+ state.stop();
270
+ expect(api.appendEntry).toHaveBeenCalledTimes(2);
271
+ expect(api.appendEntry).toHaveBeenLastCalledWith(
272
+ 'loop-state',
273
+ expect.objectContaining({
274
+ active: false,
275
+ outcome: '', // Goal cleared on stop
276
+ })
277
+ );
278
+ });
279
+ });
280
+
281
+ describe('turn management', () => {
282
+ it('increments turn count', () => {
283
+ const api = createMockApi();
284
+ const state = new LoopStateManager(api);
285
+ state.start('Test goal', 'anthropic', 'claude-haiku');
286
+
287
+ expect(state.getState()!.turnCount).toBe(0);
288
+ state.incrementTurnCount();
289
+ expect(state.getState()!.turnCount).toBe(1);
290
+ state.incrementTurnCount();
291
+ expect(state.getState()!.turnCount).toBe(2);
292
+ });
293
+ });
294
+
295
+ describe('interventions', () => {
296
+ it('adds intervention with correct data', () => {
297
+ const api = createMockApi();
298
+ const state = new LoopStateManager(api);
299
+ state.start('Test goal', 'anthropic', 'claude-haiku');
300
+ state.incrementTurnCount();
301
+
302
+ const intervention = {
303
+ turnCount: 1,
304
+ message: 'Please focus on X',
305
+ reasoning: 'Agent drifted',
306
+ timestamp: Date.now(),
307
+ };
308
+
309
+ state.addIntervention(intervention);
310
+
311
+ const s = state.getState()!;
312
+ expect(s.interventions).toHaveLength(1);
313
+ expect(s.interventions[0]).toEqual(intervention);
314
+ expect(s.justSteered).toBe(true);
315
+ });
316
+
317
+ it('clears justSteered flag', () => {
318
+ const api = createMockApi();
319
+ const state = new LoopStateManager(api);
320
+ state.start('Test goal', 'anthropic', 'claude-haiku');
321
+
322
+ state.addIntervention({
323
+ turnCount: 1,
324
+ message: 'Steer',
325
+ reasoning: 'Test',
326
+ timestamp: Date.now(),
327
+ });
328
+ expect(state.getState()!.justSteered).toBe(true);
329
+
330
+ state.clearJustSteered();
331
+ expect(state.getState()!.justSteered).toBe(false);
332
+ });
333
+
334
+ it('does not add intervention when not active', () => {
335
+ const api = createMockApi();
336
+ const state = new LoopStateManager(api);
337
+
338
+ state.addIntervention({
339
+ turnCount: 1,
340
+ message: 'Steer',
341
+ reasoning: 'Test',
342
+ timestamp: Date.now(),
343
+ });
344
+
345
+ // Should not throw, should just return
346
+ expect(state.getState()).toBeNull();
347
+ });
348
+ });
349
+
350
+ describe('shouldAnalyzeMidRun', () => {
351
+ it('returns false when justSteered is false and turn not divisible by 8', () => {
352
+ const api = createMockApi();
353
+ const state = new LoopStateManager(api);
354
+ state.start('Test goal', 'anthropic', 'claude-haiku');
355
+
356
+ expect(state.shouldAnalyzeMidRun(1)).toBe(false);
357
+ expect(state.shouldAnalyzeMidRun(2)).toBe(false);
358
+ expect(state.shouldAnalyzeMidRun(7)).toBe(false);
359
+ });
360
+
361
+ it('returns true when justSteered is true', () => {
362
+ const api = createMockApi();
363
+ const state = new LoopStateManager(api);
364
+ state.start('Test goal', 'anthropic', 'claude-haiku');
365
+
366
+ state.addIntervention({
367
+ turnCount: 1,
368
+ message: 'Steer',
369
+ reasoning: 'Test',
370
+ timestamp: Date.now(),
371
+ });
372
+
373
+ expect(state.shouldAnalyzeMidRun(1)).toBe(true);
374
+ });
375
+
376
+ it('returns true every 8th turn (safety valve)', () => {
377
+ const api = createMockApi();
378
+ const state = new LoopStateManager(api);
379
+ state.start('Test goal', 'anthropic', 'claude-haiku');
380
+
381
+ expect(state.shouldAnalyzeMidRun(8)).toBe(true);
382
+ expect(state.shouldAnalyzeMidRun(16)).toBe(true);
383
+ expect(state.shouldAnalyzeMidRun(24)).toBe(true);
384
+ });
385
+
386
+ it('returns true when both conditions met', () => {
387
+ const api = createMockApi();
388
+ const state = new LoopStateManager(api);
389
+ state.start('Test goal', 'anthropic', 'claude-haiku');
390
+
391
+ state.addIntervention({
392
+ turnCount: 1,
393
+ message: 'Steer',
394
+ reasoning: 'Test',
395
+ timestamp: Date.now(),
396
+ });
397
+
398
+ // Both justSteered and 8th turn
399
+ expect(state.shouldAnalyzeMidRun(8)).toBe(true);
400
+ });
401
+
402
+ it('returns false when not active', () => {
403
+ const api = createMockApi();
404
+ const state = new LoopStateManager(api);
405
+
406
+ expect(state.shouldAnalyzeMidRun(8)).toBe(false);
407
+ });
408
+ });
409
+
410
+ describe('intervention ASI persistence', () => {
411
+ it('stores intervention with ASI', () => {
412
+ const api = createMockApi();
413
+ const state = new LoopStateManager(api);
414
+ state.start('Test goal', 'anthropic', 'claude-haiku');
415
+
416
+ state.addIntervention({
417
+ turnCount: 1,
418
+ message: 'Focus on tests',
419
+ reasoning: 'Drift detected',
420
+ timestamp: Date.now(),
421
+ asi: {
422
+ why_stuck: 'refactoring without tests',
423
+ strategy_used: 'directive',
424
+ pattern_detected: 'test_skipping',
425
+ },
426
+ });
427
+
428
+ const s = state.getState()!;
429
+ expect(s.interventions).toHaveLength(1);
430
+ expect(s.interventions[0].asi).toBeDefined();
431
+ expect(s.interventions[0].asi!.why_stuck).toBe('refactoring without tests');
432
+ expect(s.interventions[0].asi!.strategy_used).toBe('directive');
433
+ });
434
+
435
+ it('stores intervention without ASI (backward compat)', () => {
436
+ const api = createMockApi();
437
+ const state = new LoopStateManager(api);
438
+ state.start('Test goal', 'anthropic', 'claude-haiku');
439
+
440
+ state.addIntervention({
441
+ turnCount: 1,
442
+ message: 'Focus',
443
+ reasoning: 'Test',
444
+ timestamp: Date.now(),
445
+ });
446
+
447
+ const s = state.getState()!;
448
+ expect(s.interventions).toHaveLength(1);
449
+ expect(s.interventions[0].asi).toBeUndefined();
450
+ });
451
+
452
+ it('persists ASI to session via appendEntry', () => {
453
+ const api = createMockApi();
454
+ const state = new LoopStateManager(api);
455
+ state.start('Test goal', 'anthropic', 'claude-haiku');
456
+
457
+ state.addIntervention({
458
+ turnCount: 1,
459
+ message: 'Focus on tests',
460
+ reasoning: 'Drift',
461
+ timestamp: 1234567890,
462
+ asi: {
463
+ why_stuck: 'no tests',
464
+ custom_field: 'custom_value',
465
+ },
466
+ });
467
+
468
+ expect(api.appendEntry).toHaveBeenCalled();
469
+ const lastCall = api.appendEntry.mock.calls[api.appendEntry.mock.calls.length - 1];
470
+ const persistedData = lastCall[1];
471
+ expect(persistedData.interventions).toHaveLength(1);
472
+ expect(persistedData.interventions[0].asi).toBeDefined();
473
+ expect(persistedData.interventions[0].asi.why_stuck).toBe('no tests');
474
+ expect(persistedData.interventions[0].asi.custom_field).toBe('custom_value');
475
+ });
476
+ });
477
+
478
+ describe('model management', () => {
479
+ it('updates model when active', () => {
480
+ const api = createMockApi();
481
+ const state = new LoopStateManager(api);
482
+ state.start('Test goal', 'anthropic', 'claude-haiku');
483
+
484
+ state.setModel('openai', 'gpt-4o');
485
+
486
+ const s = state.getState()!;
487
+ expect(s.provider).toBe('openai');
488
+ expect(s.modelId).toBe('gpt-4o');
489
+ });
490
+
491
+ it('does not update model when not active', () => {
492
+ const api = createMockApi();
493
+ const state = new LoopStateManager(api);
494
+
495
+ // Should not throw
496
+ state.setModel('openai', 'gpt-4o');
497
+ expect(state.getState()).toBeNull();
498
+ });
499
+ });
500
+
501
+ describe('snapshot buffer', () => {
502
+ it('updates and retrieves snapshot buffer', () => {
503
+ const api = createMockApi();
504
+ const state = new LoopStateManager(api);
505
+ state.start('Test goal', 'anthropic', 'claude-haiku');
506
+
507
+ const messages = [
508
+ { role: 'user' as const, content: 'Hello' },
509
+ { role: 'assistant' as const, content: 'Hi there' },
510
+ ];
511
+
512
+ state.updateSnapshotBuffer(messages);
513
+
514
+ expect(state.getSnapshotBuffer()).toEqual(messages);
515
+ expect(state.getState()!.lastAnalyzedTurn).toBe(0);
516
+ });
517
+
518
+ it('returns empty array when not active', () => {
519
+ const api = createMockApi();
520
+ const state = new LoopStateManager(api);
521
+
522
+ expect(state.getSnapshotBuffer()).toEqual([]);
523
+ });
524
+ });
525
+ });