@dumbmice/pi-todo-loop 0.1.1 → 0.2.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/src/index.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * @dumbmice/pi-todo-loop — continuation driven by an rpiv-todo list.
3
3
  *
4
- * Pairs with `@juicesharp/rpiv-todo`; does not import or patch it. Three hooks:
4
+ * Pairs with `@juicesharp/rpiv-todo`; does not import or patch it. Hooks and tool:
5
5
  *
6
- * context append the live open-task list at the tail (never persisted),
7
- * replacing a trailing marker so the request never ends user → user
8
- * turn_end request another turn while tasks are open, bounded by
9
- * maxRounds / maxStrikes (see loop.ts)
6
+ * context drop a marker that would sit next to a human message, and
7
+ * nothing else: the prompt is never rewritten with task state
8
+ * turn_end request another turn while tasks are open, bounded by maxRounds
9
+ * (see loop.ts), and stop when the agent calls todo_loop_block
10
+ * todo_loop_block the agent's "I need a person" signal, scoped to one message
10
11
  * /todoloop status | pause | resume | reset
11
12
  *
12
13
  * Architecture notes worth keeping in mind when changing this file:
@@ -14,9 +15,8 @@
14
15
  * - A `turn_end` continuation must leave the final message role != assistant,
15
16
  * so one entry is mandatory (`_buildBoundaryContext().canContinue` in pi
16
17
  * 1.5.0). We append a `custom_message` with `display: false`.
17
- * - Markers are never removed from history: they sit between assistant turns and
18
- * are what keep the conversation alternating. A marker at the tail is replaced
19
- * by the state block, so the request ends on exactly one user message.
18
+ * - Markers are never added to a request by this extension, and are only removed
19
+ * when one would sit next to a human message. A marker at the tail is the nudge.
20
20
  * - pi's non-assistant requirement binds the *persisted projection* checked at
21
21
  * `turn_end`, not the provider request (register item 3).
22
22
  * - The marker customType is deliberately stable: sessions created by earlier
@@ -27,7 +27,6 @@ import { existsSync } from "node:fs";
27
27
  import { homedir } from "node:os";
28
28
  import { join } from "node:path";
29
29
  import { loadConfig } from "./config.ts";
30
- import { renderInjection } from "./inject.ts";
31
30
  import { decide, initialState, type LoopState } from "./loop.ts";
32
31
  import { readSnapshot } from "./state.ts";
33
32
 
@@ -51,13 +50,22 @@ interface SessionContext {
51
50
  export const MARKER_TYPE = "todo-loop:continue";
52
51
 
53
52
  /**
54
- * Constant body. The marker keeps the persisted history alternating and gives
55
- * `turn_end` a non-assistant tail; it carries no counters, so a stale marker in
56
- * history cannot claim the loop is on some other round. It states the instruction
57
- * because with `injectState: false` this line is the only text the model receives.
53
+ * The only text this extension ever gives the model. It keeps the persisted
54
+ * history alternating, gives `turn_end` a non-assistant tail, and points at the
55
+ * todo tool rather than restating state: the list is the agent's to read.
56
+ * It carries no counters, so a stale marker in history cannot claim the loop is
57
+ * on some other round.
58
58
  */
59
59
  export const MARKER_BODY =
60
- "Continue with the open todo tasks. If the human's latest instruction says to stop or limits what to do, do that instead.";
60
+ "Continue with the open todo tasks; use the todo tool to read and update the list. If the human's latest instruction says to stop or limits what to do, do that instead.";
61
+
62
+ /**
63
+ * The same nudge, offered once per stall: this is the first stop since real progress
64
+ * that did not move the list, which is what a blocked agent looks like. The concession
65
+ * stays last — the block still ends by telling the model the human outranks it.
66
+ */
67
+ export const MARKER_BODY_HINT =
68
+ "Continue with the open todo tasks; use the todo tool to read and update the list. If you cannot proceed without a person, call todo_loop_block with the reason. If the human's latest instruction says to stop or limits what to do, do that instead.";
61
69
 
62
70
  export const CONFIG_PATH = join(homedir(), ".pi/agent/todo-loop.json");
63
71
  const KILL_SWITCH_GLOBAL = join(homedir(), ".pi/agent/todo-loop-off");
@@ -97,13 +105,82 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
97
105
  }
98
106
  };
99
107
 
100
- const state: LoopState & { paused: boolean; reason: string; lastOpenCount: number } = {
108
+ const state: LoopState & {
109
+ paused: boolean;
110
+ reason: string;
111
+ lastOpenCount: number;
112
+ /** Set by `todo_loop_block`: this message waits for a human. */
113
+ waiting: { reason: string; anchor: string } | null;
114
+ } = {
101
115
  ...initialState(),
102
116
  paused: false,
103
117
  reason: "idle",
104
118
  lastOpenCount: 0,
119
+ waiting: null,
105
120
  };
106
121
 
122
+ // The agent's own exit. A blocked agent looks exactly like a lazy one from the
123
+ // loop's side, so the loop cannot detect it — but the agent can say so. One
124
+ // sentence of state, scoped to this message: the human's next message resumes.
125
+ pi.registerTool({
126
+ name: "todo_loop_block",
127
+ label: "Block the todo loop",
128
+ description:
129
+ "Stop the automatic continuation loop for the rest of this message because you need a person: a decision, a review, a credential or information only the human has. The human is shown your reason and their next message resumes the loop. Use it only when you cannot proceed on the open tasks without them.",
130
+ parameters: {
131
+ type: "object",
132
+ properties: {
133
+ reason: { type: "string", description: "What you need from the human, in one sentence." },
134
+ },
135
+ required: ["reason"],
136
+ additionalProperties: false,
137
+ },
138
+ async execute(_id: any, params: any, _signal: any, _onUpdate: any, ctx: SessionContext) {
139
+ // The hooks respect the kill switch and pause; a tool must not be the one
140
+ // surface that keeps talking after the extension was switched off.
141
+ if (isDisabled(ctx) || state.paused) {
142
+ return {
143
+ content: [
144
+ { type: "text", text: "The todo loop is not running in this session, so there is nothing to block." },
145
+ ],
146
+ details: undefined,
147
+ };
148
+ }
149
+ // Model-authored and rendered in the TUI: one line, bounded, no control characters.
150
+ // `\p{Cc}` covers C0 and C1 (U+0085 NEL and U+009B CSI included); the slice is by
151
+ // code point, so a cut at the limit cannot split a surrogate pair.
152
+ const cleaned = String(params?.reason ?? "")
153
+ .replace(/\p{Cc}+/gu, " ")
154
+ .replace(/\s+/g, " ")
155
+ .trim();
156
+ const reason = [...cleaned].slice(0, 200).join("");
157
+ if (reason === "") {
158
+ return {
159
+ content: [
160
+ {
161
+ type: "text",
162
+ text: "todo_loop_block needs a reason: one sentence saying what you need from the human.",
163
+ },
164
+ ],
165
+ details: undefined,
166
+ };
167
+ }
168
+ const snapshot = readSnapshot(branchEntries(ctx));
169
+ state.waiting = { reason, anchor: snapshot.anchor };
170
+ state.reason = `waiting for you: ${reason}`;
171
+ ctx.ui?.notify?.(`todo-loop: waiting for you — ${reason}`, "info");
172
+ return {
173
+ content: [
174
+ {
175
+ type: "text",
176
+ text: `The loop is stopped for this message; the human sees: ${reason}. Do not start other work — wait for their next message.`,
177
+ },
178
+ ],
179
+ details: undefined,
180
+ };
181
+ },
182
+ });
183
+
107
184
  pi.on("context", async (event: any, ctx: SessionContext) => {
108
185
  if (isDisabled(ctx) || state.paused) return undefined;
109
186
  // A hook that rewrites an unknown payload into a one-message request is
@@ -111,73 +188,43 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
111
188
  if (!Array.isArray(event?.messages)) return undefined;
112
189
 
113
190
  const original = event.messages as unknown[];
114
- const config = loadConfig(configPath);
115
- // No injection configured: nothing below needs the branch at all.
116
- if (!config.injectState) return undefined;
117
-
118
- const snapshot = readSnapshot(branchEntries(ctx));
119
- const injecting = snapshot.open.length > 0;
120
- if (!injecting) return undefined;
121
-
122
- // The marker is persisted between assistant turns, so it is what keeps the
123
- // history alternating: assistant → marker(user) → assistant. Stripping the
124
- // historical ones would expose runs of consecutive assistant messages, which
125
- // alternation-requiring providers reject. So markers stay in history; only a
126
- // marker sitting at the tail (the continuation request about to be made) is
127
- // replaced by the state block, so the request never ends on user → user.
128
- const text = { type: "text", text: renderInjection(snapshot, config, { reconcile: state.rounds >= 1 }) };
129
191
  const record = (message: unknown): Record<string, unknown> | null =>
130
192
  typeof message === "object" && message !== null ? (message as Record<string, unknown>) : null;
131
193
  const isMarker = (message: unknown): boolean => record(message)?.customType === MARKER_TYPE;
132
- // pi projects every role except these to a user-role message in the request:
133
- // `custom`, `bashExecution`, `branchSummary`, `compactionSummary`, and plain
134
- // `user`. Anything else is user-role for the purpose of alternation, so the test
135
- // is a whitelist of non-user roles — the direction that merges rather than
136
- // appending a second user message.
194
+ // pi projects every role except these to a user-role message in the request.
137
195
  const NON_USER_ROLES = new Set(["assistant", "toolResult", "system", "tool"]);
138
196
  const isUserRole = (message: unknown): boolean => {
139
197
  const role = record(message)?.role;
140
198
  return typeof role === "string" && !NON_USER_ROLES.has(role);
141
199
  };
142
200
 
143
- // A marker only earns its place *between* two assistant turns, where it is the
144
- // alternation glue. Anywhere else it is redundant bookkeeping that would sit
145
- // next to a real user message and break alternation: drop it. That covers a
146
- // marker at the tail, a marker followed by a human message, and two markers in
147
- // a row.
148
- const kept = (original as unknown[]).filter((message, index, list) => {
201
+ // Nothing is ever *added* to a request: the prompt the human sends, and every
202
+ // tool turn, go out exactly as built. The one thing this hook may do is remove
203
+ // the marker this extension itself appended, when it would sit next to a human
204
+ // message and project to `user → user` — an adjacency pi's providers reject, and
205
+ // one the extension created. Between two assistant turns the marker stays: it is
206
+ // the glue that keeps that pair alternating.
207
+ const neighbour = (list: unknown[], from: number, step: number): unknown => {
208
+ for (let index = from; index >= 0 && index < list.length; index += step) {
209
+ if (!isMarker(list[index])) return list[index];
210
+ }
211
+ return undefined;
212
+ };
213
+ const kept = original.filter((message, index, list) => {
149
214
  if (!isMarker(message)) return true;
150
- const previous = list[index - 1];
151
- const next = list[index + 1];
152
- // Both neighbours must exist and neither may be user-role: `[A, M, H]` and
153
- // `[A, H, M, A]` both leave a marker next to a human message, which is the
154
- // adjacency this filter exists to prevent.
155
- return previous !== undefined && next !== undefined && !isUserRole(previous) && !isUserRole(next);
215
+ // Keep only the first marker of a run: that is what preserves
216
+ // `assistant → marker → assistant`, while testing the literal neighbours drops
217
+ // the whole run and leaves two assistant turns adjacent.
218
+ if (isMarker(list[index - 1])) return false;
219
+ const previous = neighbour(list, index - 1, -1);
220
+ const next = neighbour(list, index + 1, 1);
221
+ // Drop it only when it would project to `user → user`: next to a human message,
222
+ // on either side. A marker at the tail is the nudge itself — dropping it would
223
+ // leave the continuation request ending on an assistant turn.
224
+ return !isUserRole(previous) && !isUserRole(next);
156
225
  });
157
226
 
158
- // Nothing left to attach to: an empty request would be an instruction with no prompt.
159
- if (kept.length === 0) return undefined;
160
-
161
- const tail = kept[kept.length - 1];
162
- const tailRole = record(tail)?.role;
163
- // pi rebuilds the content of `bashExecution`, `branchSummary` and
164
- // `compactionSummary` from their own fields and ignores `content`, so merging
165
- // into one of those would silently drop the block. Only `user` and `custom`
166
- // honour `content`; the others get a separate message.
167
- const mergeable = tailRole === "user" || tailRole === "custom";
168
- if (tail !== null && isUserRole(tail) && mergeable) {
169
- // Merge into the human's own message rather than adding a second one after it.
170
- const entry = tail as Record<string, unknown>;
171
- const existing = Array.isArray(entry.content)
172
- ? (entry.content as unknown[])
173
- : typeof entry.content === "string" && entry.content !== ""
174
- ? [{ type: "text", text: entry.content }]
175
- : [];
176
- const content = [...existing, text];
177
- return { messages: [...kept.slice(0, -1), { ...entry, content }] };
178
- }
179
-
180
- return { messages: [...kept, { role: "user", content: [text], timestamp: Date.now() }] };
227
+ return kept.length === original.length ? undefined : { messages: kept };
181
228
  });
182
229
 
183
230
  pi.on("turn_end", async (event: any, ctx: SessionContext) => {
@@ -192,10 +239,9 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
192
239
  // would be redundant — and counting it as a round spends the budget on work the
193
240
  // loop did not cause. Only a settle (no tool results) can spend a round.
194
241
  //
195
- // A tool turn is still *work*, so it clears a pending strike: without this, a
196
- // model that runs tools and never updates the list (the case this extension is
197
- // for) collects two strikes across its settles and stops with "no progress"
198
- // while it was working the whole time.
242
+ // A tool turn is *work*, so it clears the stall counter and re-arms the block
243
+ // hint: an agent that keeps working is not stuck, and a later stall is a new
244
+ // question rather than a repeat of the old one.
199
245
  // A list that goes open -> clear in a turn whose only tool calls were `todo` is
200
246
  // the loop's success state and its failure state at once. Never police it;
201
247
  // just do not launder the trust: say so once.
@@ -210,13 +256,30 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
210
256
  }
211
257
  }
212
258
  state.lastOpenCount = snapshot.open.length;
259
+ // The human answered: the block belonged to the previous message.
260
+ if (state.waiting !== null && state.waiting.anchor !== snapshot.anchor) {
261
+ state.waiting = null;
262
+ state.reason = "resumed by user";
263
+ }
213
264
 
214
265
  if (toolResultCount > 0) {
215
266
  if (String(event?.outcome) !== "completed") {
216
267
  state.reason = `stopped: turn outcome was ${String(event?.outcome)}`;
217
268
  return undefined;
218
269
  }
219
- state.strikes = 0;
270
+ state.stalled = 0;
271
+ state.hintSent = false;
272
+ return undefined;
273
+ }
274
+
275
+ // The agent asked for a person. Nothing continues until the human answers; their
276
+ // next message is a new anchor, so the block clears itself.
277
+ if (state.waiting !== null && state.waiting.anchor === snapshot.anchor) {
278
+ const waiting = `waiting for you: ${state.waiting.reason}`;
279
+ if (state.reason !== waiting) {
280
+ state.reason = waiting;
281
+ ctx.ui?.notify?.(`todo-loop: ${waiting}`, "info");
282
+ }
220
283
  return undefined;
221
284
  }
222
285
 
@@ -251,7 +314,7 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
251
314
  {
252
315
  type: "custom_message",
253
316
  customType: MARKER_TYPE,
254
- content: MARKER_BODY,
317
+ content: decision.hint ? MARKER_BODY_HINT : MARKER_BODY,
255
318
  display: false,
256
319
  },
257
320
  ],
@@ -281,7 +344,11 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
281
344
  return;
282
345
  }
283
346
  if (command === "reset") {
284
- Object.assign(state, initialState(), { paused: state.paused, reason: "counters reset" });
347
+ Object.assign(state, initialState(), {
348
+ paused: state.paused,
349
+ reason: "counters reset",
350
+ waiting: null,
351
+ });
285
352
  ctx.ui?.notify?.("todo-loop: counters reset", "info");
286
353
  return;
287
354
  }
@@ -299,20 +366,26 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
299
366
  }
300
367
 
301
368
  const snapshot = readSnapshot(branchEntries(ctx));
369
+ const waitingNow = state.waiting !== null && state.waiting.anchor === snapshot.anchor;
302
370
  const mode = isDisabled(ctx)
303
371
  ? "DISABLED (kill-switch present)"
304
372
  : state.paused
305
373
  ? "PAUSED"
306
- : state.reason.startsWith("stopped:")
307
- ? "stopped (next human message starts a new budget)"
308
- : "active";
374
+ : waitingNow
375
+ ? "waiting (the agent asked for you; your next message resumes)"
376
+ : state.reason.startsWith("stopped:")
377
+ ? "stopped (next human message starts a new budget)"
378
+ : "active";
309
379
  ctx.ui?.notify?.(
310
380
  [
311
381
  `todo-loop ${mode}`,
312
- `rounds ${state.rounds}/${config.maxRounds} · strikes ${state.strikes}/${config.maxStrikes}`,
313
- `open ${snapshot.open.length}/${snapshot.tasks.length} tracked · inject ${config.injectState ? "on" : "off"}`,
382
+ `rounds ${state.rounds}/${config.maxRounds} · stalled ${state.stalled}`,
383
+ // Only while it still belongs to this message: the human's next one
384
+ // resumes the loop even though the reason is still in memory.
385
+ ...(waitingNow ? [`waiting: ${(state.waiting as { reason: string }).reason}`] : []),
386
+ `open ${snapshot.open.length}/${snapshot.tasks.length} tracked`,
314
387
  `anchor ${snapshot.anchor || "(none)"}`,
315
- `last: ${state.reason}`,
388
+ `last: ${waitingNow ? `waiting for you: ${(state.waiting as { reason: string }).reason}` : state.reason}`,
316
389
  `config ${configPath}`,
317
390
  ].join("\n"),
318
391
  "info",
package/src/loop.ts CHANGED
@@ -9,11 +9,16 @@
9
9
  export interface LoopState {
10
10
  /** Continuations started since the last human message. */
11
11
  rounds: number;
12
- /** Consecutive turns that neither ran a tool nor changed the task list. */
13
- strikes: number;
12
+ /**
13
+ * Consecutive settles that neither ran a tool nor changed the task list. No cap:
14
+ * it decides when to offer the block tool, not when to give up.
15
+ */
16
+ stalled: number;
17
+ /** Whether the "call todo_loop_block" clause has already been offered this stall. */
18
+ hintSent: boolean;
14
19
  /** Task signature at the previous decision, to detect list changes. */
15
20
  lastSignature: string;
16
- /** Settles since the list last changed: a stale list stops on the cap, not on strikes. */
21
+ /** Settles since the list last changed: a stale list stops on the cap. */
17
22
  staleSettles: number;
18
23
  /** Id of the user message the current budget belongs to. */
19
24
  anchor: string;
@@ -22,19 +27,10 @@ export interface LoopState {
22
27
  export interface LoopConfig {
23
28
  /** Max consecutive auto-continuations per human message. */
24
29
  maxRounds: number;
25
- /** Consecutive no-progress turns tolerated before stopping. */
26
- maxStrikes: number;
27
- /** Inject the live task list at the tail of every request. */
28
- injectState: boolean;
29
- /** Max open tasks rendered in that injection. */
30
- maxInjectedTasks: number;
31
30
  }
32
31
 
33
32
  export const DEFAULTS: LoopConfig = {
34
33
  maxRounds: 12,
35
- maxStrikes: 2,
36
- injectState: true,
37
- maxInjectedTasks: 6,
38
34
  };
39
35
 
40
36
  export type DecisionInput = {
@@ -60,17 +56,24 @@ export interface Decision {
60
56
  /** True when the caller should persist `rounds` as they are (no reset). */
61
57
  next: LoopState;
62
58
  reason: string;
59
+ /**
60
+ * True when this continuation should carry the "if you cannot proceed without a
61
+ * person, call todo_loop_block" clause. Offered once per stall: this is the first
62
+ * stop since real progress that did not move the list — the shape a blocked agent
63
+ * has.
64
+ */
65
+ hint: boolean;
63
66
  }
64
67
 
65
68
  /**
66
69
  * A stop keeps the counters. Only a new human message starts a new budget: a cap
67
- * or strike stop that cleared them would re-arm the full budget on the next
70
+ * or cap stop that cleared them would re-arm the full budget on the next
68
71
  * `turn_end` under the same anchor (another extension's continuation, a provider
69
72
  * retry). A queued human message is a new anchor by definition, so it resets the
70
73
  * budget the ordinary way.
71
74
  */
72
75
  function stop(state: LoopState, reason: string): Decision {
73
- return { continue: false, next: { ...state }, reason };
76
+ return { continue: false, next: { ...state }, hint: false, reason };
74
77
  }
75
78
 
76
79
  /**
@@ -85,7 +88,7 @@ export function decide(input: DecisionInput): Decision {
85
88
  // A new human message owns a fresh budget.
86
89
  let current: LoopState =
87
90
  input.anchor !== state.anchor
88
- ? { ...state, rounds: 0, strikes: 0, lastSignature: "", staleSettles: 0, anchor: input.anchor }
91
+ ? { ...state, rounds: 0, stalled: 0, hintSent: false, lastSignature: "", staleSettles: 0, anchor: input.anchor }
89
92
  : { ...state };
90
93
 
91
94
  if (input.outcome !== "completed") {
@@ -97,35 +100,42 @@ export function decide(input: DecisionInput): Decision {
97
100
 
98
101
  const progressed = input.toolResultCount > 0 || input.signature !== current.lastSignature;
99
102
  if (progressed) {
100
- current.strikes = 0;
103
+ // Work re-arms the hint: a second stall, after real progress, is a new question.
104
+ current.stalled = 0;
105
+ current.hintSent = false;
101
106
  current.staleSettles = 0;
102
107
  } else {
103
108
  current.staleSettles += 1;
104
- // A budget must never read above the cap it is measured against (`strikes 3/2`).
105
- current.strikes = Math.min(current.strikes + 1, config.maxStrikes);
106
- if (current.strikes >= config.maxStrikes) {
107
- return stop(
108
- current,
109
- `stopped: ${current.strikes} consecutive turns with no progress, ${input.openCount} open`,
110
- );
111
- }
109
+ current.stalled += 1;
112
110
  }
113
111
 
112
+ // A repeat stop is the shape a blocked agent has: it already answered once, it did
113
+ // not move the list, and it stopped again. Offer the block tool there — once per
114
+ // stall — and never stop the loop for it. The only stops are a clear list, the
115
+ // round cap, an error, and the agent asking for a person.
116
+ const hint = current.stalled >= 1 && !current.hintSent;
117
+
114
118
  if (current.rounds >= config.maxRounds) {
115
119
  const stale =
116
120
  current.staleSettles >= 2 ? `, list unchanged for ${current.staleSettles} settles` : "";
117
- return stop(current, `stopped: round cap ${config.maxRounds} reached, ${input.openCount} open${stale}`);
121
+ // A hint that comes due on the cap round has no continuation left to ride in.
122
+ // Do not drop it silently: the human reads the reason, and the agent may be
123
+ // blocked rather than done.
124
+ const stalled = hint ? ", the agent stalled and may need a person" : "";
125
+ return stop(current, `stopped: round cap ${config.maxRounds} reached, ${input.openCount} open${stale}${stalled}`);
118
126
  }
119
127
 
120
128
  current.rounds += 1;
121
129
  current.lastSignature = input.signature;
130
+ if (hint) current.hintSent = true;
122
131
  return {
123
132
  continue: true,
124
133
  next: current,
134
+ hint,
125
135
  reason: `continuing: round ${current.rounds}/${config.maxRounds}, ${input.openCount} open`,
126
136
  };
127
137
  }
128
138
 
129
139
  export function initialState(): LoopState {
130
- return { rounds: 0, strikes: 0, lastSignature: "", staleSettles: 0, anchor: "" };
140
+ return { rounds: 0, stalled: 0, hintSent: false, lastSignature: "", staleSettles: 0, anchor: "" };
131
141
  }
package/src/inject.ts DELETED
@@ -1,97 +0,0 @@
1
- /**
2
- * The state injection.
3
- *
4
- * Appended at the tail of every request by the `context` hook and never
5
- * persisted. Two properties matter:
6
- *
7
- * - It is the *whole* open list (bounded), not a hint to call `list`. After
8
- * compaction the model has no other way to learn the list still exists.
9
- * - It is a user-role block appended at the tail, replacing a trailing
10
- * continuation marker so the request never ends on two user-role messages.
11
- */
12
-
13
- import type { LoopConfig } from "./loop.ts";
14
- import type { Snapshot } from "./state.ts";
15
-
16
- export const INJECT_TAG = "todo_loop_state";
17
-
18
- function label(status: string, subject: string, activeForm?: string): string {
19
- if (status === "in_progress" && activeForm) return activeForm;
20
- return subject || "(untitled)";
21
- }
22
-
23
- /**
24
- * One line of model-authored text, rendered inside a tag at the end of a request.
25
- *
26
- * A task subject is written by the model, possibly from a fetched page or a repo
27
- * file, so it must not be able to: close the tag (`</`), start a new line that reads
28
- * like one of our own directives, or push the block to an unbounded size.
29
- */
30
- export function sanitizeLabel(text: string): string {
31
- // Every Unicode line terminator, not just \n: U+2028/9 and NEL (U+0085) split a
32
- // line for some readers, which is the channel this function exists to close.
33
- const flattened = text.replace(/[\r\n\t\u2028\u2029\u0085]+/g, " ");
34
- // C0/C1 controls, bidi overrides and isolates, zero-width and BOM characters: a
35
- // NUL can break JSON encoding and a bidi override can visually reverse a line.
36
- const cleaned = flattened.replace(
37
- /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g,
38
- "",
39
- );
40
- // No tag can be opened or closed from task text: `<` is escaped, which also covers
41
- // `</` and a bare `<todo_loop_state>` that would invert the block's nesting.
42
- const escaped = cleaned.replaceAll("<", "&lt;").replaceAll("&lt;", "&lt;");
43
- // Bound the working set before materialising code points (a 1 MB subject would
44
- // otherwise allocate a 1M-element array per row); 480 UTF-16 units can hold far
45
- // more than the 120 code points that survive.
46
- const bounded = escaped.length > 480 ? escaped.slice(0, 480) : escaped;
47
- // By code point, never UTF-16 units: `slice` can cut a surrogate pair in half and
48
- // leave a lone surrogate in the request.
49
- const points = Array.from(bounded).slice(0, 120).join("");
50
- // An unpaired surrogate already in the payload survives `Array.from`; drop it so the
51
- // request is always valid UTF-16.
52
- return points.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "").trim();
53
- }
54
-
55
- export interface InjectionOptions {
56
- /**
57
- * Add the reconciliation clause. Off for the first nudge of a chain, on afterwards:
58
- * handing a model an explicit "fix the list" instruction on its first sighting
59
- * invites it to reshape the list instead of doing the work, while after one nudge a
60
- * drifted list is the likelier explanation.
61
- */
62
- reconcile?: boolean;
63
- }
64
-
65
- const RECONCILE_CLAUSE = [
66
- "If this list has drifted from the work, reconcile it now: update statuses,",
67
- "add what is missing, and remove what no longer applies.",
68
- ].join("\n");
69
-
70
- export function renderInjection(
71
- snapshot: Snapshot,
72
- config: LoopConfig,
73
- options: InjectionOptions = {},
74
- ): string {
75
- const shown = snapshot.open.slice(0, config.maxInjectedTasks);
76
- const rows = shown.map(
77
- (task) =>
78
- ` ${task.id}. [${sanitizeLabel(task.status)}] ${sanitizeLabel(
79
- label(task.status, task.subject, task.activeForm),
80
- )}`,
81
- );
82
- const hidden = snapshot.open.length - shown.length;
83
-
84
- return [
85
- `<${INJECT_TAG}>`,
86
- `${snapshot.open.length} tracked task(s) are still open on this branch:`,
87
- ...rows,
88
- ...(hidden > 0 ? [` ... and ${hidden} more`] : []),
89
- "The human's latest message outranks everything above: if it says to stop, to wait,",
90
- "or limits what to do, do that instead and say so in one line. Otherwise carry on",
91
- "with the open tasks, and move a task to in_progress when you start it and to",
92
- "completed only once it is actually verified — if a task cannot be verified now,",
93
- "leave it open and say what is blocking it.",
94
- ...(options.reconcile ? [RECONCILE_CLAUSE] : []),
95
- `</${INJECT_TAG}>`,
96
- ].join("\n");
97
- }