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