pi-long-task 0.3.17 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +20 -0
- package/package.json +1 -1
- package/src/coordinator.ts +419 -38
- package/src/index.ts +42 -13
- package/src/plan_revision.ts +471 -0
- package/src/plan_revision_generation.ts +376 -0
- package/src/plan_store.ts +281 -0
- package/src/render.ts +2 -0
- package/src/steering.ts +234 -0
- package/src/todo_parser.ts +46 -7
package/src/steering.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
export type SteeringInputSource = "interactive" | "rpc";
|
|
2
|
+
|
|
3
|
+
export interface SteeringInput {
|
|
4
|
+
text: string;
|
|
5
|
+
source: "interactive" | "rpc" | "extension";
|
|
6
|
+
streamingBehavior?: "steer" | "followUp";
|
|
7
|
+
images?: readonly unknown[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type SteeringMessageStatus = "queued" | "processing" | "accepted" | "failed";
|
|
11
|
+
|
|
12
|
+
export interface SteeringMessage {
|
|
13
|
+
id: string;
|
|
14
|
+
sequence: number;
|
|
15
|
+
text: string;
|
|
16
|
+
source: SteeringInputSource;
|
|
17
|
+
receivedAt: string;
|
|
18
|
+
status: SteeringMessageStatus;
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type SteeringMessageProcessor = (message: Readonly<SteeringMessage>) => void | Promise<void>;
|
|
23
|
+
export type SteeringMessageObserver = (message: Readonly<SteeringMessage>) => void;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A run-scoped FIFO for guidance received while a long task is active.
|
|
27
|
+
*
|
|
28
|
+
* A message remains in the queue until its processor resolves successfully. A
|
|
29
|
+
* processor failure pauses the queue so later guidance cannot overtake it;
|
|
30
|
+
* callers may fix the recoverable failure and call retryFailed().
|
|
31
|
+
*/
|
|
32
|
+
export class SerializedSteeringQueue {
|
|
33
|
+
private readonly queueId: string;
|
|
34
|
+
private readonly now: () => Date;
|
|
35
|
+
private readonly onChange: SteeringMessageObserver | undefined;
|
|
36
|
+
private readonly messages: SteeringMessage[] = [];
|
|
37
|
+
private processor: SteeringMessageProcessor | undefined;
|
|
38
|
+
private sequence = 0;
|
|
39
|
+
private processing = false;
|
|
40
|
+
private failed = false;
|
|
41
|
+
private closed = false;
|
|
42
|
+
private readonly idleWaiters = new Set<() => void>();
|
|
43
|
+
|
|
44
|
+
constructor(options: { queueId: string; now?: () => Date; onChange?: SteeringMessageObserver }) {
|
|
45
|
+
const queueId = options.queueId.trim();
|
|
46
|
+
if (!queueId) {
|
|
47
|
+
throw new Error("A steering queue requires a non-empty queueId.");
|
|
48
|
+
}
|
|
49
|
+
this.queueId = queueId;
|
|
50
|
+
this.now = options.now ?? (() => new Date());
|
|
51
|
+
this.onChange = options.onChange;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
enqueue(text: string, source: SteeringInputSource): SteeringMessage {
|
|
55
|
+
if (this.closed) {
|
|
56
|
+
throw new Error("Cannot enqueue guidance after the steering queue is closed.");
|
|
57
|
+
}
|
|
58
|
+
const normalizedText = text.trim();
|
|
59
|
+
if (!normalizedText) {
|
|
60
|
+
throw new Error("Steering guidance must not be empty.");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const sequence = ++this.sequence;
|
|
64
|
+
const message: SteeringMessage = {
|
|
65
|
+
id: `${this.queueId}:${sequence}`,
|
|
66
|
+
sequence,
|
|
67
|
+
text: normalizedText,
|
|
68
|
+
source,
|
|
69
|
+
receivedAt: this.now().toISOString(),
|
|
70
|
+
status: "queued",
|
|
71
|
+
};
|
|
72
|
+
this.messages.push(message);
|
|
73
|
+
this.publish(message);
|
|
74
|
+
const queuedMessage = { ...message };
|
|
75
|
+
this.startPump();
|
|
76
|
+
return queuedMessage;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Attach the single plan-revision consumer. Pending messages start immediately. */
|
|
80
|
+
setProcessor(processor: SteeringMessageProcessor): () => void {
|
|
81
|
+
if (this.processor && this.processor !== processor) {
|
|
82
|
+
throw new Error("A steering queue already has a processor.");
|
|
83
|
+
}
|
|
84
|
+
this.processor = processor;
|
|
85
|
+
this.startPump();
|
|
86
|
+
return () => {
|
|
87
|
+
if (this.processor === processor) {
|
|
88
|
+
this.processor = undefined;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Retry the failed head message without allowing later messages to overtake it. */
|
|
94
|
+
retryFailed(): void {
|
|
95
|
+
if (!this.failed) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const message = this.messages[0];
|
|
99
|
+
if (message?.status === "failed") {
|
|
100
|
+
message.status = "queued";
|
|
101
|
+
delete message.error;
|
|
102
|
+
this.publish(message);
|
|
103
|
+
}
|
|
104
|
+
this.failed = false;
|
|
105
|
+
this.startPump();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
pendingMessages(): ReadonlyArray<Readonly<SteeringMessage>> {
|
|
109
|
+
return this.messages.map((message) => ({ ...message }));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
waitForIdle(): Promise<void> {
|
|
113
|
+
if (!this.processing) {
|
|
114
|
+
return Promise.resolve();
|
|
115
|
+
}
|
|
116
|
+
return new Promise((resolve) => this.idleWaiters.add(resolve));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
close(): void {
|
|
120
|
+
this.closed = true;
|
|
121
|
+
this.processor = undefined;
|
|
122
|
+
this.resolveIdleWaitersIfIdle();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private startPump(): void {
|
|
126
|
+
if (this.processing || this.failed || !this.processor || this.messages.length === 0) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
this.processing = true;
|
|
130
|
+
void this.pump();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async pump(): Promise<void> {
|
|
134
|
+
try {
|
|
135
|
+
while (!this.failed && this.processor && this.messages.length > 0) {
|
|
136
|
+
const processor = this.processor;
|
|
137
|
+
const message = this.messages[0];
|
|
138
|
+
message.status = "processing";
|
|
139
|
+
delete message.error;
|
|
140
|
+
this.publish(message);
|
|
141
|
+
try {
|
|
142
|
+
await processor({ ...message });
|
|
143
|
+
} catch (error) {
|
|
144
|
+
message.status = "failed";
|
|
145
|
+
message.error = errorMessage(error);
|
|
146
|
+
this.failed = true;
|
|
147
|
+
this.publish(message);
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
message.status = "accepted";
|
|
152
|
+
this.publish(message);
|
|
153
|
+
this.messages.shift();
|
|
154
|
+
}
|
|
155
|
+
} finally {
|
|
156
|
+
this.processing = false;
|
|
157
|
+
this.resolveIdleWaitersIfIdle();
|
|
158
|
+
// A message may have arrived between the final loop check and this
|
|
159
|
+
// assignment. Starting again here closes that race without parallelism.
|
|
160
|
+
this.startPump();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private publish(message: SteeringMessage): void {
|
|
165
|
+
try {
|
|
166
|
+
this.onChange?.({ ...message });
|
|
167
|
+
} catch {
|
|
168
|
+
// Observability must never affect delivery or ordering.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private resolveIdleWaitersIfIdle(): void {
|
|
173
|
+
if (this.processing) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
for (const resolve of this.idleWaiters) {
|
|
177
|
+
resolve();
|
|
178
|
+
}
|
|
179
|
+
this.idleWaiters.clear();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export type SteeringRouteResult =
|
|
184
|
+
| { routed: true; message: SteeringMessage }
|
|
185
|
+
| {
|
|
186
|
+
routed: false;
|
|
187
|
+
reason: "no_active_run" | "ambiguous_active_runs" | "not_steering" | "control_input" | "images" | "empty";
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** Routes input only when exactly one Pi Long Task execution owns steering. */
|
|
191
|
+
export class ActiveLongTaskSteeringRouter {
|
|
192
|
+
private readonly activeQueues = new Map<symbol, SerializedSteeringQueue>();
|
|
193
|
+
|
|
194
|
+
activate(queue: SerializedSteeringQueue): () => void {
|
|
195
|
+
const registration = Symbol("active-long-task-steering");
|
|
196
|
+
this.activeQueues.set(registration, queue);
|
|
197
|
+
return () => {
|
|
198
|
+
this.activeQueues.delete(registration);
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
route(input: SteeringInput): SteeringRouteResult {
|
|
203
|
+
if (input.source === "extension" || input.streamingBehavior !== "steer") {
|
|
204
|
+
return { routed: false, reason: "not_steering" };
|
|
205
|
+
}
|
|
206
|
+
if (input.images && input.images.length > 0) {
|
|
207
|
+
return { routed: false, reason: "images" };
|
|
208
|
+
}
|
|
209
|
+
const text = input.text.trim();
|
|
210
|
+
if (!text) {
|
|
211
|
+
return { routed: false, reason: "empty" };
|
|
212
|
+
}
|
|
213
|
+
if (isControlInput(text)) {
|
|
214
|
+
return { routed: false, reason: "control_input" };
|
|
215
|
+
}
|
|
216
|
+
if (this.activeQueues.size === 0) {
|
|
217
|
+
return { routed: false, reason: "no_active_run" };
|
|
218
|
+
}
|
|
219
|
+
if (this.activeQueues.size > 1) {
|
|
220
|
+
return { routed: false, reason: "ambiguous_active_runs" };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const queue = this.activeQueues.values().next().value as SerializedSteeringQueue;
|
|
224
|
+
return { routed: true, message: queue.enqueue(text, input.source) };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function isControlInput(text: string): boolean {
|
|
229
|
+
return text.startsWith("/") || text.startsWith("!");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function errorMessage(error: unknown): string {
|
|
233
|
+
return error instanceof Error ? error.message : String(error);
|
|
234
|
+
}
|
package/src/todo_parser.ts
CHANGED
|
@@ -6,6 +6,8 @@ export interface TaskStatusItem {
|
|
|
6
6
|
export interface Task {
|
|
7
7
|
taskId: string;
|
|
8
8
|
title: string;
|
|
9
|
+
/** Optional identity persisted in a task section as `<!-- pi-long-task-id: value -->`. */
|
|
10
|
+
stableId?: string;
|
|
9
11
|
section: string;
|
|
10
12
|
startLine: number;
|
|
11
13
|
endLine: number;
|
|
@@ -27,6 +29,7 @@ const CHECKBOX_RE = /^(\s*-\s+\[)([ xX])(\].*)$/;
|
|
|
27
29
|
const GLOBAL_PROGRESS_HEADING_RE = /^##\s+Progress\s*$/i;
|
|
28
30
|
const FIELD_HEADING_RE = /^\*\*[^*\r\n]+:\*\*\s*$/;
|
|
29
31
|
const FENCE_LINE_RE = /^\s*(`{3,}|~{3,})/;
|
|
32
|
+
const STABLE_ID_RE = /^\s*<!--\s*pi-long-task-id:\s*([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\s*-->\s*$/i;
|
|
30
33
|
|
|
31
34
|
function progressRegexForTask(taskId: string): RegExp {
|
|
32
35
|
return new RegExp(`^(\\s*-\\s+\\[)([ xX])(\\]\\s+TODO\\s+${escapeRegExp(taskId)}\\b.*)$`);
|
|
@@ -87,6 +90,30 @@ function parseTaskHeadings(lines: string[]): TaskHeading[] {
|
|
|
87
90
|
return headings;
|
|
88
91
|
}
|
|
89
92
|
|
|
93
|
+
function findStableId(lines: string[], startIdx: number, endIdx: number): string | undefined {
|
|
94
|
+
let fence: string | undefined;
|
|
95
|
+
for (let idx = startIdx; idx < endIdx; idx += 1) {
|
|
96
|
+
const stripped = stripLineBreaks(lines[idx]);
|
|
97
|
+
const fenceMatch = FENCE_LINE_RE.exec(stripped);
|
|
98
|
+
if (fenceMatch) {
|
|
99
|
+
const marker = fenceMatch[1];
|
|
100
|
+
if (!fence) {
|
|
101
|
+
fence = marker;
|
|
102
|
+
} else if (marker[0] === fence[0] && marker.length >= fence.length) {
|
|
103
|
+
fence = undefined;
|
|
104
|
+
}
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!fence) {
|
|
108
|
+
const match = STABLE_ID_RE.exec(stripped);
|
|
109
|
+
if (match) {
|
|
110
|
+
return match[1];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
90
117
|
function findProgressDone(lines: string[], taskId: string): boolean | undefined {
|
|
91
118
|
const regex = progressRegexForTask(taskId);
|
|
92
119
|
const progressStart = lines.findIndex((line) => GLOBAL_PROGRESS_HEADING_RE.test(stripLineBreaks(line).trim()));
|
|
@@ -146,9 +173,10 @@ function findStatusItems(lines: string[], startIdx: number, endIdx: number): Tas
|
|
|
146
173
|
return items;
|
|
147
174
|
}
|
|
148
175
|
|
|
149
|
-
function
|
|
176
|
+
function setStatusBlockDone(lines: string[], startIdx: number, endIdx: number, done: boolean): void {
|
|
150
177
|
let inStatus = false;
|
|
151
178
|
let seenCheckbox = false;
|
|
179
|
+
const marker = done ? "x" : " ";
|
|
152
180
|
|
|
153
181
|
for (let idx = startIdx; idx < endIdx; idx += 1) {
|
|
154
182
|
const stripped = lines[idx].trim();
|
|
@@ -166,8 +194,8 @@ function markStatusBlockDone(lines: string[], startIdx: number, endIdx: number):
|
|
|
166
194
|
const checkbox = CHECKBOX_RE.exec(raw);
|
|
167
195
|
if (checkbox) {
|
|
168
196
|
seenCheckbox = true;
|
|
169
|
-
if (checkbox[2].toLowerCase()
|
|
170
|
-
lines[idx] = `${checkbox[1]}
|
|
197
|
+
if ((checkbox[2].toLowerCase() === "x") !== done) {
|
|
198
|
+
lines[idx] = `${checkbox[1]}${marker}${checkbox[3]}${newline}`;
|
|
171
199
|
}
|
|
172
200
|
continue;
|
|
173
201
|
}
|
|
@@ -201,6 +229,7 @@ export function parseTasks(markdown: string): Task[] {
|
|
|
201
229
|
const task: Task = {
|
|
202
230
|
taskId: heading.taskId,
|
|
203
231
|
title: heading.title,
|
|
232
|
+
stableId: findStableId(lines, heading.startIdx, endIdx),
|
|
204
233
|
section,
|
|
205
234
|
startLine: heading.startIdx + 1,
|
|
206
235
|
endLine: endIdx,
|
|
@@ -219,16 +248,17 @@ export function incompleteTasks(markdown: string): Task[] {
|
|
|
219
248
|
return parseTasks(markdown).filter((task) => !task.done);
|
|
220
249
|
}
|
|
221
250
|
|
|
222
|
-
|
|
251
|
+
function setTaskDone(markdown: string, taskId: string, done: boolean): string {
|
|
223
252
|
const lines = splitLinesKeepEnds(markdown);
|
|
224
253
|
const progressRegex = progressRegexForTask(taskId);
|
|
254
|
+
const marker = done ? "x" : " ";
|
|
225
255
|
|
|
226
256
|
lines.forEach((line, idx) => {
|
|
227
257
|
const raw = stripLineBreaks(line);
|
|
228
258
|
const newline = line.endsWith("\n") ? "\n" : "";
|
|
229
259
|
const match = progressRegex.exec(raw);
|
|
230
|
-
if (match && match[2].toLowerCase()
|
|
231
|
-
lines[idx] = `${match[1]}
|
|
260
|
+
if (match && (match[2].toLowerCase() === "x") !== done) {
|
|
261
|
+
lines[idx] = `${match[1]}${marker}${match[3]}${newline}`;
|
|
232
262
|
}
|
|
233
263
|
});
|
|
234
264
|
|
|
@@ -236,12 +266,21 @@ export function markTaskDone(markdown: string, taskId: string): string {
|
|
|
236
266
|
const headingPos = headings.findIndex((heading) => heading.taskId === taskId);
|
|
237
267
|
if (headingPos >= 0) {
|
|
238
268
|
const endIdx = headingPos + 1 < headings.length ? headings[headingPos + 1].startIdx : lines.length;
|
|
239
|
-
|
|
269
|
+
setStatusBlockDone(lines, headings[headingPos].startIdx, endIdx, done);
|
|
240
270
|
}
|
|
241
271
|
|
|
242
272
|
return lines.join("");
|
|
243
273
|
}
|
|
244
274
|
|
|
275
|
+
export function markTaskDone(markdown: string, taskId: string): string {
|
|
276
|
+
return setTaskDone(markdown, taskId, true);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Clears planner-supplied completion for work whose coordinator-owned state is not complete. */
|
|
280
|
+
export function markTaskPending(markdown: string, taskId: string): string {
|
|
281
|
+
return setTaskDone(markdown, taskId, false);
|
|
282
|
+
}
|
|
283
|
+
|
|
245
284
|
export function todoGlobalInstructions(markdown: string, limit = 6000): string {
|
|
246
285
|
const selected: string[] = [];
|
|
247
286
|
|