@trevonistrevon/pi-loop 0.4.11 → 0.5.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.
Files changed (45) hide show
  1. package/README.md +10 -0
  2. package/dist/coordinator.d.ts +35 -0
  3. package/dist/coordinator.js +56 -0
  4. package/dist/goal-types.d.ts +78 -0
  5. package/dist/goal-types.js +1 -0
  6. package/dist/goal-verifier.d.ts +20 -0
  7. package/dist/goal-verifier.js +198 -0
  8. package/dist/index.js +170 -40
  9. package/dist/loop-reducer.d.ts +63 -0
  10. package/dist/loop-reducer.js +67 -0
  11. package/dist/monitor-completion-coordinator.d.ts +10 -0
  12. package/dist/monitor-completion-coordinator.js +13 -0
  13. package/dist/monitor-manager.d.ts +2 -0
  14. package/dist/monitor-manager.js +107 -29
  15. package/dist/monitor-reducer.d.ts +82 -0
  16. package/dist/monitor-reducer.js +69 -0
  17. package/dist/notification-reducer.d.ts +81 -0
  18. package/dist/notification-reducer.js +65 -0
  19. package/dist/store.d.ts +2 -0
  20. package/dist/store.js +118 -44
  21. package/dist/task-backlog-coordinator.d.ts +12 -0
  22. package/dist/task-backlog-coordinator.js +22 -0
  23. package/dist/task-reducer.d.ts +66 -0
  24. package/dist/task-reducer.js +76 -0
  25. package/dist/task-store.d.ts +2 -0
  26. package/dist/task-store.js +82 -30
  27. package/docs/architecture/goal-state-schema.md +505 -0
  28. package/docs/architecture/state-machine-migration.md +546 -0
  29. package/docs/architecture/state-machine-reducer-event-model.md +823 -0
  30. package/docs/architecture/state-machine-test-matrix.md +249 -0
  31. package/docs/architecture/state-machine-transition-map.md +436 -0
  32. package/package.json +1 -1
  33. package/src/coordinator.ts +115 -0
  34. package/src/goal-types.ts +99 -0
  35. package/src/goal-verifier.ts +241 -0
  36. package/src/index.ts +196 -39
  37. package/src/loop-reducer.ts +148 -0
  38. package/src/monitor-completion-coordinator.ts +24 -0
  39. package/src/monitor-manager.ts +115 -27
  40. package/src/monitor-reducer.ts +166 -0
  41. package/src/notification-reducer.ts +155 -0
  42. package/src/store.ts +119 -43
  43. package/src/task-backlog-coordinator.ts +32 -0
  44. package/src/task-reducer.ts +152 -0
  45. package/src/task-store.ts +84 -27
package/src/store.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, join } from "node:path";
4
+ import { type LoopReducerEvent, type LoopReducerState, reduceLoopState } from "./loop-reducer.js";
4
5
  import type { LoopEntry, LoopStatus, LoopStoreData, Trigger } from "./types.js";
5
6
 
6
7
  const LOOPS_DIR = join(homedir(), ".pi", "loops");
7
8
  const LOCK_RETRY_MS = 50;
8
9
  const LOCK_MAX_RETRIES = 100;
9
10
  const MAX_LOOPS = 25;
10
- const MAX_EXPIRY_DAYS = 7;
11
11
 
12
12
  function acquireLock(lockPath: string): void {
13
13
  for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
@@ -95,29 +95,41 @@ export class LoopStore {
95
95
  }
96
96
  }
97
97
 
98
+ private toReducerState(): LoopReducerState {
99
+ return {
100
+ nextId: this.nextId,
101
+ loopsById: Object.fromEntries(this.loops.entries()),
102
+ };
103
+ }
104
+
105
+ private applyReducerEvent(event: LoopReducerEvent): void {
106
+ const result = reduceLoopState(this.toReducerState(), event);
107
+ this.nextId = result.state.nextId;
108
+ this.loops = new Map(Object.entries(result.state.loopsById));
109
+ }
110
+
98
111
  create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; taskBacklog?: boolean; readOnly?: boolean; maxFires?: number }): LoopEntry {
99
112
  return this.withLock(() => {
100
113
  if (this.loops.size >= MAX_LOOPS) {
101
114
  throw new Error(`Maximum of ${MAX_LOOPS} loops reached. Delete some before creating new ones.`);
102
115
  }
103
116
  const now = Date.now();
104
- const entry: LoopEntry = {
105
- id: String(this.nextId++),
106
- prompt,
107
- trigger,
108
- status: "active",
109
- recurring: opts.recurring,
110
- autoTask: opts.autoTask,
111
- taskBacklog: opts.taskBacklog,
112
- readOnly: opts.readOnly,
113
- maxFires: opts.maxFires,
114
- fireCount: 0,
115
- createdAt: now,
116
- updatedAt: now,
117
- expiresAt: now + MAX_EXPIRY_DAYS * 24 * 60 * 60 * 1000,
118
- };
119
- this.loops.set(entry.id, entry);
120
- return entry;
117
+ this.applyReducerEvent({
118
+ type: "LOOP_CREATED",
119
+ at: now,
120
+ source: "tool",
121
+ entityType: "loop",
122
+ payload: {
123
+ prompt,
124
+ trigger,
125
+ recurring: opts.recurring,
126
+ autoTask: opts.autoTask,
127
+ taskBacklog: opts.taskBacklog,
128
+ readOnly: opts.readOnly,
129
+ maxFires: opts.maxFires,
130
+ },
131
+ });
132
+ return this.loops.get(String(this.nextId - 1))!;
121
133
  });
122
134
  }
123
135
 
@@ -137,31 +149,77 @@ export class LoopStore {
137
149
  if (!entry) return { entry: undefined, changedFields: [] };
138
150
 
139
151
  const changedFields: string[] = [];
140
- if (fields.status !== undefined) {
141
- entry.status = fields.status;
152
+ const now = Date.now();
153
+
154
+ if (fields.status === "paused") {
155
+ this.applyReducerEvent({
156
+ type: "LOOP_PAUSED",
157
+ at: now,
158
+ source: "tool",
159
+ entityType: "loop",
160
+ entityId: id,
161
+ payload: { id },
162
+ });
163
+ changedFields.push("status");
164
+ } else if (fields.status === "active") {
165
+ this.applyReducerEvent({
166
+ type: "LOOP_RESUMED",
167
+ at: now,
168
+ source: "tool",
169
+ entityType: "loop",
170
+ entityId: id,
171
+ payload: { id },
172
+ });
142
173
  changedFields.push("status");
143
174
  }
175
+
176
+ const current = this.loops.get(id);
177
+ if (!current) return { entry: undefined, changedFields };
178
+
144
179
  if (fields.trigger !== undefined) {
145
- entry.trigger = fields.trigger;
180
+ current.trigger = fields.trigger;
146
181
  changedFields.push("trigger");
147
182
  }
148
183
  if (fields.prompt !== undefined) {
149
- entry.prompt = fields.prompt;
184
+ current.prompt = fields.prompt;
150
185
  changedFields.push("prompt");
151
186
  }
152
187
  if (fields.fireCount !== undefined) {
153
- entry.fireCount = fields.fireCount;
188
+ if (fields.fireCount === (current.fireCount ?? 0) + 1) {
189
+ this.applyReducerEvent({
190
+ type: "LOOP_FIRED",
191
+ at: now,
192
+ source: "system",
193
+ entityType: "loop",
194
+ entityId: id,
195
+ payload: { id },
196
+ });
197
+ } else {
198
+ current.fireCount = fields.fireCount;
199
+ current.updatedAt = now;
200
+ }
154
201
  changedFields.push("fireCount");
155
202
  }
156
- entry.updatedAt = Date.now();
157
- return { entry, changedFields };
203
+
204
+ if (fields.trigger !== undefined || fields.prompt !== undefined) {
205
+ current.updatedAt = now;
206
+ }
207
+
208
+ return { entry: this.loops.get(id), changedFields };
158
209
  });
159
210
  }
160
211
 
161
212
  delete(id: string): boolean {
162
213
  return this.withLock(() => {
163
214
  if (!this.loops.has(id)) return false;
164
- this.loops.delete(id);
215
+ this.applyReducerEvent({
216
+ type: "LOOP_DELETED",
217
+ at: Date.now(),
218
+ source: "tool",
219
+ entityType: "loop",
220
+ entityId: id,
221
+ payload: { id },
222
+ });
165
223
  return true;
166
224
  });
167
225
  }
@@ -170,11 +228,17 @@ export class LoopStore {
170
228
  return this.withLock(() => {
171
229
  const now = Date.now();
172
230
  let count = 0;
173
- for (const [id, entry] of this.loops) {
174
- if (now >= entry.expiresAt) {
175
- this.loops.delete(id);
176
- count++;
177
- }
231
+ for (const [id, entry] of [...this.loops.entries()]) {
232
+ if (now < entry.expiresAt) continue;
233
+ this.applyReducerEvent({
234
+ type: "LOOP_EXPIRED",
235
+ at: now,
236
+ source: "system",
237
+ entityType: "loop",
238
+ entityId: id,
239
+ payload: { id, reason: "expires_at" },
240
+ });
241
+ count++;
178
242
  }
179
243
  return count;
180
244
  });
@@ -183,26 +247,38 @@ export class LoopStore {
183
247
  expireEventLoops(sessionStartedAt: number): number {
184
248
  return this.withLock(() => {
185
249
  let count = 0;
186
- const toDelete: string[] = [];
187
- for (const [id, entry] of this.loops) {
250
+ for (const [id, entry] of [...this.loops.entries()]) {
188
251
  if (entry.status !== "active") continue;
189
- if (entry.trigger.type === "event" || entry.trigger.type === "hybrid") {
190
- if (entry.createdAt < sessionStartedAt) {
191
- toDelete.push(id);
192
- count++;
193
- }
194
- }
252
+ if (entry.trigger.type !== "event" && entry.trigger.type !== "hybrid") continue;
253
+ if (entry.createdAt >= sessionStartedAt) continue;
254
+ this.applyReducerEvent({
255
+ type: "LOOP_EXPIRED",
256
+ at: sessionStartedAt,
257
+ source: "session",
258
+ entityType: "loop",
259
+ entityId: id,
260
+ payload: { id, reason: "resume_event_stale" },
261
+ });
262
+ count++;
195
263
  }
196
- for (const id of toDelete) this.loops.delete(id);
197
264
  return count;
198
265
  });
199
266
  }
200
267
 
201
268
  clearAll(): number {
202
269
  return this.withLock(() => {
203
- const count = this.loops.size;
204
- this.loops.clear();
205
- return count;
270
+ const ids = [...this.loops.keys()];
271
+ for (const id of ids) {
272
+ this.applyReducerEvent({
273
+ type: "LOOP_DELETED",
274
+ at: Date.now(),
275
+ source: "system",
276
+ entityType: "loop",
277
+ entityId: id,
278
+ payload: { id },
279
+ });
280
+ }
281
+ return ids.length;
206
282
  });
207
283
  }
208
284
 
@@ -0,0 +1,32 @@
1
+ import type { ReducerEffect, ReducerEvent } from "./coordinator.js";
2
+
3
+ export type TaskBacklogEvent = ReducerEvent<
4
+ "TASK_BACKLOG_EVALUATED",
5
+ { pendingCount: number; threshold: number }
6
+ >;
7
+
8
+ export type TaskBacklogEffect =
9
+ | ReducerEffect<"ENSURE_AUTO_TASK_WORKER", { pendingCount: number; threshold: number }>
10
+ | ReducerEffect<"CLEANUP_TASK_BACKLOG_LOOPS", { pendingCount: number }>;
11
+
12
+ export function reduceTaskBacklogEvent(event: TaskBacklogEvent): TaskBacklogEffect[] {
13
+ if (event.type !== "TASK_BACKLOG_EVALUATED") return [];
14
+
15
+ const { pendingCount, threshold } = event.payload;
16
+ if (pendingCount < 0) return [];
17
+ if (pendingCount === 0) {
18
+ return [{
19
+ type: "CLEANUP_TASK_BACKLOG_LOOPS",
20
+ entityType: "task",
21
+ payload: { pendingCount },
22
+ }];
23
+ }
24
+ if (pendingCount >= threshold) {
25
+ return [{
26
+ type: "ENSURE_AUTO_TASK_WORKER",
27
+ entityType: "task",
28
+ payload: { pendingCount, threshold },
29
+ }];
30
+ }
31
+ return [];
32
+ }
@@ -0,0 +1,152 @@
1
+ import type { TaskEntry } from "./task-types.js";
2
+
3
+ export interface TaskReducerState {
4
+ nextId: number;
5
+ tasksById: Record<string, TaskEntry>;
6
+ }
7
+
8
+ export type TaskReducerEvent =
9
+ | {
10
+ type: "TASK_CREATED";
11
+ at: number;
12
+ source: "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
13
+ entityType?: "task";
14
+ entityId?: string;
15
+ payload: {
16
+ subject: string;
17
+ description: string;
18
+ metadata?: Record<string, unknown>;
19
+ };
20
+ }
21
+ | {
22
+ type: "TASK_STARTED" | "TASK_COMPLETED" | "TASK_REOPENED" | "TASK_DELETED";
23
+ at: number;
24
+ source: "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
25
+ entityType?: "task";
26
+ entityId?: string;
27
+ payload: { id: string };
28
+ }
29
+ | {
30
+ type: "TASK_UPDATED";
31
+ at: number;
32
+ source: "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
33
+ entityType?: "task";
34
+ entityId?: string;
35
+ payload: {
36
+ id: string;
37
+ subject?: string;
38
+ description?: string;
39
+ };
40
+ }
41
+ | {
42
+ type: "TASKS_PRUNED";
43
+ at: number;
44
+ source: "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
45
+ entityType?: "task";
46
+ entityId?: string;
47
+ payload: {
48
+ reason: "git_commit" | "zero_pending_cleanup" | "manual";
49
+ };
50
+ };
51
+
52
+ export type TaskReducerEffect =
53
+ | {
54
+ type: "PERSIST_TASK";
55
+ entityType: "task";
56
+ entityId: string;
57
+ payload: { task: TaskEntry };
58
+ }
59
+ | {
60
+ type: "DELETE_TASK";
61
+ entityType: "task";
62
+ entityId: string;
63
+ payload: { id: string };
64
+ };
65
+
66
+ export interface TaskReduceResult {
67
+ state: TaskReducerState;
68
+ effects: TaskReducerEffect[];
69
+ }
70
+
71
+ function cloneState(state: TaskReducerState): TaskReducerState {
72
+ return {
73
+ nextId: state.nextId,
74
+ tasksById: { ...state.tasksById },
75
+ };
76
+ }
77
+
78
+ export function reduceTaskState(state: TaskReducerState, event: TaskReducerEvent): TaskReduceResult {
79
+ if (event.type === "TASK_CREATED") {
80
+ const next = cloneState(state);
81
+ const id = String(next.nextId++);
82
+ const task: TaskEntry = {
83
+ id,
84
+ subject: event.payload.subject,
85
+ description: event.payload.description,
86
+ status: "pending",
87
+ createdAt: event.at,
88
+ updatedAt: event.at,
89
+ metadata: event.payload.metadata,
90
+ };
91
+ next.tasksById[id] = task;
92
+ return {
93
+ state: next,
94
+ effects: [{ type: "PERSIST_TASK", entityType: "task", entityId: id, payload: { task } }],
95
+ };
96
+ }
97
+
98
+ if (event.type === "TASKS_PRUNED") {
99
+ const next = cloneState(state);
100
+ const effects: TaskReducerEffect[] = [];
101
+ for (const [id, task] of Object.entries(next.tasksById)) {
102
+ if (task.status !== "completed") continue;
103
+ delete next.tasksById[id];
104
+ effects.push({ type: "DELETE_TASK", entityType: "task", entityId: id, payload: { id } });
105
+ }
106
+ return { state: next, effects };
107
+ }
108
+
109
+ const id = event.payload.id;
110
+ const current = state.tasksById[id];
111
+ if (!current) return { state, effects: [] };
112
+
113
+ if (event.type === "TASK_DELETED") {
114
+ const next = cloneState(state);
115
+ delete next.tasksById[id];
116
+ return {
117
+ state: next,
118
+ effects: [{ type: "DELETE_TASK", entityType: "task", entityId: id, payload: { id } }],
119
+ };
120
+ }
121
+
122
+ const next = cloneState(state);
123
+ const task: TaskEntry = { ...current };
124
+
125
+ if (event.type === "TASK_STARTED") {
126
+ task.status = "in_progress";
127
+ task.updatedAt = event.at;
128
+ }
129
+
130
+ if (event.type === "TASK_COMPLETED") {
131
+ task.status = "completed";
132
+ task.updatedAt = event.at;
133
+ task.completedAt = event.at;
134
+ }
135
+
136
+ if (event.type === "TASK_REOPENED") {
137
+ task.status = "pending";
138
+ task.updatedAt = event.at;
139
+ }
140
+
141
+ if (event.type === "TASK_UPDATED") {
142
+ if (event.payload.subject !== undefined) task.subject = event.payload.subject;
143
+ if (event.payload.description !== undefined) task.description = event.payload.description;
144
+ task.updatedAt = event.at;
145
+ }
146
+
147
+ next.tasksById[id] = task;
148
+ return {
149
+ state: next,
150
+ effects: [{ type: "PERSIST_TASK", entityType: "task", entityId: id, payload: { task } }],
151
+ };
152
+ }
package/src/task-store.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, join } from "node:path";
4
+ import { reduceTaskState, type TaskReducerEvent, type TaskReducerState } from "./task-reducer.js";
4
5
  import type { TaskEntry, TaskStatus, TaskStoreData } from "./task-types.js";
5
6
 
6
7
  const TASKS_DIR = join(homedir(), ".pi", "tasks");
@@ -94,23 +95,33 @@ export class TaskStore {
94
95
  }
95
96
  }
96
97
 
98
+ private toReducerState(): TaskReducerState {
99
+ return {
100
+ nextId: this.nextId,
101
+ tasksById: Object.fromEntries(this.tasks.entries()),
102
+ };
103
+ }
104
+
105
+ private applyReducerEvent(event: TaskReducerEvent): void {
106
+ const result = reduceTaskState(this.toReducerState(), event);
107
+ this.nextId = result.state.nextId;
108
+ this.tasks = new Map(Object.entries(result.state.tasksById));
109
+ }
110
+
97
111
  create(subject: string, description: string, metadata?: Record<string, unknown>): TaskEntry {
98
112
  return this.withLock(() => {
99
113
  if (this.tasks.size >= MAX_TASKS) {
100
114
  throw new Error(`Maximum of ${MAX_TASKS} tasks reached. Delete some before creating new ones.`);
101
115
  }
102
116
  const now = Date.now();
103
- const entry: TaskEntry = {
104
- id: String(this.nextId++),
105
- subject,
106
- description,
107
- status: "pending",
108
- createdAt: now,
109
- updatedAt: now,
110
- metadata,
111
- };
112
- this.tasks.set(entry.id, entry);
113
- return entry;
117
+ this.applyReducerEvent({
118
+ type: "TASK_CREATED",
119
+ at: now,
120
+ source: "tool",
121
+ entityType: "task",
122
+ payload: { subject, description, metadata },
123
+ });
124
+ return this.tasks.get(String(this.nextId - 1))!;
114
125
  });
115
126
  }
116
127
 
@@ -129,21 +140,66 @@ export class TaskStore {
129
140
  const entry = this.tasks.get(id);
130
141
  if (!entry) return undefined;
131
142
 
132
- if (fields.status !== undefined) {
133
- entry.status = fields.status;
134
- if (fields.status === "completed") entry.completedAt = Date.now();
143
+ const now = Date.now();
144
+ if (fields.status === "in_progress") {
145
+ this.applyReducerEvent({
146
+ type: "TASK_STARTED",
147
+ at: now,
148
+ source: "tool",
149
+ entityType: "task",
150
+ entityId: id,
151
+ payload: { id },
152
+ });
153
+ } else if (fields.status === "completed") {
154
+ this.applyReducerEvent({
155
+ type: "TASK_COMPLETED",
156
+ at: now,
157
+ source: "tool",
158
+ entityType: "task",
159
+ entityId: id,
160
+ payload: { id },
161
+ });
162
+ } else if (fields.status === "pending") {
163
+ this.applyReducerEvent({
164
+ type: "TASK_REOPENED",
165
+ at: now,
166
+ source: "tool",
167
+ entityType: "task",
168
+ entityId: id,
169
+ payload: { id },
170
+ });
171
+ }
172
+
173
+ if (fields.subject !== undefined || fields.description !== undefined) {
174
+ this.applyReducerEvent({
175
+ type: "TASK_UPDATED",
176
+ at: now,
177
+ source: "tool",
178
+ entityType: "task",
179
+ entityId: id,
180
+ payload: {
181
+ id,
182
+ subject: fields.subject,
183
+ description: fields.description,
184
+ },
185
+ });
135
186
  }
136
- if (fields.subject !== undefined) entry.subject = fields.subject;
137
- if (fields.description !== undefined) entry.description = fields.description;
138
- entry.updatedAt = Date.now();
139
- return entry;
187
+
188
+ return this.tasks.get(id);
140
189
  });
141
190
  }
142
191
 
143
192
  delete(id: string): boolean {
144
193
  return this.withLock(() => {
145
194
  if (!this.tasks.has(id)) return false;
146
- this.tasks.delete(id);
195
+ this.applyReducerEvent({
196
+ type: "TASK_DELETED",
197
+ at: Date.now(),
198
+ source: "tool",
199
+ entityType: "task",
200
+ entityId: id,
201
+ payload: { id },
202
+ });
147
203
  return true;
148
204
  });
149
205
  }
@@ -158,14 +214,15 @@ export class TaskStore {
158
214
 
159
215
  sweepCompleted(): number {
160
216
  return this.withLock(() => {
161
- let count = 0;
162
- for (const [id, entry] of this.tasks) {
163
- if (entry.status === "completed") {
164
- this.tasks.delete(id);
165
- count++;
166
- }
167
- }
168
- return count;
217
+ const before = this.tasks.size;
218
+ this.applyReducerEvent({
219
+ type: "TASKS_PRUNED",
220
+ at: Date.now(),
221
+ source: "system",
222
+ entityType: "task",
223
+ payload: { reason: "manual" },
224
+ });
225
+ return before - this.tasks.size;
169
226
  });
170
227
  }
171
228
  }