@trevonistrevon/pi-loop 0.5.4 → 0.5.5

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 (74) hide show
  1. package/coverage/base.css +224 -0
  2. package/coverage/block-navigation.js +87 -0
  3. package/coverage/coverage-final.json +32 -0
  4. package/coverage/favicon.png +0 -0
  5. package/coverage/index.html +176 -0
  6. package/coverage/prettify.css +1 -0
  7. package/coverage/prettify.js +2 -0
  8. package/coverage/sort-arrow-sprite.png +0 -0
  9. package/coverage/sorter.js +210 -0
  10. package/coverage/src/commands/index.html +131 -0
  11. package/coverage/src/commands/loop-command.ts.html +634 -0
  12. package/coverage/src/commands/tasks-command.ts.html +490 -0
  13. package/coverage/src/coordinator.ts.html +430 -0
  14. package/coverage/src/goal-coordinator.ts.html +259 -0
  15. package/coverage/src/goal-reducer.ts.html +982 -0
  16. package/coverage/src/goal-store.ts.html +742 -0
  17. package/coverage/src/goal-verifier.ts.html +808 -0
  18. package/coverage/src/index.html +386 -0
  19. package/coverage/src/index.ts.html +1054 -0
  20. package/coverage/src/loop-parse.ts.html +574 -0
  21. package/coverage/src/loop-reducer.ts.html +559 -0
  22. package/coverage/src/monitor-completion-coordinator.ts.html +157 -0
  23. package/coverage/src/monitor-manager.ts.html +865 -0
  24. package/coverage/src/monitor-reducer.ts.html +583 -0
  25. package/coverage/src/notification-reducer.ts.html +550 -0
  26. package/coverage/src/reducer-backed-store.ts.html +589 -0
  27. package/coverage/src/runtime/index.html +191 -0
  28. package/coverage/src/runtime/monitor-ondone-runtime.ts.html +310 -0
  29. package/coverage/src/runtime/notification-runtime.ts.html +721 -0
  30. package/coverage/src/runtime/scope.ts.html +196 -0
  31. package/coverage/src/runtime/session-runtime.ts.html +589 -0
  32. package/coverage/src/runtime/task-backlog-runtime.ts.html +574 -0
  33. package/coverage/src/runtime/task-rpc.ts.html +535 -0
  34. package/coverage/src/scheduler.ts.html +400 -0
  35. package/coverage/src/store.ts.html +667 -0
  36. package/coverage/src/task-backlog-coordinator.ts.html +181 -0
  37. package/coverage/src/task-reducer.ts.html +550 -0
  38. package/coverage/src/task-store.ts.html +526 -0
  39. package/coverage/src/tools/index.html +146 -0
  40. package/coverage/src/tools/loop-tools.ts.html +1000 -0
  41. package/coverage/src/tools/monitor-tools.ts.html +547 -0
  42. package/coverage/src/tools/native-task-tools.ts.html +535 -0
  43. package/coverage/src/trigger-system.ts.html +547 -0
  44. package/coverage/src/ui/index.html +116 -0
  45. package/coverage/src/ui/widget.ts.html +292 -0
  46. package/dist/goal-reducer.d.ts +9 -0
  47. package/dist/goal-reducer.js +11 -2
  48. package/dist/goal-store.d.ts +4 -15
  49. package/dist/goal-store.js +32 -155
  50. package/dist/index.js +2 -1
  51. package/dist/loop-reducer.d.ts +7 -0
  52. package/dist/loop-reducer.js +9 -0
  53. package/dist/reducer-backed-store.d.ts +65 -0
  54. package/dist/reducer-backed-store.js +160 -0
  55. package/dist/store.d.ts +4 -16
  56. package/dist/store.js +25 -157
  57. package/dist/task-reducer.js +3 -0
  58. package/dist/task-store.d.ts +4 -15
  59. package/dist/task-store.js +25 -148
  60. package/dist/tools/monitor-tools.js +9 -0
  61. package/dist/trigger-system.js +2 -1
  62. package/package.json +4 -1
  63. package/src/goal-reducer.ts +20 -1
  64. package/src/goal-store.ts +35 -143
  65. package/src/index.ts +2 -1
  66. package/src/loop-reducer.ts +10 -0
  67. package/src/reducer-backed-store.ts +168 -0
  68. package/src/store.ts +28 -142
  69. package/src/task-reducer.ts +3 -0
  70. package/src/task-store.ts +28 -136
  71. package/src/tools/monitor-tools.ts +9 -0
  72. package/src/trigger-system.ts +2 -1
  73. package/vitest.config.ts +25 -0
  74. package/DIFFERENTIAL_REVIEW_REPORT.md +0 -81
@@ -1,136 +1,23 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
1
  import { homedir } from "node:os";
3
- import { dirname, isAbsolute, join } from "node:path";
2
+ import { join } from "node:path";
4
3
  import { reduceGoalState } from "./goal-reducer.js";
4
+ import { ReducerBackedStore } from "./reducer-backed-store.js";
5
5
  const GOALS_DIR = join(homedir(), ".pi", "goals");
6
- const LOCK_RETRY_MS = 50;
7
- const LOCK_MAX_RETRIES = 100;
8
6
  const MAX_GOALS = 200;
9
- function acquireLock(lockPath) {
10
- mkdirSync(dirname(lockPath), { recursive: true });
11
- for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
12
- try {
13
- writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
14
- return;
15
- }
16
- catch (e) {
17
- if (e.code === "EEXIST") {
18
- try {
19
- const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
20
- if (!pid || !isProcessRunning(pid)) {
21
- try {
22
- unlinkSync(lockPath);
23
- }
24
- catch { /* ignore */ }
25
- continue;
26
- }
27
- }
28
- catch { /* ignore read errors */ }
29
- const start = Date.now();
30
- while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
31
- continue;
32
- }
33
- throw e;
34
- }
35
- }
36
- throw new Error(`Failed to acquire lock: ${lockPath}`);
37
- }
38
- function releaseLock(lockPath) {
39
- try {
40
- unlinkSync(lockPath);
41
- }
42
- catch { /* ignore */ }
43
- }
44
- function isProcessRunning(pid) {
45
- try {
46
- process.kill(pid, 0);
47
- return true;
48
- }
49
- catch {
50
- return false;
51
- }
52
- }
53
- export class GoalStore {
54
- filePath;
55
- lockPath;
56
- lastLoadedSignature;
57
- nextId = 1;
58
- goals = new Map();
7
+ export class GoalStore extends ReducerBackedStore {
59
8
  constructor(listIdOrPath) {
60
- if (!listIdOrPath)
61
- return;
62
- const isAbsPath = isAbsolute(listIdOrPath);
63
- const filePath = isAbsPath ? listIdOrPath : join(GOALS_DIR, `${listIdOrPath}.json`);
64
- mkdirSync(dirname(filePath), { recursive: true });
65
- this.filePath = filePath;
66
- this.lockPath = filePath + ".lock";
67
- this.load();
68
- }
69
- getFileSignature() {
70
- if (!this.filePath || !existsSync(this.filePath))
71
- return undefined;
72
- const stat = statSync(this.filePath);
73
- return `${stat.mtimeMs}:${stat.size}`;
74
- }
75
- load(force = false) {
76
- if (!this.filePath)
77
- return;
78
- const signature = this.getFileSignature();
79
- if (!signature)
80
- return;
81
- if (!force && signature === this.lastLoadedSignature)
82
- return;
83
- try {
84
- const data = JSON.parse(readFileSync(this.filePath, "utf-8"));
85
- this.nextId = data.nextId;
86
- this.goals.clear();
87
- for (const goal of data.goals) {
88
- this.goals.set(goal.id, goal);
89
- }
90
- this.lastLoadedSignature = signature;
91
- }
92
- catch { /* corrupt file — start fresh */ }
93
- }
94
- save() {
95
- if (!this.filePath)
96
- return;
97
- const data = {
98
- nextId: this.nextId,
99
- goals: Array.from(this.goals.values()),
100
- };
101
- const tmpPath = this.filePath + ".tmp";
102
- writeFileSync(tmpPath, JSON.stringify(data, null, 2));
103
- renameSync(tmpPath, this.filePath);
104
- this.lastLoadedSignature = this.getFileSignature();
105
- }
106
- withLock(fn) {
107
- if (!this.lockPath)
108
- return fn();
109
- acquireLock(this.lockPath);
110
- try {
111
- this.load(true);
112
- const result = fn();
113
- this.save();
114
- return result;
115
- }
116
- finally {
117
- releaseLock(this.lockPath);
118
- }
119
- }
120
- toReducerState() {
121
- return {
122
- nextId: this.nextId,
123
- goalsById: Object.fromEntries(this.goals.entries()),
124
- };
125
- }
126
- applyReducerEvent(event) {
127
- const result = reduceGoalState(this.toReducerState(), event);
128
- this.nextId = result.state.nextId;
129
- this.goals = new Map(Object.entries(result.state.goalsById));
9
+ super({
10
+ baseDir: GOALS_DIR,
11
+ reduce: (state, event) => reduceGoalState(state, event),
12
+ toReducerState: (nextId, entries) => ({ nextId, goalsById: Object.fromEntries(entries.entries()) }),
13
+ fromReducerState: (state) => ({ nextId: state.nextId, entries: new Map(Object.entries(state.goalsById)) }),
14
+ serialize: (nextId, entries) => ({ nextId, goals: Array.from(entries.values()) }),
15
+ deserialize: (data) => ({ nextId: data.nextId, entries: new Map(data.goals.map((g) => [g.id, g])) }),
16
+ }, listIdOrPath);
130
17
  }
131
18
  create(title, description, scope, criteria, metadata) {
132
19
  return this.withLock(() => {
133
- if (this.goals.size >= MAX_GOALS) {
20
+ if (this.entries.size >= MAX_GOALS) {
134
21
  throw new Error(`Maximum of ${MAX_GOALS} goals reached. Archive some before creating new ones.`);
135
22
  }
136
23
  this.applyReducerEvent({
@@ -146,22 +33,12 @@ export class GoalStore {
146
33
  metadata,
147
34
  },
148
35
  });
149
- return this.goals.get(String(this.nextId - 1));
36
+ return this.entries.get(String(this.nextId - 1));
150
37
  });
151
38
  }
152
- get(id) {
153
- if (this.filePath)
154
- this.load();
155
- return this.goals.get(id);
156
- }
157
- list() {
158
- if (this.filePath)
159
- this.load();
160
- return Array.from(this.goals.values()).sort((a, b) => Number(a.id) - Number(b.id));
161
- }
162
39
  activate(id) {
163
40
  return this.withLock(() => {
164
- if (!this.goals.has(id))
41
+ if (!this.entries.has(id))
165
42
  return undefined;
166
43
  this.applyReducerEvent({
167
44
  type: "GOAL_ACTIVATED",
@@ -171,12 +48,12 @@ export class GoalStore {
171
48
  entityId: id,
172
49
  payload: { id },
173
50
  });
174
- return this.goals.get(id);
51
+ return this.entries.get(id);
175
52
  });
176
53
  }
177
54
  recordProgress(id, progress) {
178
55
  return this.withLock(() => {
179
- if (!this.goals.has(id))
56
+ if (!this.entries.has(id))
180
57
  return undefined;
181
58
  this.applyReducerEvent({
182
59
  type: "GOAL_PROGRESS_RECORDED",
@@ -186,12 +63,12 @@ export class GoalStore {
186
63
  entityId: id,
187
64
  payload: { id, progress },
188
65
  });
189
- return this.goals.get(id);
66
+ return this.entries.get(id);
190
67
  });
191
68
  }
192
69
  markVerificationStarted(id) {
193
70
  return this.withLock(() => {
194
- if (!this.goals.has(id))
71
+ if (!this.entries.has(id))
195
72
  return undefined;
196
73
  this.applyReducerEvent({
197
74
  type: "GOAL_VERIFICATION_STARTED",
@@ -201,12 +78,12 @@ export class GoalStore {
201
78
  entityId: id,
202
79
  payload: { id },
203
80
  });
204
- return this.goals.get(id);
81
+ return this.entries.get(id);
205
82
  });
206
83
  }
207
84
  markVerified(id, reason, progress) {
208
85
  return this.withLock(() => {
209
- if (!this.goals.has(id))
86
+ if (!this.entries.has(id))
210
87
  return undefined;
211
88
  this.applyReducerEvent({
212
89
  type: "GOAL_VERIFICATION_PASSED",
@@ -216,12 +93,12 @@ export class GoalStore {
216
93
  entityId: id,
217
94
  payload: { id, reason, progress },
218
95
  });
219
- return this.goals.get(id);
96
+ return this.entries.get(id);
220
97
  });
221
98
  }
222
99
  markFailed(id, reason, progress) {
223
100
  return this.withLock(() => {
224
- if (!this.goals.has(id))
101
+ if (!this.entries.has(id))
225
102
  return undefined;
226
103
  this.applyReducerEvent({
227
104
  type: "GOAL_FAILED",
@@ -231,12 +108,12 @@ export class GoalStore {
231
108
  entityId: id,
232
109
  payload: { id, reason, progress },
233
110
  });
234
- return this.goals.get(id);
111
+ return this.entries.get(id);
235
112
  });
236
113
  }
237
114
  markBlocked(id, reason, progress) {
238
115
  return this.withLock(() => {
239
- if (!this.goals.has(id))
116
+ if (!this.entries.has(id))
240
117
  return undefined;
241
118
  this.applyReducerEvent({
242
119
  type: "GOAL_BLOCKED",
@@ -246,12 +123,12 @@ export class GoalStore {
246
123
  entityId: id,
247
124
  payload: { id, reason, progress },
248
125
  });
249
- return this.goals.get(id);
126
+ return this.entries.get(id);
250
127
  });
251
128
  }
252
129
  unblock(id) {
253
130
  return this.withLock(() => {
254
- if (!this.goals.has(id))
131
+ if (!this.entries.has(id))
255
132
  return undefined;
256
133
  this.applyReducerEvent({
257
134
  type: "GOAL_UNBLOCKED",
@@ -261,19 +138,19 @@ export class GoalStore {
261
138
  entityId: id,
262
139
  payload: { id },
263
140
  });
264
- return this.goals.get(id);
141
+ return this.entries.get(id);
265
142
  });
266
143
  }
267
144
  updateDetails(id, fields) {
268
145
  return this.withLock(() => {
269
- if (!this.goals.has(id))
146
+ if (!this.entries.has(id))
270
147
  return undefined;
271
148
  if (fields.title === undefined
272
149
  && fields.description === undefined
273
150
  && fields.scope === undefined
274
151
  && fields.criteria === undefined
275
152
  && fields.metadata === undefined) {
276
- return this.goals.get(id);
153
+ return this.entries.get(id);
277
154
  }
278
155
  this.applyReducerEvent({
279
156
  type: "GOAL_UPDATED",
@@ -290,12 +167,12 @@ export class GoalStore {
290
167
  metadata: fields.metadata,
291
168
  },
292
169
  });
293
- return this.goals.get(id);
170
+ return this.entries.get(id);
294
171
  });
295
172
  }
296
173
  archive(id, reason) {
297
174
  return this.withLock(() => {
298
- if (!this.goals.has(id))
175
+ if (!this.entries.has(id))
299
176
  return undefined;
300
177
  this.applyReducerEvent({
301
178
  type: "GOAL_ARCHIVED",
@@ -305,7 +182,7 @@ export class GoalStore {
305
182
  entityId: id,
306
183
  payload: { id, reason },
307
184
  });
308
- return this.goals.get(id);
185
+ return this.entries.get(id);
309
186
  });
310
187
  }
311
188
  }
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { registerLoopCommand } from "./commands/loop-command.js";
17
17
  import { registerTasksCommand } from "./commands/tasks-command.js";
18
+ import { atMaxFires } from "./loop-reducer.js";
18
19
  import { MonitorManager } from "./monitor-manager.js";
19
20
  import { createMonitorOnDoneRuntime } from "./runtime/monitor-ondone-runtime.js";
20
21
  import { createNotificationRuntime, } from "./runtime/notification-runtime.js";
@@ -158,7 +159,7 @@ export default function (pi) {
158
159
  // ── Loop fire handler ──
159
160
  function onLoopFire(entry) {
160
161
  debug(`loop:fire #${entry.id}`, { prompt: entry.prompt.slice(0, 50) });
161
- if (entry.maxFires && (entry.fireCount ?? 0) >= entry.maxFires) {
162
+ if (atMaxFires(entry)) {
162
163
  debug(`loop #${entry.id} — reached maxFires ${entry.maxFires}, expiring`);
163
164
  store.delete(entry.id);
164
165
  return;
@@ -1,5 +1,12 @@
1
1
  import type { LoopEntry, Trigger } from "./types.js";
2
2
  export declare const MAX_LOOP_EXPIRY_MS: number;
3
+ /**
4
+ * Whether a loop has reached its fire cap. Single source of truth for the
5
+ * `maxFires` check shared by the fire callbacks (`onLoopFire` pre-fire guard and
6
+ * `TriggerSystem.fireLoop` post-fire cleanup). Each caller keeps its own timing;
7
+ * only the predicate is shared.
8
+ */
9
+ export declare function atMaxFires(loop: Pick<LoopEntry, "maxFires" | "fireCount">): boolean;
3
10
  type ReducerSource = "tool" | "command" | "scheduler" | "eventbus" | "monitor" | "session" | "coordinator" | "system";
4
11
  export interface LoopReducerState {
5
12
  nextId: number;
@@ -1,4 +1,13 @@
1
1
  export const MAX_LOOP_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
2
+ /**
3
+ * Whether a loop has reached its fire cap. Single source of truth for the
4
+ * `maxFires` check shared by the fire callbacks (`onLoopFire` pre-fire guard and
5
+ * `TriggerSystem.fireLoop` post-fire cleanup). Each caller keeps its own timing;
6
+ * only the predicate is shared.
7
+ */
8
+ export function atMaxFires(loop) {
9
+ return !!loop.maxFires && (loop.fireCount ?? 0) >= loop.maxFires;
10
+ }
2
11
  function cloneState(state) {
3
12
  return {
4
13
  nextId: state.nextId,
@@ -0,0 +1,65 @@
1
+ import type { AnyReducerEffect } from "./coordinator.js";
2
+ export interface ReducerResult<TState> {
3
+ state: TState;
4
+ effects: AnyReducerEffect[];
5
+ }
6
+ /**
7
+ * Per-store glue the base needs to translate between its internal
8
+ * `{ nextId, entries }` representation, the pure reducer's state shape, and the
9
+ * on-disk JSON shape. Each is a small, allocation-only function.
10
+ */
11
+ export interface ReducerBackedStoreConfig<TEntry, TState, TEvent, TData> {
12
+ /** Directory for `<listId>.json` when constructed with a bare list id. */
13
+ baseDir: string;
14
+ reduce: (state: TState, event: TEvent) => ReducerResult<TState>;
15
+ toReducerState: (nextId: number, entries: Map<string, TEntry>) => TState;
16
+ fromReducerState: (state: TState) => {
17
+ nextId: number;
18
+ entries: Map<string, TEntry>;
19
+ };
20
+ serialize: (nextId: number, entries: Map<string, TEntry>) => TData;
21
+ deserialize: (data: TData) => {
22
+ nextId: number;
23
+ entries: Map<string, TEntry>;
24
+ };
25
+ }
26
+ /**
27
+ * Shared persistence + reducer-dispatch machinery for the file-backed entity
28
+ * stores (loops, tasks, goals). Owns file locking, signature-gated load, atomic
29
+ * save, and reducer application; subclasses add only their entity-specific
30
+ * command methods.
31
+ *
32
+ * Durability boundary: every mutation runs inside {@link withLock}, which saves
33
+ * the whole file unconditionally after the callback. Reducer effects are
34
+ * therefore *not* the persistence mechanism — they are surfaced to
35
+ * {@link onEffects} (default: no-op) so cross-entity effects (e.g.
36
+ * `DISPATCH_EVENT`, `REQUEST_GOAL_VERIFICATION`) can be forwarded by the runtime
37
+ * without being silently dropped at the reducer call site.
38
+ */
39
+ export declare abstract class ReducerBackedStore<TEntry extends {
40
+ id: string;
41
+ }, TState, TEvent, TData> {
42
+ protected filePath: string | undefined;
43
+ protected lockPath: string | undefined;
44
+ private lastLoadedSignature;
45
+ protected nextId: number;
46
+ protected entries: Map<string, TEntry>;
47
+ private readonly config;
48
+ constructor(config: ReducerBackedStoreConfig<TEntry, TState, TEvent, TData>, listIdOrPath?: string);
49
+ private getFileSignature;
50
+ private load;
51
+ private save;
52
+ protected withLock<T>(fn: () => T): T;
53
+ protected applyReducerEvent(event: TEvent): void;
54
+ /**
55
+ * Hook for reducer effects. Default no-op: durability is owned by
56
+ * {@link withLock}. Override to forward non-persistence effects.
57
+ */
58
+ protected onEffects(_effects: AnyReducerEffect[]): void;
59
+ /** Reload (signature-gated) and return the entry, or undefined. */
60
+ get(id: string): TEntry | undefined;
61
+ /** Reload (signature-gated) and return all entries sorted by numeric id. */
62
+ list(): TEntry[];
63
+ /** Remove the backing file when the store is empty. No-op for memory stores. */
64
+ deleteFileIfEmpty(): boolean;
65
+ }
@@ -0,0 +1,160 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, join } from "node:path";
3
+ const LOCK_RETRY_MS = 50;
4
+ const LOCK_MAX_RETRIES = 100;
5
+ function acquireLock(lockPath) {
6
+ mkdirSync(dirname(lockPath), { recursive: true });
7
+ for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
8
+ try {
9
+ writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
10
+ return;
11
+ }
12
+ catch (e) {
13
+ if (e.code === "EEXIST") {
14
+ try {
15
+ const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
16
+ if (!pid || !isProcessRunning(pid)) {
17
+ try {
18
+ unlinkSync(lockPath);
19
+ }
20
+ catch { /* ignore */ }
21
+ continue;
22
+ }
23
+ }
24
+ catch { /* ignore read errors */ }
25
+ const start = Date.now();
26
+ while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
27
+ continue;
28
+ }
29
+ throw e;
30
+ }
31
+ }
32
+ throw new Error(`Failed to acquire lock: ${lockPath}`);
33
+ }
34
+ function releaseLock(lockPath) {
35
+ try {
36
+ unlinkSync(lockPath);
37
+ }
38
+ catch { /* ignore */ }
39
+ }
40
+ function isProcessRunning(pid) {
41
+ try {
42
+ process.kill(pid, 0);
43
+ return true;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
49
+ /**
50
+ * Shared persistence + reducer-dispatch machinery for the file-backed entity
51
+ * stores (loops, tasks, goals). Owns file locking, signature-gated load, atomic
52
+ * save, and reducer application; subclasses add only their entity-specific
53
+ * command methods.
54
+ *
55
+ * Durability boundary: every mutation runs inside {@link withLock}, which saves
56
+ * the whole file unconditionally after the callback. Reducer effects are
57
+ * therefore *not* the persistence mechanism — they are surfaced to
58
+ * {@link onEffects} (default: no-op) so cross-entity effects (e.g.
59
+ * `DISPATCH_EVENT`, `REQUEST_GOAL_VERIFICATION`) can be forwarded by the runtime
60
+ * without being silently dropped at the reducer call site.
61
+ */
62
+ export class ReducerBackedStore {
63
+ filePath;
64
+ lockPath;
65
+ lastLoadedSignature;
66
+ nextId = 1;
67
+ entries = new Map();
68
+ config;
69
+ constructor(config, listIdOrPath) {
70
+ this.config = config;
71
+ if (!listIdOrPath)
72
+ return;
73
+ const filePath = isAbsolute(listIdOrPath) ? listIdOrPath : join(config.baseDir, `${listIdOrPath}.json`);
74
+ mkdirSync(dirname(filePath), { recursive: true });
75
+ this.filePath = filePath;
76
+ this.lockPath = `${filePath}.lock`;
77
+ this.load();
78
+ }
79
+ getFileSignature() {
80
+ if (!this.filePath || !existsSync(this.filePath))
81
+ return undefined;
82
+ const stat = statSync(this.filePath);
83
+ return `${stat.mtimeMs}:${stat.size}`;
84
+ }
85
+ load(force = false) {
86
+ if (!this.filePath)
87
+ return;
88
+ const signature = this.getFileSignature();
89
+ if (!signature)
90
+ return;
91
+ if (!force && signature === this.lastLoadedSignature)
92
+ return;
93
+ try {
94
+ const data = JSON.parse(readFileSync(this.filePath, "utf-8"));
95
+ const { nextId, entries } = this.config.deserialize(data);
96
+ this.nextId = nextId;
97
+ this.entries = entries;
98
+ this.lastLoadedSignature = signature;
99
+ }
100
+ catch { /* corrupt file — start fresh */ }
101
+ }
102
+ save() {
103
+ if (!this.filePath)
104
+ return;
105
+ const data = this.config.serialize(this.nextId, this.entries);
106
+ const tmpPath = `${this.filePath}.tmp`;
107
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2));
108
+ renameSync(tmpPath, this.filePath);
109
+ this.lastLoadedSignature = this.getFileSignature();
110
+ }
111
+ withLock(fn) {
112
+ if (!this.lockPath)
113
+ return fn();
114
+ acquireLock(this.lockPath);
115
+ try {
116
+ this.load(true);
117
+ const result = fn();
118
+ this.save();
119
+ return result;
120
+ }
121
+ finally {
122
+ releaseLock(this.lockPath);
123
+ }
124
+ }
125
+ applyReducerEvent(event) {
126
+ const result = this.config.reduce(this.config.toReducerState(this.nextId, this.entries), event);
127
+ const { nextId, entries } = this.config.fromReducerState(result.state);
128
+ this.nextId = nextId;
129
+ this.entries = entries;
130
+ if (result.effects.length > 0)
131
+ this.onEffects(result.effects);
132
+ }
133
+ /**
134
+ * Hook for reducer effects. Default no-op: durability is owned by
135
+ * {@link withLock}. Override to forward non-persistence effects.
136
+ */
137
+ onEffects(_effects) { }
138
+ /** Reload (signature-gated) and return the entry, or undefined. */
139
+ get(id) {
140
+ if (this.filePath)
141
+ this.load();
142
+ return this.entries.get(id);
143
+ }
144
+ /** Reload (signature-gated) and return all entries sorted by numeric id. */
145
+ list() {
146
+ if (this.filePath)
147
+ this.load();
148
+ return Array.from(this.entries.values()).sort((a, b) => Number(a.id) - Number(b.id));
149
+ }
150
+ /** Remove the backing file when the store is empty. No-op for memory stores. */
151
+ deleteFileIfEmpty() {
152
+ if (!this.filePath || this.entries.size > 0)
153
+ return false;
154
+ try {
155
+ unlinkSync(this.filePath);
156
+ }
157
+ catch { /* ignore */ }
158
+ return true;
159
+ }
160
+ }
package/dist/store.d.ts CHANGED
@@ -1,17 +1,8 @@
1
- import type { LoopEntry, Trigger } from "./types.js";
2
- export declare class LoopStore {
3
- private filePath;
4
- private lockPath;
5
- private lastLoadedSignature;
6
- private nextId;
7
- private loops;
1
+ import { type LoopReducerEvent, type LoopReducerState } from "./loop-reducer.js";
2
+ import { ReducerBackedStore } from "./reducer-backed-store.js";
3
+ import type { LoopEntry, LoopStoreData, Trigger } from "./types.js";
4
+ export declare class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, LoopReducerEvent, LoopStoreData> {
8
5
  constructor(listIdOrPath?: string);
9
- private getFileSignature;
10
- private load;
11
- private save;
12
- private withLock;
13
- private toReducerState;
14
- private applyReducerEvent;
15
6
  create(trigger: Trigger, prompt: string, opts: {
16
7
  recurring: boolean;
17
8
  autoTask?: boolean;
@@ -19,8 +10,6 @@ export declare class LoopStore {
19
10
  readOnly?: boolean;
20
11
  maxFires?: number;
21
12
  }): LoopEntry;
22
- get(id: string): LoopEntry | undefined;
23
- list(): LoopEntry[];
24
13
  pause(id: string): LoopEntry | undefined;
25
14
  resume(id: string): LoopEntry | undefined;
26
15
  fire(id: string): LoopEntry | undefined;
@@ -35,5 +24,4 @@ export declare class LoopStore {
35
24
  clearExpired(): number;
36
25
  expireEventLoops(sessionStartedAt: number): number;
37
26
  clearAll(): number;
38
- deleteFileIfEmpty(): boolean;
39
27
  }