@trevonistrevon/pi-loop 0.6.0 → 0.6.1
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/CHANGELOG.md +13 -0
- package/README.md +17 -5
- package/dist/commands/loop-command.d.ts +9 -2
- package/dist/commands/loop-command.js +80 -50
- package/dist/index.d.ts +2 -1
- package/dist/index.js +41 -16
- package/dist/loop-format.d.ts +3 -0
- package/dist/loop-format.js +20 -0
- package/dist/loop-parse.d.ts +1 -0
- package/dist/loop-parse.js +45 -1
- package/dist/loop-reducer.d.ts +13 -1
- package/dist/loop-reducer.js +26 -0
- package/dist/monitor-manager.js +1 -1
- package/dist/notification-reducer.d.ts +2 -0
- package/dist/runtime/monitor-ondone-runtime.js +1 -1
- package/dist/runtime/notification-runtime.d.ts +2 -1
- package/dist/runtime/notification-runtime.js +19 -7
- package/dist/runtime/task-backlog-runtime.d.ts +2 -1
- package/dist/runtime/task-backlog-runtime.js +13 -9
- package/dist/scheduler.js +25 -7
- package/dist/store.d.ts +9 -1
- package/dist/store.js +57 -0
- package/dist/tools/loop-tools.d.ts +8 -0
- package/dist/tools/loop-tools.js +104 -12
- package/dist/trigger-system.js +1 -1
- package/dist/types.d.ts +24 -1
- package/package.json +10 -2
- package/src/commands/loop-command.ts +99 -54
- package/src/index.ts +43 -16
- package/src/loop-format.ts +22 -0
- package/src/loop-parse.ts +44 -1
- package/src/loop-reducer.ts +41 -1
- package/src/monitor-manager.ts +1 -1
- package/src/notification-reducer.ts +3 -0
- package/src/runtime/monitor-ondone-runtime.ts +1 -1
- package/src/runtime/notification-runtime.ts +21 -8
- package/src/runtime/task-backlog-runtime.ts +15 -11
- package/src/scheduler.ts +25 -8
- package/src/store.ts +59 -2
- package/src/tools/loop-tools.ts +128 -12
- package/src/trigger-system.ts +1 -1
- package/src/types.ts +29 -1
- package/.release-please-manifest.json +0 -3
- package/AGENTS.md +0 -123
- package/CONTRIBUTING.md +0 -79
- package/Dockerfile +0 -17
- package/benchmarks/experiment-sim.js +0 -30
- package/coverage/base.css +0 -224
- package/coverage/block-navigation.js +0 -87
- package/coverage/coverage-final.json +0 -36
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +0 -191
- package/coverage/prettify.css +0 -1
- package/coverage/prettify.js +0 -2
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +0 -210
- package/coverage/src/api.ts.html +0 -193
- package/coverage/src/commands/index.html +0 -131
- package/coverage/src/commands/loop-command.ts.html +0 -634
- package/coverage/src/commands/tasks-command.ts.html +0 -490
- package/coverage/src/coordinator.ts.html +0 -430
- package/coverage/src/index.html +0 -341
- package/coverage/src/index.ts.html +0 -1249
- package/coverage/src/loop-parse.ts.html +0 -607
- package/coverage/src/loop-reducer.ts.html +0 -559
- package/coverage/src/monitor-completion-coordinator.ts.html +0 -157
- package/coverage/src/monitor-manager.ts.html +0 -964
- package/coverage/src/monitor-reducer.ts.html +0 -583
- package/coverage/src/notification-reducer.ts.html +0 -550
- package/coverage/src/reducer-backed-store.ts.html +0 -589
- package/coverage/src/rpc/channels.ts.html +0 -376
- package/coverage/src/rpc/cross-extension-rpc.ts.html +0 -541
- package/coverage/src/rpc/index.html +0 -131
- package/coverage/src/runtime/index.html +0 -251
- package/coverage/src/runtime/loop-events.ts.html +0 -265
- package/coverage/src/runtime/monitor-ondone-runtime.ts.html +0 -310
- package/coverage/src/runtime/native-task-rpc.ts.html +0 -502
- package/coverage/src/runtime/notification-runtime.ts.html +0 -721
- package/coverage/src/runtime/scope.ts.html +0 -196
- package/coverage/src/runtime/session-runtime.ts.html +0 -679
- package/coverage/src/runtime/task-backlog-runtime.ts.html +0 -616
- package/coverage/src/runtime/task-events.ts.html +0 -208
- package/coverage/src/runtime/task-mutations.ts.html +0 -394
- package/coverage/src/runtime/task-rpc.ts.html +0 -523
- package/coverage/src/scheduler.ts.html +0 -400
- package/coverage/src/store.ts.html +0 -667
- package/coverage/src/task-backlog-coordinator.ts.html +0 -181
- package/coverage/src/task-reducer.ts.html +0 -550
- package/coverage/src/task-store.ts.html +0 -526
- package/coverage/src/tools/index.html +0 -161
- package/coverage/src/tools/loop-tools.ts.html +0 -991
- package/coverage/src/tools/monitor-tools.ts.html +0 -538
- package/coverage/src/tools/native-task-tools.ts.html +0 -493
- package/coverage/src/tools/tool-result.ts.html +0 -97
- package/coverage/src/trigger-system.ts.html +0 -547
- package/coverage/src/ui/index.html +0 -116
- package/coverage/src/ui/widget.ts.html +0 -292
- package/docker-compose.yml +0 -12
- package/docs/architecture/state-machine-migration.md +0 -502
- package/docs/architecture/state-machine-reducer-event-model.md +0 -822
- package/docs/architecture/state-machine-test-matrix.md +0 -249
- package/docs/architecture/state-machine-transition-map.md +0 -436
- package/release-please-config.json +0 -9
- package/vitest.config.ts +0 -29
package/src/scheduler.ts
CHANGED
|
@@ -8,6 +8,9 @@ function computeNextFire(entry: LoopEntry): Date {
|
|
|
8
8
|
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
|
|
9
9
|
return cronToNextFire(entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule);
|
|
10
10
|
}
|
|
11
|
+
if (entry.trigger.type === "dynamic") {
|
|
12
|
+
return new Date(entry.dynamic?.nextWakeAt ?? Date.now());
|
|
13
|
+
}
|
|
11
14
|
return new Date(Date.now() + 60000);
|
|
12
15
|
}
|
|
13
16
|
|
|
@@ -20,9 +23,19 @@ export class CronScheduler {
|
|
|
20
23
|
) {}
|
|
21
24
|
|
|
22
25
|
start(): void {
|
|
23
|
-
for (const
|
|
26
|
+
for (const storedEntry of this.store.list()) {
|
|
27
|
+
let entry = storedEntry;
|
|
24
28
|
if (entry.status !== "active") continue;
|
|
25
|
-
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
|
|
29
|
+
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
|
|
30
|
+
if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate && !this.fireTimes.has(entry.id)) {
|
|
31
|
+
entry = this.store.updateDynamic(entry.id, {
|
|
32
|
+
dynamic: {
|
|
33
|
+
awaitingUpdate: false,
|
|
34
|
+
nextWakeAt: undefined,
|
|
35
|
+
lastUpdatedAt: Date.now(),
|
|
36
|
+
},
|
|
37
|
+
}) ?? entry;
|
|
38
|
+
}
|
|
26
39
|
this.armTimer(entry);
|
|
27
40
|
}
|
|
28
41
|
}
|
|
@@ -33,7 +46,7 @@ export class CronScheduler {
|
|
|
33
46
|
}
|
|
34
47
|
|
|
35
48
|
add(entry: LoopEntry): void {
|
|
36
|
-
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
|
|
49
|
+
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
|
|
37
50
|
this.armTimer(entry);
|
|
38
51
|
}
|
|
39
52
|
}
|
|
@@ -47,12 +60,14 @@ export class CronScheduler {
|
|
|
47
60
|
}
|
|
48
61
|
|
|
49
62
|
private armTimer(entry: LoopEntry): void {
|
|
50
|
-
const _scheduleExpr = entry.trigger.type === "hybrid" ? entry.trigger.cron : (entry.trigger as { schedule: string }).schedule;
|
|
51
|
-
|
|
52
63
|
const nextFire = computeNextFire(entry);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
64
|
+
let jitter = 0;
|
|
65
|
+
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
|
|
66
|
+
const scheduleExpr = entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule;
|
|
67
|
+
const minuteField = scheduleExpr.trim().split(/\s+/)[0] ?? "";
|
|
68
|
+
const minuteStep = minuteField.startsWith("*/") ? parseInt(minuteField.slice(2), 10) || 30 : 30;
|
|
69
|
+
jitter = computeJitter(entry.id, entry.recurring, minuteStep);
|
|
70
|
+
}
|
|
56
71
|
const fireTime = nextFire.getTime() + jitter;
|
|
57
72
|
|
|
58
73
|
if (fireTime > entry.expiresAt) {
|
|
@@ -73,6 +88,8 @@ export class CronScheduler {
|
|
|
73
88
|
continue;
|
|
74
89
|
}
|
|
75
90
|
|
|
91
|
+
if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate) continue;
|
|
92
|
+
|
|
76
93
|
if (filter && !filter(entry)) continue;
|
|
77
94
|
|
|
78
95
|
if (now >= entry.expiresAt) {
|
package/src/store.ts
CHANGED
|
@@ -2,12 +2,15 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { type LoopReducerEvent, type LoopReducerState, reduceLoopState } from "./loop-reducer.js";
|
|
4
4
|
import { ReducerBackedStore } from "./reducer-backed-store.js";
|
|
5
|
-
import type { LoopEntry, LoopStoreData, Trigger } from "./types.js";
|
|
5
|
+
import type { DynamicLoopState, LoopDeletionTombstone, LoopDeletionTombstoneInput, LoopEntry, LoopStoreData, Trigger } from "./types.js";
|
|
6
6
|
|
|
7
7
|
const LOOPS_DIR = join(homedir(), ".pi", "loops");
|
|
8
8
|
const MAX_LOOPS = 25;
|
|
9
|
+
const TOMBSTONE_TTL_MS = 10 * 60 * 1000;
|
|
9
10
|
|
|
10
11
|
export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, LoopReducerEvent, LoopStoreData> {
|
|
12
|
+
private tombstones = new Map<string, LoopDeletionTombstone>();
|
|
13
|
+
|
|
11
14
|
constructor(listIdOrPath?: string) {
|
|
12
15
|
super(
|
|
13
16
|
{
|
|
@@ -22,7 +25,7 @@ export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, L
|
|
|
22
25
|
);
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; taskBacklog?: boolean; readOnly?: boolean; maxFires?: number }): LoopEntry {
|
|
28
|
+
create(trigger: Trigger, prompt: string, opts: { recurring: boolean; autoTask?: boolean; taskBacklog?: boolean; readOnly?: boolean; maxFires?: number; dynamic?: Partial<DynamicLoopState> }): LoopEntry {
|
|
26
29
|
return this.withLock(() => {
|
|
27
30
|
if (this.entries.size >= MAX_LOOPS) {
|
|
28
31
|
throw new Error(`Maximum of ${MAX_LOOPS} loops reached. Delete some before creating new ones.`);
|
|
@@ -41,6 +44,7 @@ export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, L
|
|
|
41
44
|
taskBacklog: opts.taskBacklog,
|
|
42
45
|
readOnly: opts.readOnly,
|
|
43
46
|
maxFires: opts.maxFires,
|
|
47
|
+
dynamic: opts.dynamic,
|
|
44
48
|
},
|
|
45
49
|
});
|
|
46
50
|
return this.entries.get(String(this.nextId - 1))!;
|
|
@@ -75,6 +79,22 @@ export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, L
|
|
|
75
79
|
entityId: id,
|
|
76
80
|
payload: { id },
|
|
77
81
|
});
|
|
82
|
+
if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate) {
|
|
83
|
+
this.applyReducerEvent({
|
|
84
|
+
type: "LOOP_DYNAMIC_UPDATED",
|
|
85
|
+
at: Date.now(),
|
|
86
|
+
source: "tool",
|
|
87
|
+
entityType: "loop",
|
|
88
|
+
entityId: id,
|
|
89
|
+
payload: {
|
|
90
|
+
id,
|
|
91
|
+
dynamic: {
|
|
92
|
+
awaitingUpdate: false,
|
|
93
|
+
lastUpdatedAt: Date.now(),
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
78
98
|
return this.entries.get(id);
|
|
79
99
|
});
|
|
80
100
|
}
|
|
@@ -119,6 +139,43 @@ export class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, L
|
|
|
119
139
|
});
|
|
120
140
|
}
|
|
121
141
|
|
|
142
|
+
updateDynamic(id: string, fields: { prompt?: string; dynamic: Partial<DynamicLoopState> }): LoopEntry | undefined {
|
|
143
|
+
return this.withLock(() => {
|
|
144
|
+
if (!this.entries.has(id)) return undefined;
|
|
145
|
+
this.applyReducerEvent({
|
|
146
|
+
type: "LOOP_DYNAMIC_UPDATED",
|
|
147
|
+
at: Date.now(),
|
|
148
|
+
source: "tool",
|
|
149
|
+
entityType: "loop",
|
|
150
|
+
entityId: id,
|
|
151
|
+
payload: { id, prompt: fields.prompt, dynamic: fields.dynamic },
|
|
152
|
+
});
|
|
153
|
+
return this.entries.get(id);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
getDeletionTombstone(id: string): LoopDeletionTombstone | undefined {
|
|
158
|
+
const tombstone = this.tombstones.get(id);
|
|
159
|
+
if (!tombstone) return undefined;
|
|
160
|
+
if (Date.now() - tombstone.deletedAt <= TOMBSTONE_TTL_MS) return tombstone;
|
|
161
|
+
this.tombstones.delete(id);
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
recordDeletionTombstone(id: string, input: LoopDeletionTombstoneInput): LoopDeletionTombstone | undefined {
|
|
166
|
+
const entry = this.entries.get(id);
|
|
167
|
+
if (!entry) return undefined;
|
|
168
|
+
const tombstone: LoopDeletionTombstone = {
|
|
169
|
+
id,
|
|
170
|
+
reason: input.reason,
|
|
171
|
+
pendingCount: input.pendingCount,
|
|
172
|
+
deletedAt: Date.now(),
|
|
173
|
+
prompt: entry.prompt,
|
|
174
|
+
};
|
|
175
|
+
this.tombstones.set(id, tombstone);
|
|
176
|
+
return tombstone;
|
|
177
|
+
}
|
|
178
|
+
|
|
122
179
|
delete(id: string): boolean {
|
|
123
180
|
return this.withLock(() => {
|
|
124
181
|
if (!this.entries.has(id)) return false;
|
package/src/tools/loop-tools.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
+
import { formatTrigger } from "../loop-format.js";
|
|
3
4
|
import { parseInterval } from "../loop-parse.js";
|
|
4
5
|
import type { LoopEntry, Trigger } from "../types.js";
|
|
5
6
|
import { textResult } from "./tool-result.js";
|
|
@@ -15,6 +16,8 @@ interface LoopStoreLike {
|
|
|
15
16
|
maxFires?: number;
|
|
16
17
|
}): LoopEntry;
|
|
17
18
|
pause(id: string): LoopEntry | undefined;
|
|
19
|
+
updateDynamic(id: string, fields: { prompt?: string; dynamic: Partial<NonNullable<LoopEntry["dynamic"]>> }): LoopEntry | undefined;
|
|
20
|
+
getDeletionTombstone(id: string): { reason: string; pendingCount?: number } | undefined;
|
|
18
21
|
delete(id: string): boolean;
|
|
19
22
|
}
|
|
20
23
|
|
|
@@ -82,6 +85,89 @@ function formatRemaining(ms: number): string {
|
|
|
82
85
|
return `${Math.round(ms / 3600000)}h`;
|
|
83
86
|
}
|
|
84
87
|
|
|
88
|
+
function parseDelayMs(input: string): number | undefined {
|
|
89
|
+
const match = input.trim().match(/^(\d+)\s*(s|m|h|d)$/i);
|
|
90
|
+
if (!match) return undefined;
|
|
91
|
+
const value = Number.parseInt(match[1] ?? "", 10);
|
|
92
|
+
const unit = (match[2] ?? "").toLowerCase();
|
|
93
|
+
const multiplier = unit === "s" ? 1000 : unit === "m" ? 60000 : unit === "h" ? 3600000 : 86400000;
|
|
94
|
+
return value * multiplier;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface LoopUpdateParams {
|
|
98
|
+
id: string;
|
|
99
|
+
status: "continue" | "completed" | "paused";
|
|
100
|
+
state?: string;
|
|
101
|
+
metrics?: string;
|
|
102
|
+
doneCriteria?: string;
|
|
103
|
+
nextInterval?: string;
|
|
104
|
+
prompt?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolveNextWakeAt(nextInterval?: string): { nextWakeAt?: number; error?: string } {
|
|
108
|
+
if (!nextInterval) return { nextWakeAt: undefined };
|
|
109
|
+
const parsedDelayMs = parseDelayMs(nextInterval);
|
|
110
|
+
if (!parsedDelayMs) return { error: `Invalid nextInterval "${nextInterval}". Use formats like 3m, 30s, or 1h.` };
|
|
111
|
+
return { nextWakeAt: Date.now() + parsedDelayMs };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function formatDynamicUpdateResult(id: string, iteration: number | undefined, nextWakeAt: number | undefined): string {
|
|
115
|
+
const mode = nextWakeAt === undefined
|
|
116
|
+
? "Next wake: when idle"
|
|
117
|
+
: `Next wake: ${formatRemaining(Math.max(0, nextWakeAt - Date.now()))}`;
|
|
118
|
+
return `Dynamic loop #${id} updated\n` +
|
|
119
|
+
`Iteration: ${iteration ?? "?"}` +
|
|
120
|
+
`\n${mode}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function formatDeletionTombstone(id: string, tombstone: { reason: string; pendingCount?: number }): string {
|
|
124
|
+
const detail = tombstone.pendingCount === undefined ? "" : ` (pending: ${tombstone.pendingCount})`;
|
|
125
|
+
return `Loop #${id} already auto-deleted: ${tombstone.reason}${detail}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function continueDynamicLoop(
|
|
129
|
+
params: LoopUpdateParams,
|
|
130
|
+
entry: LoopEntry & { dynamic: NonNullable<LoopEntry["dynamic"]> },
|
|
131
|
+
store: LoopStoreLike,
|
|
132
|
+
triggerSystem: TriggerSystemLike,
|
|
133
|
+
): string {
|
|
134
|
+
const { nextWakeAt, error } = resolveNextWakeAt(params.nextInterval);
|
|
135
|
+
if (error) return error;
|
|
136
|
+
|
|
137
|
+
const updated = store.updateDynamic(params.id, {
|
|
138
|
+
prompt: params.prompt,
|
|
139
|
+
dynamic: {
|
|
140
|
+
goal: params.prompt ?? entry.dynamic.goal,
|
|
141
|
+
state: params.state,
|
|
142
|
+
metrics: params.metrics,
|
|
143
|
+
doneCriteria: params.doneCriteria,
|
|
144
|
+
iteration: (entry.dynamic.iteration ?? 0) + 1,
|
|
145
|
+
nextWakeAt,
|
|
146
|
+
awaitingUpdate: false,
|
|
147
|
+
lastUpdatedAt: Date.now(),
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
if (updated) {
|
|
151
|
+
triggerSystem.remove(params.id);
|
|
152
|
+
triggerSystem.add(updated);
|
|
153
|
+
}
|
|
154
|
+
return formatDynamicUpdateResult(params.id, updated?.dynamic?.iteration, nextWakeAt);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function stopDynamicLoop(
|
|
158
|
+
params: LoopUpdateParams,
|
|
159
|
+
store: LoopStoreLike,
|
|
160
|
+
triggerSystem: TriggerSystemLike,
|
|
161
|
+
): string {
|
|
162
|
+
triggerSystem.remove(params.id);
|
|
163
|
+
if (params.status === "completed") {
|
|
164
|
+
store.delete(params.id);
|
|
165
|
+
return `Dynamic loop #${params.id} completed and deleted`;
|
|
166
|
+
}
|
|
167
|
+
store.pause(params.id);
|
|
168
|
+
return `Dynamic loop #${params.id} paused`;
|
|
169
|
+
}
|
|
170
|
+
|
|
85
171
|
export function registerLoopTools(options: LoopToolsOptions): void {
|
|
86
172
|
const {
|
|
87
173
|
pi,
|
|
@@ -216,11 +302,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
216
302
|
const bootstrapped = await maybeBootstrapTaskLoop(entry);
|
|
217
303
|
updateWidget();
|
|
218
304
|
|
|
219
|
-
const triggerDesc = trigger
|
|
220
|
-
? `schedule: ${trigger.schedule}`
|
|
221
|
-
: trigger.type === "event"
|
|
222
|
-
? `event: ${trigger.source}`
|
|
223
|
-
: `hybrid: cron ${trigger.cron} + event ${trigger.event.source}`;
|
|
305
|
+
const triggerDesc = formatTrigger(trigger, "create");
|
|
224
306
|
|
|
225
307
|
return Promise.resolve(textResult(
|
|
226
308
|
`Loop #${entry.id} created: ${entry.prompt.slice(0, 60)}\n` +
|
|
@@ -248,13 +330,9 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
|
|
|
248
330
|
|
|
249
331
|
const lines: string[] = [];
|
|
250
332
|
for (const entry of loops) {
|
|
251
|
-
const triggerDesc = entry.trigger
|
|
252
|
-
? `cron: ${entry.trigger.schedule}`
|
|
253
|
-
: entry.trigger.type === "event"
|
|
254
|
-
? `event: ${entry.trigger.source}`
|
|
255
|
-
: `hybrid: ${entry.trigger.cron} + ${entry.trigger.event.source}`;
|
|
333
|
+
const triggerDesc = formatTrigger(entry.trigger, "list");
|
|
256
334
|
|
|
257
|
-
const nextFire = entry.trigger.type
|
|
335
|
+
const nextFire = entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.dynamic?.nextWakeAt !== undefined ? getScheduler().nextFire(entry.id) : undefined;
|
|
258
336
|
const statusIcon = entry.status === "active" ? "*" : entry.status === "paused" ? "-" : "x";
|
|
259
337
|
let line = `${statusIcon} #${entry.id} [${entry.status}] ${entry.prompt.slice(0, 60)}`;
|
|
260
338
|
line += ` (${triggerDesc})`;
|
|
@@ -271,6 +349,38 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
|
|
|
271
349
|
},
|
|
272
350
|
});
|
|
273
351
|
|
|
352
|
+
pi.registerTool({
|
|
353
|
+
name: "LoopUpdate",
|
|
354
|
+
label: "LoopUpdate",
|
|
355
|
+
description: `Update progress for a dynamic loop.
|
|
356
|
+
|
|
357
|
+
Use this after a dynamic loop wake. Mark status as "continue" with updated state/metrics and optional nextInterval, "completed" when the goal is done, or "paused" to stop temporarily.`,
|
|
358
|
+
parameters: Type.Object({
|
|
359
|
+
id: Type.String({ description: "Dynamic loop ID to update" }),
|
|
360
|
+
status: Type.String({ description: "continue, completed, or paused", enum: ["continue", "completed", "paused"] }),
|
|
361
|
+
state: Type.Optional(Type.String({ description: "Current progress/state summary" })),
|
|
362
|
+
metrics: Type.Optional(Type.String({ description: "Current metrics/check results" })),
|
|
363
|
+
doneCriteria: Type.Optional(Type.String({ description: "Definition of done for the dynamic loop" })),
|
|
364
|
+
nextInterval: Type.Optional(Type.String({ description: "When to wake next, e.g. 3m, 30s, 1h" })),
|
|
365
|
+
prompt: Type.Optional(Type.String({ description: "Optional updated goal/prompt text" })),
|
|
366
|
+
}),
|
|
367
|
+
execute(_toolCallId, params: LoopUpdateParams) {
|
|
368
|
+
const store = getStore();
|
|
369
|
+
const triggerSystem = getTriggerSystem();
|
|
370
|
+
const entry = store.get(params.id);
|
|
371
|
+
if (!entry) return Promise.resolve(textResult(`Loop #${params.id} not found`));
|
|
372
|
+
if (entry.trigger.type !== "dynamic" || !entry.dynamic) {
|
|
373
|
+
return Promise.resolve(textResult(`Loop #${params.id} is not a dynamic loop`));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const message = params.status === "continue"
|
|
377
|
+
? continueDynamicLoop(params, entry as LoopEntry & { dynamic: NonNullable<LoopEntry["dynamic"]> }, store, triggerSystem)
|
|
378
|
+
: stopDynamicLoop(params, store, triggerSystem);
|
|
379
|
+
updateWidget();
|
|
380
|
+
return Promise.resolve(textResult(message));
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
|
|
274
384
|
pi.registerTool({
|
|
275
385
|
name: "LoopDelete",
|
|
276
386
|
label: "LoopDelete",
|
|
@@ -286,7 +396,11 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
|
|
|
286
396
|
|
|
287
397
|
if (action === "pause") {
|
|
288
398
|
const entry = getStore().pause(id);
|
|
289
|
-
if (!entry)
|
|
399
|
+
if (!entry) {
|
|
400
|
+
const tombstone = getStore().getDeletionTombstone(id);
|
|
401
|
+
if (tombstone) return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone)));
|
|
402
|
+
return Promise.resolve(textResult(`Loop #${id} not found`));
|
|
403
|
+
}
|
|
290
404
|
getTriggerSystem().remove(id);
|
|
291
405
|
updateWidget();
|
|
292
406
|
return Promise.resolve(textResult(`Loop #${id} paused`));
|
|
@@ -296,6 +410,8 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
|
|
|
296
410
|
const deleted = getStore().delete(id);
|
|
297
411
|
updateWidget();
|
|
298
412
|
if (deleted) return Promise.resolve(textResult(`Loop #${id} deleted`));
|
|
413
|
+
const tombstone = getStore().getDeletionTombstone(id);
|
|
414
|
+
if (tombstone) return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone)));
|
|
299
415
|
return Promise.resolve(textResult(`Loop #${id} not found`));
|
|
300
416
|
},
|
|
301
417
|
});
|
package/src/trigger-system.ts
CHANGED
|
@@ -31,7 +31,7 @@ export class TriggerSystem {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
add(entry: LoopEntry): void {
|
|
34
|
-
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
|
|
34
|
+
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
|
|
35
35
|
this.scheduler.add(entry);
|
|
36
36
|
}
|
|
37
37
|
if (entry.trigger.type === "event" || entry.trigger.type === "hybrid") {
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
export type LoopDeletionReason = "task_backlog_empty";
|
|
2
|
+
|
|
3
|
+
export interface LoopDeletionTombstone {
|
|
4
|
+
id: string;
|
|
5
|
+
reason: LoopDeletionReason;
|
|
6
|
+
deletedAt: number;
|
|
7
|
+
prompt: string;
|
|
8
|
+
pendingCount?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type LoopDeletionTombstoneInput = Omit<LoopDeletionTombstone, "id" | "deletedAt" | "prompt">;
|
|
12
|
+
|
|
1
13
|
export type LoopStatus = "active" | "paused";
|
|
2
14
|
|
|
3
15
|
export interface CronTrigger {
|
|
@@ -18,7 +30,22 @@ export interface HybridTrigger {
|
|
|
18
30
|
debounceMs: number;
|
|
19
31
|
}
|
|
20
32
|
|
|
21
|
-
export
|
|
33
|
+
export interface DynamicTrigger {
|
|
34
|
+
type: "dynamic";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type Trigger = CronTrigger | EventTrigger | HybridTrigger | DynamicTrigger;
|
|
38
|
+
|
|
39
|
+
export interface DynamicLoopState {
|
|
40
|
+
goal: string;
|
|
41
|
+
state?: string;
|
|
42
|
+
metrics?: string;
|
|
43
|
+
doneCriteria?: string;
|
|
44
|
+
iteration: number;
|
|
45
|
+
nextWakeAt?: number;
|
|
46
|
+
awaitingUpdate?: boolean;
|
|
47
|
+
lastUpdatedAt?: number;
|
|
48
|
+
}
|
|
22
49
|
|
|
23
50
|
export interface LoopEntry {
|
|
24
51
|
id: string;
|
|
@@ -34,6 +61,7 @@ export interface LoopEntry {
|
|
|
34
61
|
readOnly?: boolean;
|
|
35
62
|
maxFires?: number;
|
|
36
63
|
fireCount?: number;
|
|
64
|
+
dynamic?: DynamicLoopState;
|
|
37
65
|
}
|
|
38
66
|
|
|
39
67
|
export interface LoopStoreData {
|
package/AGENTS.md
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
# pi-loop Development Guidelines
|
|
2
|
-
|
|
3
|
-
## Overview
|
|
4
|
-
`pi-loop` is a pi extension providing cron/event-based agent re-wake loops and background process monitoring. Modeled after Claude Code's `/loop`, `CronCreate`, and `MonitorCreate` tools.
|
|
5
|
-
|
|
6
|
-
## Stack
|
|
7
|
-
- TypeScript 6.x (strict, ES2022 target, bundler module resolution)
|
|
8
|
-
- `typebox` for tool parameter validation
|
|
9
|
-
- `vitest` for tests
|
|
10
|
-
- `biome` for linting (linter: on, formatter: off)
|
|
11
|
-
- npm packaging as `@trevonistrevon/pi-loop`
|
|
12
|
-
|
|
13
|
-
## Architecture
|
|
14
|
-
```
|
|
15
|
-
src/
|
|
16
|
-
├── index.ts # Extension entry: tool/command registration, session wiring, native task fallback timer
|
|
17
|
-
├── api.ts # Public subpath (@trevonistrevon/pi-loop/api): RPC channels/DTOs, TaskStore, scope resolvers
|
|
18
|
-
├── types.ts # LoopKind, Trigger spec, LoopEntry, MonitorEntry, LoopConfig
|
|
19
|
-
├── task-types.ts # TaskEntry, TaskStatus, TaskStoreData
|
|
20
|
-
├── store.ts # File-backed CRUD (.pi/loops/loops.json) with file locking
|
|
21
|
-
├── task-store.ts # File-backed CRUD for native fallback tasks (.pi/tasks/tasks.json)
|
|
22
|
-
├── reducer-backed-store.ts # Shared atomic-write + pid-lock persistence layer for reducer-driven stores
|
|
23
|
-
├── coordinator.ts # ReducerSource/ReducerEvent/ReducerEffect plumbing shared by *-reducer/*-coordinator modules
|
|
24
|
-
├── loop-reducer.ts # Pure loop state transitions, incl. maxFires/expiry checks
|
|
25
|
-
├── monitor-reducer.ts # Pure monitor state transitions
|
|
26
|
-
├── notification-reducer.ts # Pure pending-notification state transitions
|
|
27
|
-
├── task-reducer.ts # Pure native task state transitions
|
|
28
|
-
├── monitor-completion-coordinator.ts # Reduces monitor "onDone" completion into a fire effect
|
|
29
|
-
├── task-backlog-coordinator.ts # Reduces pending-task counts into auto-create/auto-clean effects
|
|
30
|
-
├── scheduler.ts # Timer-based cron scheduler with jitter + 7-day expiry
|
|
31
|
-
├── trigger-system.ts # Unified trigger engine: cron timers + pi event subscriptions + hybrid
|
|
32
|
-
├── monitor-manager.ts # ChildProcess tracking, output buffering, event emission, stop
|
|
33
|
-
├── loop-parse.ts # Human interval → cron expression, next-fire computation, jitter
|
|
34
|
-
├── rpc/ # VENDORED — canonical copy shared verbatim with pi-orca; see "Cross-extension RPC" below
|
|
35
|
-
│ ├── channels.ts # TASKS_RPC/SUBAGENTS_RPC/TASK_EVENTS channel constants + wire DTOs
|
|
36
|
-
│ └── cross-extension-rpc.ts # rpcCall (client), rpcProbe (detection), handleRpc (server), PROTOCOL_VERSION, RpcError
|
|
37
|
-
├── runtime/ # Extension-init-time wiring, one module per concern
|
|
38
|
-
│ ├── loop-events.ts # Auto-delete payload types for backlog-drained loops
|
|
39
|
-
│ ├── monitor-ondone-runtime.ts # Coordinator wiring for MonitorCreate's onDone → loop fire
|
|
40
|
-
│ ├── native-task-rpc.ts # tasks:rpc:* server: ping/pending/create/clean/update over the native TaskStore
|
|
41
|
-
│ ├── notification-runtime.ts # Pending-notification buffering + idle-driven delivery
|
|
42
|
-
│ ├── scope.ts # PI_LOOP_SCOPE → loop/task store path resolution
|
|
43
|
-
│ ├── session-runtime.ts # Session-switch hooks, store (re)binding
|
|
44
|
-
│ ├── task-backlog-runtime.ts # Auto-create/auto-clean backlog worker evaluation
|
|
45
|
-
│ ├── task-events.ts # emitNativeTaskEvent: tasks:created/started/completed/reopened/updated/deleted
|
|
46
|
-
│ ├── task-mutations.ts # Shared mutation service: mutate → emit event → widget → backlog, used by RPC server and tools
|
|
47
|
-
│ └── task-rpc.ts # Client bridge: probes pi-tasks, falls back to native TaskStore for autoTask/pending/clean
|
|
48
|
-
├── tools/ # Tool implementations
|
|
49
|
-
│ ├── loop-tools.ts # LoopCreate/LoopList/LoopDelete
|
|
50
|
-
│ ├── monitor-tools.ts # MonitorCreate/MonitorList/MonitorStop
|
|
51
|
-
│ ├── native-task-tools.ts # TaskCreate/TaskList/TaskUpdate/TaskDelete (fallback only)
|
|
52
|
-
│ └── tool-result.ts # textResult() helper
|
|
53
|
-
├── commands/
|
|
54
|
-
│ ├── loop-command.ts # /loop interactive loop creation
|
|
55
|
-
│ └── tasks-command.ts # /tasks native task viewer/manager
|
|
56
|
-
└── ui/
|
|
57
|
-
└── widget.ts # Persistent widget: active loops + monitors + task summary
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
## Cross-extension RPC
|
|
61
|
-
`src/rpc/` is a **vendored** module: the canonical copy is maintained here and copied verbatim into the sibling `pi-orca` repo. If you edit `channels.ts` or `cross-extension-rpc.ts`, copy the change to `pi-orca` and bump the `VENDOR_REV` comment at the top of both files in both repos — the two copies must never drift.
|
|
62
|
-
|
|
63
|
-
The wire contract is request/reply over `pi.events`: a caller emits `{ requestId, ...params }` on a channel (e.g. `tasks:rpc:create`), the server replies on `<channel>:reply:<requestId>` with an envelope, `{success:true,data}` or `{success:false,error}`. `rpcCall` resolves/rejects that promise, `rpcProbe` swallows the rejection into `undefined` for presence detection, `handleRpc` is the server-side helper that turns a handler function into a channel subscription with automatic envelope wrapping. `PROTOCOL_VERSION` (currently `2`) is returned in ping replies so callers can gate on server capability.
|
|
64
|
-
|
|
65
|
-
`pi-loop`'s native tasks RPC server (`src/runtime/native-task-rpc.ts`) registers at extension init — not behind the native-tool-registration fallback timer — so early cross-extension calls never race it. It stands down (silent no-op via `isEnabled`) once an external `pi-tasks` is detected.
|
|
66
|
-
|
|
67
|
-
## Public API surface
|
|
68
|
-
External consumers must import `@trevonistrevon/pi-loop/api` (`src/api.ts`), never a deep `src/...` path. The package `exports` map in `package.json` enforces this — deep imports fail to resolve. `src/api.ts` re-exports the RPC channel constants/DTOs, `rpcCall`/`rpcProbe`/`handleRpc`/`RpcError`/`PROTOCOL_VERSION`, `TaskStore`, `TaskEntry`/`TaskStatus`, `resolveLoopStorePath`/`resolveTaskStorePath`, and `NATIVE_TASKS_PROVIDER`. Anything not re-exported there is internal and may change without notice.
|
|
69
|
-
|
|
70
|
-
## Conventions (mirror pi-tasks)
|
|
71
|
-
- No comments unless answering "why", never "what"
|
|
72
|
-
- `debug(...)` helper gated on `PI_LOOP_DEBUG` env var, logs to stderr
|
|
73
|
-
- `textResult(msg)` helper for uniform tool output
|
|
74
|
-
- All tool params use `Type.Object()` with description strings
|
|
75
|
-
- Tool descriptions follow Claude Code format: `## When to Use`, `## When NOT to Use`
|
|
76
|
-
- Cross-extension communication via `pi.events` with `requestId` + reply channels
|
|
77
|
-
- File-backed stores use atomic write (write tmp → rename) + pid-based file locking
|
|
78
|
-
- Runtime tracker UI uses `UICtx.setStatus()` for compact single-line state
|
|
79
|
-
- Tests co-located in `test/`, named `<module>.test.ts`
|
|
80
|
-
|
|
81
|
-
## Tool Schema Discipline
|
|
82
|
-
- Tool calls must use the exact schema field names from the tool definition. Do not invent aliases.
|
|
83
|
-
- Example: `TaskUpdate` uses `id`, not `taskId`.
|
|
84
|
-
- When a tool validation error clearly indicates an immediately recoverable schema mismatch, correct it silently and retry. Do not emit user-facing chatter like "retrying with the correct shape" unless the recovery itself changes the user's understanding.
|
|
85
|
-
- When adding or revising tool prompt guidance, include concrete parameter-name reminders for commonly miscalled tools.
|
|
86
|
-
|
|
87
|
-
## File Locking Pattern
|
|
88
|
-
Copy TaskStore from pi-tasks: `O_EXCL` lockfile, stale PID detection, `LOCK_RETRY_MS`/`LOCK_MAX_RETRIES`
|
|
89
|
-
|
|
90
|
-
## Trigger Types
|
|
91
|
-
Three trigger types, all stored as `LoopEntry.trigger`:
|
|
92
|
-
- `{ type: "cron", schedule: "*/5 * * * *" }` — timer-based
|
|
93
|
-
- `{ type: "event", source: "tool_execution_start", filter?: "regex:..." | '{"key":"value"}' }` — eventbus-based
|
|
94
|
-
- `{ type: "hybrid", cron: "...", event: { source, filter? }, debounceMs: 30000 }` — both with debounce
|
|
95
|
-
|
|
96
|
-
All cron/hybrid loops are dynamic: they track their next fire time but only deliver on agent idle (`agent_end`/`turn_start`) rather than wall-clock timers.
|
|
97
|
-
|
|
98
|
-
## Re-wake via In-Memory Pending Notifications
|
|
99
|
-
When a loop fires, the scheduler calls `onLoopFire()` which emits `pi.events("loop:fire", ...)`. The extension buffers a pending notification in memory, re-checks whether the wake is still relevant, and only then injects a `pi.sendMessage()` custom message to wake the agent. Do not rely on early queued follow-up user messages for loop delivery; those are not extension-cancelable once handed to pi's queue.
|
|
100
|
-
|
|
101
|
-
All loops are idle-driven. Cron and hybrid loops track their next fire time but only deliver when the agent becomes idle (via `agent_end`/`turn_start`), resetting their timer from the actual delivery point.
|
|
102
|
-
|
|
103
|
-
## Monitor Streaming via PI Events
|
|
104
|
-
Monitor stdout/stderr lines are emitted as `pi.events("monitor:output", { monitorId, line, timestamp })`. Tool consumers subscribe to these events. Completion emits `"monitor:done"` / `"monitor:error"`.
|
|
105
|
-
|
|
106
|
-
## pi-tasks Integration
|
|
107
|
-
`pi-loop` probes for `@tintinweb/pi-tasks` at init via `tasks:rpc:ping` (see "Cross-extension RPC" above) and again on a `tasks:ready` listener for late binding. When an external provider answers, `LoopCreate` with `autoTask: true` calls `tasks:rpc:create` to create a tracked task on fire. When no external provider answers, `pi-loop`'s own native RPC server (`src/runtime/native-task-rpc.ts`) serves all five verbs — `ping`/`pending`/`create`/`clean`/`update` — against the native `TaskStore`, and `autoTask: true` creates a native task directly instead of over RPC.
|
|
108
|
-
|
|
109
|
-
## /loop Self-Paced Mode
|
|
110
|
-
When no interval is specified in `/loop prompt`, the loop runs in self-paced mode. The agent receives the prompt, acts on it, and uses `LoopCreate`/`LoopUpdate` to schedule the next iteration. The loop fires once, then the agent decides the next interval dynamically (matching Claude Code's dynamic interval behavior).
|
|
111
|
-
|
|
112
|
-
## Testing
|
|
113
|
-
- `vitest` with `describe`/`it` blocks
|
|
114
|
-
- In-memory stores for unit tests, `tmpdir` for file-backed tests
|
|
115
|
-
- Fake timers (`vi.useFakeTimers`) for scheduler tests
|
|
116
|
-
- Mock pi eventbus for monitor-manager tests
|
|
117
|
-
- `vitest run` in CI, `vitest` for watch mode
|
|
118
|
-
|
|
119
|
-
## Limits
|
|
120
|
-
- Maximum 25 active loops
|
|
121
|
-
- Maximum 25 running monitors
|
|
122
|
-
- 7-day expiry on recurring loops
|
|
123
|
-
- 5-minute default cron interval for self-paced mode
|
package/CONTRIBUTING.md
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
# Contributing to @trevonistrevon/pi-loop
|
|
2
|
-
|
|
3
|
-
Thanks for contributing! This document covers local dev setup, conventions, and workflow.
|
|
4
|
-
|
|
5
|
-
## Getting Started
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
git clone https://github.com/trvon/pi-loop.git
|
|
9
|
-
cd pi-loop
|
|
10
|
-
npm install
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
## Scripts
|
|
14
|
-
|
|
15
|
-
| Command | Description |
|
|
16
|
-
|---|---|
|
|
17
|
-
| `npm run typecheck` | TypeScript type checking (`tsc --noEmit`) |
|
|
18
|
-
| `npm run lint` | Lint with Biome |
|
|
19
|
-
| `npm run lint:fix` | Auto-fix lint issues |
|
|
20
|
-
| `npm test` | Run tests (`vitest run`) |
|
|
21
|
-
| `npm run test:watch` | Watch mode |
|
|
22
|
-
| `npm run test:coverage` | Run tests with coverage (used in CI) |
|
|
23
|
-
| `npm run test:e2e` | Run `test/e2e/reminder-injection.sh` (self-skips without a local llama server) |
|
|
24
|
-
| `npm run build` | Compile TypeScript |
|
|
25
|
-
|
|
26
|
-
## Architecture
|
|
27
|
-
|
|
28
|
-
See `AGENTS.md` for the full architecture overview and directory tree, including `src/rpc/` (vendored cross-extension RPC) and `src/api.ts` (public `@trevonistrevon/pi-loop/api` subpath — the only supported import path for external consumers). Key modules:
|
|
29
|
-
|
|
30
|
-
- **`src/index.ts`** — Extension entry: registers tools, `/loop` `/tasks` commands, widget lifecycle, cross-extension RPC server
|
|
31
|
-
- **`src/types.ts`** — Core types: `LoopEntry`, `MonitorEntry`, `Trigger` variants
|
|
32
|
-
- **`src/store.ts`** — File-backed CRUD with pid-based file locking (atomic write → rename)
|
|
33
|
-
- **`src/scheduler.ts`** — Timer-based cron scheduler with per-loop jitter and 7-day expiry
|
|
34
|
-
- **`src/trigger-system.ts`** — Unified engine: cron timers + pi event subscriptions + hybrid debounce
|
|
35
|
-
- **`src/monitor-manager.ts`** — `ChildProcess` wrapper: stdout/stderr streaming as pi events
|
|
36
|
-
- **`src/loop-parse.ts`** — Human interval parsing (`5m` → cron), cron matching, next-fire computation
|
|
37
|
-
- **`src/runtime/native-task-rpc.ts`** — `tasks:rpc:*` server for the native fallback task store
|
|
38
|
-
- **`src/ui/widget.ts`** — TUI widget rendering active loops + monitors above the editor
|
|
39
|
-
|
|
40
|
-
## Conventions
|
|
41
|
-
|
|
42
|
-
- **TypeScript 6.x** strict mode, ES2022 target, bundler module resolution
|
|
43
|
-
- **No comments unless answering "why"** — never "what"
|
|
44
|
-
- **`debug(...)`** helper gated on `PI_LOOP_DEBUG` env var, logs to stderr
|
|
45
|
-
- **`textResult(msg)`** helper for uniform tool output strings
|
|
46
|
-
- **Tool params** use `Type.Object()` with description strings from `typebox`
|
|
47
|
-
- **File-backed stores** use atomic write (write tmp → rename) + pid-based file locking
|
|
48
|
-
|
|
49
|
-
## Testing
|
|
50
|
-
|
|
51
|
-
Tests are co-located in `test/` as `<module>.test.ts`. The suite uses:
|
|
52
|
-
|
|
53
|
-
- **vitest** with `describe`/`it`
|
|
54
|
-
- In-memory stores for unit tests
|
|
55
|
-
- `tmpdir` for file-backed store tests
|
|
56
|
-
- `vi.useFakeTimers()` for scheduler tests
|
|
57
|
-
- Mocked pi eventbus for monitor-manager tests
|
|
58
|
-
|
|
59
|
-
```bash
|
|
60
|
-
npm test # Run once
|
|
61
|
-
npm run test:watch # Watch mode
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
### Manual fixtures (not run in CI)
|
|
65
|
-
|
|
66
|
-
- `benchmarks/experiment-sim.js` — synthetic monitor fixture: prints periodic `iteration N/M, loss=...` lines and handles `SIGTERM` with a checkpoint message. Used to manually exercise `MonitorCreate` streaming, `onDone`, and stop handling. Run with `node benchmarks/experiment-sim.js`. Not wired into CI by design — it's a fixture, not an assertion.
|
|
67
|
-
- `test/e2e/reminder-injection.sh` (`npm run test:e2e`) — drives a real `pi` session against a local llama server to validate loop/reminder injection end to end. Requires a model server reachable at `localhost:2276`; the script checks for it and self-skips (exit 0) when absent. Never runs in CI — there is no llama server in the CI environment.
|
|
68
|
-
|
|
69
|
-
## Pull Request Workflow
|
|
70
|
-
|
|
71
|
-
1. Fork, branch, implement
|
|
72
|
-
2. Ensure `npm run lint`, `npm run typecheck`, and `npm run test:coverage` all pass, then `npm run build`
|
|
73
|
-
3. Open PR against `main`
|
|
74
|
-
|
|
75
|
-
CI (`.github/workflows/ci.yml`) runs the same validation in this order on Node 20.x and 22.x: `lint` → `typecheck` → `test:coverage` → `build`.
|
|
76
|
-
|
|
77
|
-
## License
|
|
78
|
-
|
|
79
|
-
By contributing, you agree that your contributions will be licensed under the MIT License.
|
package/Dockerfile
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
FROM node:22-alpine AS base
|
|
2
|
-
WORKDIR /app
|
|
3
|
-
COPY package.json package-lock.json ./
|
|
4
|
-
RUN npm ci
|
|
5
|
-
COPY . .
|
|
6
|
-
|
|
7
|
-
FROM node:20-alpine AS node20
|
|
8
|
-
WORKDIR /app
|
|
9
|
-
COPY package.json package-lock.json ./
|
|
10
|
-
RUN npm ci
|
|
11
|
-
COPY . .
|
|
12
|
-
|
|
13
|
-
FROM node:22-alpine AS node22
|
|
14
|
-
WORKDIR /app
|
|
15
|
-
COPY package.json package-lock.json ./
|
|
16
|
-
RUN npm ci
|
|
17
|
-
COPY . .
|