@dumbmice/pi-todo-loop 0.0.0-stage → 0.1.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/AGENTS.md +366 -0
- package/CHANGELOG.md +85 -0
- package/CONTRIBUTING.md +4 -0
- package/LICENSE +21 -0
- package/README.md +182 -2
- package/docs/behaviour.md +168 -0
- package/docs/pi-api-alignment.md +134 -0
- package/package.json +61 -5
- package/scripts/check-pack.mjs +62 -0
- package/scripts/pack-json.mjs +33 -0
- package/src/config.ts +48 -0
- package/src/index.ts +323 -0
- package/src/inject.ts +97 -0
- package/src/loop.ts +131 -0
- package/src/state.ts +216 -0
- package/tsconfig.json +17 -0
package/src/state.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rpiv-todo state, read-only.
|
|
3
|
+
*
|
|
4
|
+
* The extension never writes todo state and never imports rpiv-todo. It reads
|
|
5
|
+
* the two shapes a todo tool persists on the session branch:
|
|
6
|
+
*
|
|
7
|
+
* 1. `custom` entries with `customType: "rpiv-todo-state"` — the authoritative
|
|
8
|
+
* snapshot written by every mutation (state/replay.ts upstream).
|
|
9
|
+
* 2. a `todo` toolResult carrying `details.tasks` — the fallback, used when the
|
|
10
|
+
* custom entry shape is absent or unrecognised.
|
|
11
|
+
*
|
|
12
|
+
* Both are extension-authored session data, which pi does not send to the LLM
|
|
13
|
+
* (that is exactly the gap this extension closes).
|
|
14
|
+
*
|
|
15
|
+
* Everything here fails soft: a malformed branch degrades to "no list" rather
|
|
16
|
+
* than throwing inside a turn boundary.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface Task {
|
|
20
|
+
id: number;
|
|
21
|
+
subject: string;
|
|
22
|
+
status: string;
|
|
23
|
+
activeForm?: string;
|
|
24
|
+
/** Internal: the id was derived from the array position, not supplied by the payload. */
|
|
25
|
+
syntheticId?: true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Snapshot {
|
|
29
|
+
/** Non-deleted tasks, in list order. */
|
|
30
|
+
tasks: Task[];
|
|
31
|
+
/** Tasks that are not `completed` or `deleted`. */
|
|
32
|
+
open: Task[];
|
|
33
|
+
/** `id:status` joined — cheap change detector. */
|
|
34
|
+
signature: string;
|
|
35
|
+
/** Id of the newest human message on the branch; never empty once seen. */
|
|
36
|
+
anchor: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const EMPTY_SNAPSHOT: Snapshot = { tasks: [], open: [], signature: "", anchor: "" };
|
|
40
|
+
|
|
41
|
+
const TODO_CUSTOM_TYPE = "rpiv-todo-state";
|
|
42
|
+
const TODO_TOOL_NAME = "todo";
|
|
43
|
+
const CLOSED_STATUSES = new Set(["completed", "deleted"]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Normalise one task. A missing or unusable id falls back to the array index so
|
|
47
|
+
* a single bad field cannot silently shrink the list toward "clear".
|
|
48
|
+
*/
|
|
49
|
+
export function normalizeTask(value: unknown, index = 0): Task | undefined {
|
|
50
|
+
// An array is a wrapper the reader does not understand, not a task: keeping it
|
|
51
|
+
// would produce a phantom "open" task that never lets the loop report the list
|
|
52
|
+
// clear.
|
|
53
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
54
|
+
const record = value as Record<string, unknown>;
|
|
55
|
+
|
|
56
|
+
// Only a string can need conversion — `Number(...)` on anything else is the only
|
|
57
|
+
// place this file could throw.
|
|
58
|
+
const numeric = typeof record.id === "number" ? record.id : typeof record.id === "string" ? Number(record.id) : Number.NaN;
|
|
59
|
+
const usableId = typeof record.id === "number" || (typeof record.id === "string" && record.id.trim() !== "");
|
|
60
|
+
const id = usableId && Number.isInteger(numeric) && numeric >= 0 ? numeric : index;
|
|
61
|
+
|
|
62
|
+
const subject =
|
|
63
|
+
typeof record.subject === "string"
|
|
64
|
+
? record.subject
|
|
65
|
+
: typeof record.content === "string"
|
|
66
|
+
? record.content
|
|
67
|
+
: "";
|
|
68
|
+
|
|
69
|
+
// Fail-open (see isOpen) makes status matching load-bearing, so normalise it:
|
|
70
|
+
// "Completed " from another tool must not read as open forever.
|
|
71
|
+
const status = typeof record.status === "string" ? record.status.trim().toLowerCase() : "pending";
|
|
72
|
+
|
|
73
|
+
// A record with neither a label nor a status is not a task; keeping it would make
|
|
74
|
+
// the list permanently "open" on a phantom entry.
|
|
75
|
+
if (subject === "" && typeof record.status !== "string") return undefined;
|
|
76
|
+
|
|
77
|
+
const task: Task = {
|
|
78
|
+
id,
|
|
79
|
+
subject,
|
|
80
|
+
status,
|
|
81
|
+
...(typeof record.activeForm === "string" ? { activeForm: record.activeForm } : {}),
|
|
82
|
+
};
|
|
83
|
+
if (!(usableId && Number.isInteger(numeric) && numeric >= 0)) task.syntheticId = true;
|
|
84
|
+
return task;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Open means "not finished". An allow-list would fail closed: a status the
|
|
89
|
+
* todo tool adds or renames later (`blocked`, a localised value) would vanish
|
|
90
|
+
* from the injected block and the loop would report the list clear with work
|
|
91
|
+
* still outstanding.
|
|
92
|
+
*/
|
|
93
|
+
export function isOpen(task: Task): boolean {
|
|
94
|
+
return !CLOSED_STATUSES.has(task.status);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Give every task a distinct id without ever renumbering one the payload supplied.
|
|
99
|
+
*
|
|
100
|
+
* A real id is what the model must use to update the task, so it is reserved first;
|
|
101
|
+
* only synthetic ids (derived from the array position) and duplicates of a real id are
|
|
102
|
+
* moved to a free number above the reserved set.
|
|
103
|
+
*/
|
|
104
|
+
function withUniqueIds(tasks: readonly Task[]): Task[] {
|
|
105
|
+
const reserved = new Set<number>();
|
|
106
|
+
for (const task of tasks) if (task.syntheticId !== true) reserved.add(task.id);
|
|
107
|
+
|
|
108
|
+
const used = new Set<number>();
|
|
109
|
+
let next = 1;
|
|
110
|
+
const freeId = (): number => {
|
|
111
|
+
while (reserved.has(next) || used.has(next)) next += 1;
|
|
112
|
+
used.add(next);
|
|
113
|
+
return next;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return tasks.map((task) => {
|
|
117
|
+
if (task.syntheticId !== true && !used.has(task.id)) {
|
|
118
|
+
used.add(task.id);
|
|
119
|
+
return task;
|
|
120
|
+
}
|
|
121
|
+
const { syntheticId: _synthetic, ...rest } = task;
|
|
122
|
+
return { ...rest, id: freeId() };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function snapshotFrom(tasks: readonly Task[], anchor = ""): Snapshot {
|
|
127
|
+
const kept = withUniqueIds(tasks.filter((task) => task.status !== "deleted"));
|
|
128
|
+
return {
|
|
129
|
+
tasks: [...kept],
|
|
130
|
+
open: kept.filter(isOpen),
|
|
131
|
+
signature: kept.map((task) => `${task.id}:${task.status}`).join(","),
|
|
132
|
+
anchor,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function tasksFromEntry(entry: Record<string, unknown>): Task[] | undefined {
|
|
137
|
+
if (entry.type === "custom" && entry.customType === TODO_CUSTOM_TYPE) {
|
|
138
|
+
const data = entry.data as Record<string, unknown> | undefined;
|
|
139
|
+
if (data && Array.isArray(data.tasks)) {
|
|
140
|
+
return data.tasks.map(normalizeTask).filter((task): task is Task => task !== undefined);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (entry.type === "message") {
|
|
144
|
+
const message = entry.message as Record<string, unknown> | undefined;
|
|
145
|
+
if (message?.role === "toolResult" && message.toolName === TODO_TOOL_NAME) {
|
|
146
|
+
const details = message.details as Record<string, unknown> | undefined;
|
|
147
|
+
if (details && Array.isArray(details.tasks)) {
|
|
148
|
+
return details.tasks.map(normalizeTask).filter((task): task is Task => task !== undefined);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A human message anchor: never empty, so the "no anchor yet" sentinel stays unambiguous. */
|
|
156
|
+
function anchorFromEntry(entry: Record<string, unknown>, index: number): string | undefined {
|
|
157
|
+
if (entry.type !== "message") return undefined;
|
|
158
|
+
const message = entry.message as Record<string, unknown> | undefined;
|
|
159
|
+
if (message?.role !== "user") return undefined;
|
|
160
|
+
// If pi ever materialises a custom_message as a plain user message, our own
|
|
161
|
+
// marker must not become the anchor: it changes every round and would reset
|
|
162
|
+
// the budget on every continuation.
|
|
163
|
+
if (typeof message.customType === "string") return undefined;
|
|
164
|
+
const id = entry.id;
|
|
165
|
+
return typeof id === "string" && id !== "" ? id : `#${index}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Read the latest snapshot from a branch, newest entry first.
|
|
170
|
+
*
|
|
171
|
+
* Both shapes are located by their position, and the newer one wins: preference
|
|
172
|
+
* by type alone would serve a stale list forever if the todo tool stopped
|
|
173
|
+
* writing custom entries partway through a session.
|
|
174
|
+
*/
|
|
175
|
+
export function readSnapshot(entries: readonly unknown[]): Snapshot {
|
|
176
|
+
let custom: Task[] | undefined;
|
|
177
|
+
let customIndex = -1;
|
|
178
|
+
let fallback: Task[] | undefined;
|
|
179
|
+
let fallbackIndex = -1;
|
|
180
|
+
let anchor = "";
|
|
181
|
+
|
|
182
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
183
|
+
const entry = entries[index];
|
|
184
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
185
|
+
const record = entry as Record<string, unknown>;
|
|
186
|
+
|
|
187
|
+
if (anchor === "") {
|
|
188
|
+
const found = anchorFromEntry(record, index);
|
|
189
|
+
if (found !== undefined) anchor = found;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (record.type === "custom" && custom === undefined) {
|
|
193
|
+
const found = tasksFromEntry(record);
|
|
194
|
+
if (found !== undefined) {
|
|
195
|
+
custom = found;
|
|
196
|
+
customIndex = index;
|
|
197
|
+
}
|
|
198
|
+
} else if (fallback === undefined) {
|
|
199
|
+
const found = tasksFromEntry(record);
|
|
200
|
+
if (found !== undefined) {
|
|
201
|
+
fallback = found;
|
|
202
|
+
fallbackIndex = index;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if ((custom !== undefined || fallback !== undefined) && anchor !== "") break;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const newest =
|
|
210
|
+
custom !== undefined && (fallback === undefined || customIndex > fallbackIndex)
|
|
211
|
+
? custom
|
|
212
|
+
: fallback !== undefined
|
|
213
|
+
? fallback
|
|
214
|
+
: undefined;
|
|
215
|
+
return snapshotFrom(newest ?? [], anchor);
|
|
216
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"lib": ["ES2023"],
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"noUnusedLocals": true,
|
|
11
|
+
"noUnusedParameters": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"types": ["node"]
|
|
15
|
+
},
|
|
16
|
+
"include": ["src"]
|
|
17
|
+
}
|