@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createCoordinator, } from "../coordinator.js";
|
|
2
|
+
import { formatTrigger } from "../loop-format.js";
|
|
2
3
|
import { reduceNotificationState, } from "../notification-reducer.js";
|
|
3
4
|
export function createNotificationRuntime(options) {
|
|
4
5
|
const { pi, hasPendingTasks, cleanDoneTasks, getHasPendingMessages, debug } = options;
|
|
@@ -43,18 +44,28 @@ export function createNotificationRuntime(options) {
|
|
|
43
44
|
});
|
|
44
45
|
}
|
|
45
46
|
function buildLoopFireMessage(data) {
|
|
46
|
-
const triggerInfo =
|
|
47
|
-
? data.trigger
|
|
48
|
-
: data.trigger?.type === "cron"
|
|
49
|
-
? `schedule: ${data.trigger.schedule}`
|
|
50
|
-
: data.trigger?.type === "event"
|
|
51
|
-
? `event: ${data.trigger.source}`
|
|
52
|
-
: "hybrid";
|
|
47
|
+
const triggerInfo = formatTrigger(data.trigger, "notification");
|
|
53
48
|
const loopId = data.loopId || "?";
|
|
54
49
|
const prompt = data.prompt || "loop fired";
|
|
55
50
|
const constraint = data.readOnly
|
|
56
51
|
? "\n\nREAD-ONLY MODE — use only read tools (Read, TaskList, LoopList, MonitorList, etc.). No file writes, shell execution, or destructive changes."
|
|
57
52
|
: "";
|
|
53
|
+
if (data.dynamic || (typeof data.trigger !== "string" && data.trigger?.type === "dynamic")) {
|
|
54
|
+
const dynamic = data.dynamic;
|
|
55
|
+
const lines = [
|
|
56
|
+
`[pi-loop] Loop #${loopId} fired (dynamic).${constraint}`,
|
|
57
|
+
`Goal: ${dynamic?.goal ?? prompt}`,
|
|
58
|
+
`Iteration: ${dynamic?.iteration ?? 0}`,
|
|
59
|
+
];
|
|
60
|
+
if (dynamic?.state)
|
|
61
|
+
lines.push(`State: ${dynamic.state}`);
|
|
62
|
+
if (dynamic?.metrics)
|
|
63
|
+
lines.push(`Metrics: ${dynamic.metrics}`);
|
|
64
|
+
if (dynamic?.doneCriteria)
|
|
65
|
+
lines.push(`Done criteria: ${dynamic.doneCriteria}`);
|
|
66
|
+
lines.push("Continue toward the goal. When done call LoopUpdate with status=\"completed\". If more work remains, call LoopUpdate with status=\"continue\" plus state/metrics. Omit nextInterval for idle-driven rewake; include nextInterval for a timed wake. If blocked, use status=\"paused\".");
|
|
67
|
+
return lines.join("\n");
|
|
68
|
+
}
|
|
58
69
|
return [
|
|
59
70
|
`[pi-loop] Loop #${loopId} fired (${triggerInfo}).${constraint}`,
|
|
60
71
|
prompt,
|
|
@@ -88,6 +99,7 @@ export function createNotificationRuntime(options) {
|
|
|
88
99
|
recurring: notification.recurring,
|
|
89
100
|
readOnly: notification.readOnly,
|
|
90
101
|
autoTask: notification.autoTask,
|
|
102
|
+
dynamic: notification.dynamic,
|
|
91
103
|
timestamp: notification.timestamp,
|
|
92
104
|
},
|
|
93
105
|
}, {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TaskStore } from "../task-store.js";
|
|
2
|
-
import type { LoopEntry, Trigger } from "../types.js";
|
|
2
|
+
import type { LoopDeletionTombstoneInput, LoopEntry, Trigger } from "../types.js";
|
|
3
3
|
import { type LoopAutodeletedPayload, type TaskBacklogEmptyPayload } from "./loop-events.js";
|
|
4
4
|
export declare const AUTO_TASK_WORKER_THRESHOLD = 5;
|
|
5
5
|
export declare const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, complete it. If no pending tasks remain, call LoopDelete on your own loop ID.";
|
|
@@ -11,6 +11,7 @@ export interface TaskBacklogRuntimeOptions {
|
|
|
11
11
|
maxFires?: number;
|
|
12
12
|
}) => LoopEntry;
|
|
13
13
|
deleteLoop: (id: string) => void;
|
|
14
|
+
recordDeletionTombstone?: (id: string, tombstone: LoopDeletionTombstoneInput) => void;
|
|
14
15
|
addTrigger: (entry: LoopEntry) => void;
|
|
15
16
|
removeTrigger: (id: string) => void;
|
|
16
17
|
updateWidget: () => void;
|
|
@@ -4,7 +4,7 @@ import { buildLoopAutodeletedPayload, buildTaskBacklogEmptyPayload, } from "./lo
|
|
|
4
4
|
export const AUTO_TASK_WORKER_THRESHOLD = 5;
|
|
5
5
|
export const AUTO_TASK_WORKER_PROMPT = "Run TaskList, pick next pending task, mark it in_progress, implement it, run validation, complete it. If no pending tasks remain, call LoopDelete on your own loop ID.";
|
|
6
6
|
export function createTaskBacklogRuntime(options) {
|
|
7
|
-
const { getLoops, createLoop, deleteLoop, addTrigger, removeTrigger, updateWidget, hasPendingTasks, bootstrapTaskLoop, triggerHasEventSource, emitLoopAutodeleted, emitTaskBacklogEmpty, debug, } = options;
|
|
7
|
+
const { getLoops, createLoop, deleteLoop, recordDeletionTombstone, addTrigger, removeTrigger, updateWidget, hasPendingTasks, bootstrapTaskLoop, triggerHasEventSource, emitLoopAutodeleted, emitTaskBacklogEmpty, debug, } = options;
|
|
8
8
|
function isAutoTaskWorkerLoop(entry) {
|
|
9
9
|
return entry.status === "active"
|
|
10
10
|
&& entry.prompt === AUTO_TASK_WORKER_PROMPT
|
|
@@ -18,6 +18,13 @@ export function createTaskBacklogRuntime(options) {
|
|
|
18
18
|
function findAutoTaskWorkerLoop() {
|
|
19
19
|
return getLoops().find(isAutoTaskWorkerLoop);
|
|
20
20
|
}
|
|
21
|
+
function deleteTaskBacklogLoop(entry, pendingCount) {
|
|
22
|
+
debug?.(`task backlog loop #${entry.id} — no pending tasks remain, deleting`);
|
|
23
|
+
removeTrigger(entry.id);
|
|
24
|
+
recordDeletionTombstone?.(entry.id, { reason: "task_backlog_empty", pendingCount });
|
|
25
|
+
deleteLoop(entry.id);
|
|
26
|
+
emitLoopAutodeleted?.(buildLoopAutodeletedPayload(entry, pendingCount));
|
|
27
|
+
}
|
|
21
28
|
async function cleanupTaskBacklogLoops() {
|
|
22
29
|
const backlogLoops = getLoops().filter(isTaskBacklogLoop);
|
|
23
30
|
if (backlogLoops.length === 0)
|
|
@@ -25,13 +32,10 @@ export function createTaskBacklogRuntime(options) {
|
|
|
25
32
|
const pending = await hasPendingTasks();
|
|
26
33
|
if (pending < 0 || pending > 0)
|
|
27
34
|
return 0;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
deleteLoop(entry.id);
|
|
33
|
-
emitLoopAutodeleted?.(buildLoopAutodeletedPayload(entry, pending));
|
|
34
|
-
}
|
|
35
|
+
const deletedLoopIds = backlogLoops.map((entry) => entry.id);
|
|
36
|
+
emitTaskBacklogEmpty?.(buildTaskBacklogEmptyPayload(deletedLoopIds));
|
|
37
|
+
for (const entry of backlogLoops)
|
|
38
|
+
deleteTaskBacklogLoop(entry, pending);
|
|
35
39
|
updateWidget();
|
|
36
40
|
return backlogLoops.length;
|
|
37
41
|
}
|
|
@@ -43,7 +47,7 @@ export function createTaskBacklogRuntime(options) {
|
|
|
43
47
|
return { entry: existing, created: false };
|
|
44
48
|
const trigger = {
|
|
45
49
|
type: "hybrid",
|
|
46
|
-
cron: "*/
|
|
50
|
+
cron: "*/3 * * * *",
|
|
47
51
|
event: { source: "tasks:created" },
|
|
48
52
|
debounceMs: 30000,
|
|
49
53
|
};
|
package/dist/scheduler.js
CHANGED
|
@@ -4,6 +4,9 @@ function computeNextFire(entry) {
|
|
|
4
4
|
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
|
|
5
5
|
return cronToNextFire(entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule);
|
|
6
6
|
}
|
|
7
|
+
if (entry.trigger.type === "dynamic") {
|
|
8
|
+
return new Date(entry.dynamic?.nextWakeAt ?? Date.now());
|
|
9
|
+
}
|
|
7
10
|
return new Date(Date.now() + 60000);
|
|
8
11
|
}
|
|
9
12
|
export class CronScheduler {
|
|
@@ -15,10 +18,20 @@ export class CronScheduler {
|
|
|
15
18
|
this.onFire = onFire;
|
|
16
19
|
}
|
|
17
20
|
start() {
|
|
18
|
-
for (const
|
|
21
|
+
for (const storedEntry of this.store.list()) {
|
|
22
|
+
let entry = storedEntry;
|
|
19
23
|
if (entry.status !== "active")
|
|
20
24
|
continue;
|
|
21
|
-
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
|
|
25
|
+
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
|
|
26
|
+
if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate && !this.fireTimes.has(entry.id)) {
|
|
27
|
+
entry = this.store.updateDynamic(entry.id, {
|
|
28
|
+
dynamic: {
|
|
29
|
+
awaitingUpdate: false,
|
|
30
|
+
nextWakeAt: undefined,
|
|
31
|
+
lastUpdatedAt: Date.now(),
|
|
32
|
+
},
|
|
33
|
+
}) ?? entry;
|
|
34
|
+
}
|
|
22
35
|
this.armTimer(entry);
|
|
23
36
|
}
|
|
24
37
|
}
|
|
@@ -27,7 +40,7 @@ export class CronScheduler {
|
|
|
27
40
|
this.fireTimes.clear();
|
|
28
41
|
}
|
|
29
42
|
add(entry) {
|
|
30
|
-
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
|
|
43
|
+
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
|
|
31
44
|
this.armTimer(entry);
|
|
32
45
|
}
|
|
33
46
|
}
|
|
@@ -38,11 +51,14 @@ export class CronScheduler {
|
|
|
38
51
|
return this.fireTimes.get(id);
|
|
39
52
|
}
|
|
40
53
|
armTimer(entry) {
|
|
41
|
-
const _scheduleExpr = entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule;
|
|
42
54
|
const nextFire = computeNextFire(entry);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
55
|
+
let jitter = 0;
|
|
56
|
+
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
|
|
57
|
+
const scheduleExpr = entry.trigger.type === "hybrid" ? entry.trigger.cron : entry.trigger.schedule;
|
|
58
|
+
const minuteField = scheduleExpr.trim().split(/\s+/)[0] ?? "";
|
|
59
|
+
const minuteStep = minuteField.startsWith("*/") ? parseInt(minuteField.slice(2), 10) || 30 : 30;
|
|
60
|
+
jitter = computeJitter(entry.id, entry.recurring, minuteStep);
|
|
61
|
+
}
|
|
46
62
|
const fireTime = nextFire.getTime() + jitter;
|
|
47
63
|
if (fireTime > entry.expiresAt) {
|
|
48
64
|
this.store.delete(entry.id);
|
|
@@ -59,6 +75,8 @@ export class CronScheduler {
|
|
|
59
75
|
this.fireTimes.delete(id);
|
|
60
76
|
continue;
|
|
61
77
|
}
|
|
78
|
+
if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate)
|
|
79
|
+
continue;
|
|
62
80
|
if (filter && !filter(entry))
|
|
63
81
|
continue;
|
|
64
82
|
if (now >= entry.expiresAt) {
|
package/dist/store.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type LoopReducerEvent, type LoopReducerState } from "./loop-reducer.js";
|
|
2
2
|
import { ReducerBackedStore } from "./reducer-backed-store.js";
|
|
3
|
-
import type { LoopEntry, LoopStoreData, Trigger } from "./types.js";
|
|
3
|
+
import type { DynamicLoopState, LoopDeletionTombstone, LoopDeletionTombstoneInput, LoopEntry, LoopStoreData, Trigger } from "./types.js";
|
|
4
4
|
export declare class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducerState, LoopReducerEvent, LoopStoreData> {
|
|
5
|
+
private tombstones;
|
|
5
6
|
constructor(listIdOrPath?: string);
|
|
6
7
|
create(trigger: Trigger, prompt: string, opts: {
|
|
7
8
|
recurring: boolean;
|
|
@@ -9,6 +10,7 @@ export declare class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducer
|
|
|
9
10
|
taskBacklog?: boolean;
|
|
10
11
|
readOnly?: boolean;
|
|
11
12
|
maxFires?: number;
|
|
13
|
+
dynamic?: Partial<DynamicLoopState>;
|
|
12
14
|
}): LoopEntry;
|
|
13
15
|
pause(id: string): LoopEntry | undefined;
|
|
14
16
|
resume(id: string): LoopEntry | undefined;
|
|
@@ -20,6 +22,12 @@ export declare class LoopStore extends ReducerBackedStore<LoopEntry, LoopReducer
|
|
|
20
22
|
entry: LoopEntry | undefined;
|
|
21
23
|
changedFields: string[];
|
|
22
24
|
};
|
|
25
|
+
updateDynamic(id: string, fields: {
|
|
26
|
+
prompt?: string;
|
|
27
|
+
dynamic: Partial<DynamicLoopState>;
|
|
28
|
+
}): LoopEntry | undefined;
|
|
29
|
+
getDeletionTombstone(id: string): LoopDeletionTombstone | undefined;
|
|
30
|
+
recordDeletionTombstone(id: string, input: LoopDeletionTombstoneInput): LoopDeletionTombstone | undefined;
|
|
23
31
|
delete(id: string): boolean;
|
|
24
32
|
clearExpired(): number;
|
|
25
33
|
expireEventLoops(sessionStartedAt: number): number;
|
package/dist/store.js
CHANGED
|
@@ -4,7 +4,9 @@ import { reduceLoopState } from "./loop-reducer.js";
|
|
|
4
4
|
import { ReducerBackedStore } from "./reducer-backed-store.js";
|
|
5
5
|
const LOOPS_DIR = join(homedir(), ".pi", "loops");
|
|
6
6
|
const MAX_LOOPS = 25;
|
|
7
|
+
const TOMBSTONE_TTL_MS = 10 * 60 * 1000;
|
|
7
8
|
export class LoopStore extends ReducerBackedStore {
|
|
9
|
+
tombstones = new Map();
|
|
8
10
|
constructor(listIdOrPath) {
|
|
9
11
|
super({
|
|
10
12
|
baseDir: LOOPS_DIR,
|
|
@@ -34,6 +36,7 @@ export class LoopStore extends ReducerBackedStore {
|
|
|
34
36
|
taskBacklog: opts.taskBacklog,
|
|
35
37
|
readOnly: opts.readOnly,
|
|
36
38
|
maxFires: opts.maxFires,
|
|
39
|
+
dynamic: opts.dynamic,
|
|
37
40
|
},
|
|
38
41
|
});
|
|
39
42
|
return this.entries.get(String(this.nextId - 1));
|
|
@@ -68,6 +71,22 @@ export class LoopStore extends ReducerBackedStore {
|
|
|
68
71
|
entityId: id,
|
|
69
72
|
payload: { id },
|
|
70
73
|
});
|
|
74
|
+
if (entry.trigger.type === "dynamic" && entry.dynamic?.awaitingUpdate) {
|
|
75
|
+
this.applyReducerEvent({
|
|
76
|
+
type: "LOOP_DYNAMIC_UPDATED",
|
|
77
|
+
at: Date.now(),
|
|
78
|
+
source: "tool",
|
|
79
|
+
entityType: "loop",
|
|
80
|
+
entityId: id,
|
|
81
|
+
payload: {
|
|
82
|
+
id,
|
|
83
|
+
dynamic: {
|
|
84
|
+
awaitingUpdate: false,
|
|
85
|
+
lastUpdatedAt: Date.now(),
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
71
90
|
return this.entries.get(id);
|
|
72
91
|
});
|
|
73
92
|
}
|
|
@@ -108,6 +127,44 @@ export class LoopStore extends ReducerBackedStore {
|
|
|
108
127
|
return { entry: this.entries.get(id), changedFields };
|
|
109
128
|
});
|
|
110
129
|
}
|
|
130
|
+
updateDynamic(id, fields) {
|
|
131
|
+
return this.withLock(() => {
|
|
132
|
+
if (!this.entries.has(id))
|
|
133
|
+
return undefined;
|
|
134
|
+
this.applyReducerEvent({
|
|
135
|
+
type: "LOOP_DYNAMIC_UPDATED",
|
|
136
|
+
at: Date.now(),
|
|
137
|
+
source: "tool",
|
|
138
|
+
entityType: "loop",
|
|
139
|
+
entityId: id,
|
|
140
|
+
payload: { id, prompt: fields.prompt, dynamic: fields.dynamic },
|
|
141
|
+
});
|
|
142
|
+
return this.entries.get(id);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
getDeletionTombstone(id) {
|
|
146
|
+
const tombstone = this.tombstones.get(id);
|
|
147
|
+
if (!tombstone)
|
|
148
|
+
return undefined;
|
|
149
|
+
if (Date.now() - tombstone.deletedAt <= TOMBSTONE_TTL_MS)
|
|
150
|
+
return tombstone;
|
|
151
|
+
this.tombstones.delete(id);
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
recordDeletionTombstone(id, input) {
|
|
155
|
+
const entry = this.entries.get(id);
|
|
156
|
+
if (!entry)
|
|
157
|
+
return undefined;
|
|
158
|
+
const tombstone = {
|
|
159
|
+
id,
|
|
160
|
+
reason: input.reason,
|
|
161
|
+
pendingCount: input.pendingCount,
|
|
162
|
+
deletedAt: Date.now(),
|
|
163
|
+
prompt: entry.prompt,
|
|
164
|
+
};
|
|
165
|
+
this.tombstones.set(id, tombstone);
|
|
166
|
+
return tombstone;
|
|
167
|
+
}
|
|
111
168
|
delete(id) {
|
|
112
169
|
return this.withLock(() => {
|
|
113
170
|
if (!this.entries.has(id))
|
|
@@ -11,6 +11,14 @@ interface LoopStoreLike {
|
|
|
11
11
|
maxFires?: number;
|
|
12
12
|
}): LoopEntry;
|
|
13
13
|
pause(id: string): LoopEntry | undefined;
|
|
14
|
+
updateDynamic(id: string, fields: {
|
|
15
|
+
prompt?: string;
|
|
16
|
+
dynamic: Partial<NonNullable<LoopEntry["dynamic"]>>;
|
|
17
|
+
}): LoopEntry | undefined;
|
|
18
|
+
getDeletionTombstone(id: string): {
|
|
19
|
+
reason: string;
|
|
20
|
+
pendingCount?: number;
|
|
21
|
+
} | undefined;
|
|
14
22
|
delete(id: string): boolean;
|
|
15
23
|
}
|
|
16
24
|
interface TriggerSystemLike {
|
package/dist/tools/loop-tools.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
|
+
import { formatTrigger } from "../loop-format.js";
|
|
2
3
|
import { parseInterval } from "../loop-parse.js";
|
|
3
4
|
import { textResult } from "./tool-result.js";
|
|
4
5
|
function validateTrigger(trigger) {
|
|
@@ -40,6 +41,67 @@ function formatRemaining(ms) {
|
|
|
40
41
|
return `${Math.round(ms / 60000)}m`;
|
|
41
42
|
return `${Math.round(ms / 3600000)}h`;
|
|
42
43
|
}
|
|
44
|
+
function parseDelayMs(input) {
|
|
45
|
+
const match = input.trim().match(/^(\d+)\s*(s|m|h|d)$/i);
|
|
46
|
+
if (!match)
|
|
47
|
+
return undefined;
|
|
48
|
+
const value = Number.parseInt(match[1] ?? "", 10);
|
|
49
|
+
const unit = (match[2] ?? "").toLowerCase();
|
|
50
|
+
const multiplier = unit === "s" ? 1000 : unit === "m" ? 60000 : unit === "h" ? 3600000 : 86400000;
|
|
51
|
+
return value * multiplier;
|
|
52
|
+
}
|
|
53
|
+
function resolveNextWakeAt(nextInterval) {
|
|
54
|
+
if (!nextInterval)
|
|
55
|
+
return { nextWakeAt: undefined };
|
|
56
|
+
const parsedDelayMs = parseDelayMs(nextInterval);
|
|
57
|
+
if (!parsedDelayMs)
|
|
58
|
+
return { error: `Invalid nextInterval "${nextInterval}". Use formats like 3m, 30s, or 1h.` };
|
|
59
|
+
return { nextWakeAt: Date.now() + parsedDelayMs };
|
|
60
|
+
}
|
|
61
|
+
function formatDynamicUpdateResult(id, iteration, nextWakeAt) {
|
|
62
|
+
const mode = nextWakeAt === undefined
|
|
63
|
+
? "Next wake: when idle"
|
|
64
|
+
: `Next wake: ${formatRemaining(Math.max(0, nextWakeAt - Date.now()))}`;
|
|
65
|
+
return `Dynamic loop #${id} updated\n` +
|
|
66
|
+
`Iteration: ${iteration ?? "?"}` +
|
|
67
|
+
`\n${mode}`;
|
|
68
|
+
}
|
|
69
|
+
function formatDeletionTombstone(id, tombstone) {
|
|
70
|
+
const detail = tombstone.pendingCount === undefined ? "" : ` (pending: ${tombstone.pendingCount})`;
|
|
71
|
+
return `Loop #${id} already auto-deleted: ${tombstone.reason}${detail}`;
|
|
72
|
+
}
|
|
73
|
+
function continueDynamicLoop(params, entry, store, triggerSystem) {
|
|
74
|
+
const { nextWakeAt, error } = resolveNextWakeAt(params.nextInterval);
|
|
75
|
+
if (error)
|
|
76
|
+
return error;
|
|
77
|
+
const updated = store.updateDynamic(params.id, {
|
|
78
|
+
prompt: params.prompt,
|
|
79
|
+
dynamic: {
|
|
80
|
+
goal: params.prompt ?? entry.dynamic.goal,
|
|
81
|
+
state: params.state,
|
|
82
|
+
metrics: params.metrics,
|
|
83
|
+
doneCriteria: params.doneCriteria,
|
|
84
|
+
iteration: (entry.dynamic.iteration ?? 0) + 1,
|
|
85
|
+
nextWakeAt,
|
|
86
|
+
awaitingUpdate: false,
|
|
87
|
+
lastUpdatedAt: Date.now(),
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
if (updated) {
|
|
91
|
+
triggerSystem.remove(params.id);
|
|
92
|
+
triggerSystem.add(updated);
|
|
93
|
+
}
|
|
94
|
+
return formatDynamicUpdateResult(params.id, updated?.dynamic?.iteration, nextWakeAt);
|
|
95
|
+
}
|
|
96
|
+
function stopDynamicLoop(params, store, triggerSystem) {
|
|
97
|
+
triggerSystem.remove(params.id);
|
|
98
|
+
if (params.status === "completed") {
|
|
99
|
+
store.delete(params.id);
|
|
100
|
+
return `Dynamic loop #${params.id} completed and deleted`;
|
|
101
|
+
}
|
|
102
|
+
store.pause(params.id);
|
|
103
|
+
return `Dynamic loop #${params.id} paused`;
|
|
104
|
+
}
|
|
43
105
|
export function registerLoopTools(options) {
|
|
44
106
|
const { pi, getStore, getTriggerSystem, getScheduler, getMonitorManager, updateWidget, maybeBootstrapTaskLoop, isTaskSystemReady, } = options;
|
|
45
107
|
pi.registerTool({
|
|
@@ -160,11 +222,7 @@ Skip this tool when the task is a one-off check (just do it directly) or when th
|
|
|
160
222
|
}
|
|
161
223
|
const bootstrapped = await maybeBootstrapTaskLoop(entry);
|
|
162
224
|
updateWidget();
|
|
163
|
-
const triggerDesc = trigger
|
|
164
|
-
? `schedule: ${trigger.schedule}`
|
|
165
|
-
: trigger.type === "event"
|
|
166
|
-
? `event: ${trigger.source}`
|
|
167
|
-
: `hybrid: cron ${trigger.cron} + event ${trigger.event.source}`;
|
|
225
|
+
const triggerDesc = formatTrigger(trigger, "create");
|
|
168
226
|
return Promise.resolve(textResult(`Loop #${entry.id} created: ${entry.prompt.slice(0, 60)}\n` +
|
|
169
227
|
`Trigger: ${triggerDesc}\n` +
|
|
170
228
|
`Recurring: ${entry.recurring}\n` +
|
|
@@ -188,12 +246,8 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
|
|
|
188
246
|
return Promise.resolve(textResult("No loops configured. Use LoopCreate to set up a schedule."));
|
|
189
247
|
const lines = [];
|
|
190
248
|
for (const entry of loops) {
|
|
191
|
-
const triggerDesc = entry.trigger
|
|
192
|
-
|
|
193
|
-
: entry.trigger.type === "event"
|
|
194
|
-
? `event: ${entry.trigger.source}`
|
|
195
|
-
: `hybrid: ${entry.trigger.cron} + ${entry.trigger.event.source}`;
|
|
196
|
-
const nextFire = entry.trigger.type !== "event" ? getScheduler().nextFire(entry.id) : undefined;
|
|
249
|
+
const triggerDesc = formatTrigger(entry.trigger, "list");
|
|
250
|
+
const nextFire = entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.dynamic?.nextWakeAt !== undefined ? getScheduler().nextFire(entry.id) : undefined;
|
|
197
251
|
const statusIcon = entry.status === "active" ? "*" : entry.status === "paused" ? "-" : "x";
|
|
198
252
|
let line = `${statusIcon} #${entry.id} [${entry.status}] ${entry.prompt.slice(0, 60)}`;
|
|
199
253
|
line += ` (${triggerDesc})`;
|
|
@@ -210,6 +264,37 @@ Use this before creating new loops to avoid duplicates, or to find IDs for LoopD
|
|
|
210
264
|
return Promise.resolve(textResult(lines.join("\n")));
|
|
211
265
|
},
|
|
212
266
|
});
|
|
267
|
+
pi.registerTool({
|
|
268
|
+
name: "LoopUpdate",
|
|
269
|
+
label: "LoopUpdate",
|
|
270
|
+
description: `Update progress for a dynamic loop.
|
|
271
|
+
|
|
272
|
+
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.`,
|
|
273
|
+
parameters: Type.Object({
|
|
274
|
+
id: Type.String({ description: "Dynamic loop ID to update" }),
|
|
275
|
+
status: Type.String({ description: "continue, completed, or paused", enum: ["continue", "completed", "paused"] }),
|
|
276
|
+
state: Type.Optional(Type.String({ description: "Current progress/state summary" })),
|
|
277
|
+
metrics: Type.Optional(Type.String({ description: "Current metrics/check results" })),
|
|
278
|
+
doneCriteria: Type.Optional(Type.String({ description: "Definition of done for the dynamic loop" })),
|
|
279
|
+
nextInterval: Type.Optional(Type.String({ description: "When to wake next, e.g. 3m, 30s, 1h" })),
|
|
280
|
+
prompt: Type.Optional(Type.String({ description: "Optional updated goal/prompt text" })),
|
|
281
|
+
}),
|
|
282
|
+
execute(_toolCallId, params) {
|
|
283
|
+
const store = getStore();
|
|
284
|
+
const triggerSystem = getTriggerSystem();
|
|
285
|
+
const entry = store.get(params.id);
|
|
286
|
+
if (!entry)
|
|
287
|
+
return Promise.resolve(textResult(`Loop #${params.id} not found`));
|
|
288
|
+
if (entry.trigger.type !== "dynamic" || !entry.dynamic) {
|
|
289
|
+
return Promise.resolve(textResult(`Loop #${params.id} is not a dynamic loop`));
|
|
290
|
+
}
|
|
291
|
+
const message = params.status === "continue"
|
|
292
|
+
? continueDynamicLoop(params, entry, store, triggerSystem)
|
|
293
|
+
: stopDynamicLoop(params, store, triggerSystem);
|
|
294
|
+
updateWidget();
|
|
295
|
+
return Promise.resolve(textResult(message));
|
|
296
|
+
},
|
|
297
|
+
});
|
|
213
298
|
pi.registerTool({
|
|
214
299
|
name: "LoopDelete",
|
|
215
300
|
label: "LoopDelete",
|
|
@@ -224,8 +309,12 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
|
|
|
224
309
|
const { id, action } = params;
|
|
225
310
|
if (action === "pause") {
|
|
226
311
|
const entry = getStore().pause(id);
|
|
227
|
-
if (!entry)
|
|
312
|
+
if (!entry) {
|
|
313
|
+
const tombstone = getStore().getDeletionTombstone(id);
|
|
314
|
+
if (tombstone)
|
|
315
|
+
return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone)));
|
|
228
316
|
return Promise.resolve(textResult(`Loop #${id} not found`));
|
|
317
|
+
}
|
|
229
318
|
getTriggerSystem().remove(id);
|
|
230
319
|
updateWidget();
|
|
231
320
|
return Promise.resolve(textResult(`Loop #${id} paused`));
|
|
@@ -235,6 +324,9 @@ Use "pause" to temporarily stop a loop without removing it. Use "delete" to perm
|
|
|
235
324
|
updateWidget();
|
|
236
325
|
if (deleted)
|
|
237
326
|
return Promise.resolve(textResult(`Loop #${id} deleted`));
|
|
327
|
+
const tombstone = getStore().getDeletionTombstone(id);
|
|
328
|
+
if (tombstone)
|
|
329
|
+
return Promise.resolve(textResult(formatDeletionTombstone(id, tombstone)));
|
|
238
330
|
return Promise.resolve(textResult(`Loop #${id} not found`));
|
|
239
331
|
},
|
|
240
332
|
});
|
package/dist/trigger-system.js
CHANGED
|
@@ -28,7 +28,7 @@ export class TriggerSystem {
|
|
|
28
28
|
this.hybridTimers.clear();
|
|
29
29
|
}
|
|
30
30
|
add(entry) {
|
|
31
|
-
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid") {
|
|
31
|
+
if (entry.trigger.type === "cron" || entry.trigger.type === "hybrid" || entry.trigger.type === "dynamic") {
|
|
32
32
|
this.scheduler.add(entry);
|
|
33
33
|
}
|
|
34
34
|
if (entry.trigger.type === "event" || entry.trigger.type === "hybrid") {
|
package/dist/types.d.ts
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
export type LoopDeletionReason = "task_backlog_empty";
|
|
2
|
+
export interface LoopDeletionTombstone {
|
|
3
|
+
id: string;
|
|
4
|
+
reason: LoopDeletionReason;
|
|
5
|
+
deletedAt: number;
|
|
6
|
+
prompt: string;
|
|
7
|
+
pendingCount?: number;
|
|
8
|
+
}
|
|
9
|
+
export type LoopDeletionTombstoneInput = Omit<LoopDeletionTombstone, "id" | "deletedAt" | "prompt">;
|
|
1
10
|
export type LoopStatus = "active" | "paused";
|
|
2
11
|
export interface CronTrigger {
|
|
3
12
|
type: "cron";
|
|
@@ -17,7 +26,20 @@ export interface HybridTrigger {
|
|
|
17
26
|
};
|
|
18
27
|
debounceMs: number;
|
|
19
28
|
}
|
|
20
|
-
export
|
|
29
|
+
export interface DynamicTrigger {
|
|
30
|
+
type: "dynamic";
|
|
31
|
+
}
|
|
32
|
+
export type Trigger = CronTrigger | EventTrigger | HybridTrigger | DynamicTrigger;
|
|
33
|
+
export interface DynamicLoopState {
|
|
34
|
+
goal: string;
|
|
35
|
+
state?: string;
|
|
36
|
+
metrics?: string;
|
|
37
|
+
doneCriteria?: string;
|
|
38
|
+
iteration: number;
|
|
39
|
+
nextWakeAt?: number;
|
|
40
|
+
awaitingUpdate?: boolean;
|
|
41
|
+
lastUpdatedAt?: number;
|
|
42
|
+
}
|
|
21
43
|
export interface LoopEntry {
|
|
22
44
|
id: string;
|
|
23
45
|
prompt: string;
|
|
@@ -32,6 +54,7 @@ export interface LoopEntry {
|
|
|
32
54
|
readOnly?: boolean;
|
|
33
55
|
maxFires?: number;
|
|
34
56
|
fireCount?: number;
|
|
57
|
+
dynamic?: DynamicLoopState;
|
|
35
58
|
}
|
|
36
59
|
export interface LoopStoreData {
|
|
37
60
|
nextId: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trevonistrevon/pi-loop",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "A pi extension for cron/event-based agent re-wake loops and background process monitoring.",
|
|
5
5
|
"author": "trevonistrevon",
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,6 +17,13 @@
|
|
|
17
17
|
"monitor",
|
|
18
18
|
"scheduler"
|
|
19
19
|
],
|
|
20
|
+
"files": [
|
|
21
|
+
"src/",
|
|
22
|
+
"dist/",
|
|
23
|
+
"README.md",
|
|
24
|
+
"CHANGELOG.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
20
27
|
"exports": {
|
|
21
28
|
".": "./src/index.ts",
|
|
22
29
|
"./api": "./src/api.ts",
|
|
@@ -30,7 +37,8 @@
|
|
|
30
37
|
"typebox": "^1.1.34"
|
|
31
38
|
},
|
|
32
39
|
"scripts": {
|
|
33
|
-
"
|
|
40
|
+
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
41
|
+
"build": "npm run clean && tsc",
|
|
34
42
|
"prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build",
|
|
35
43
|
"test": "vitest run",
|
|
36
44
|
"test:coverage": "vitest run --coverage",
|