@astralyn/pi 0.87.2 → 0.87.3

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/bundle/chunks/{chunk-NBIUJDV6.js → chunk-MJ45I4SM.js} +1 -1
  3. package/dist/bundle/chunks/{chunk-RFLBIA5Z.js → chunk-TYOQD6VQ.js} +33 -20
  4. package/dist/bundle/chunks/{virtual-modules-GOHLE5JL.js → virtual-modules-NDGCHNG7.js} +1 -1
  5. package/dist/bundle/cli.js +1 -1
  6. package/dist/bundle/index.js +1 -1
  7. package/dist/bundle/rpc-entry.js +1 -1
  8. package/dist/core/tools/tool-definition-wrapper.d.ts.map +1 -1
  9. package/dist/core/tools/tool-definition-wrapper.js +114 -1
  10. package/dist/core/tools/tool-definition-wrapper.js.map +1 -1
  11. package/dist/extensions/background/actions.d.ts.map +1 -1
  12. package/dist/extensions/background/actions.js +107 -55
  13. package/dist/extensions/background/actions.js.map +1 -1
  14. package/dist/extensions/background/index.js +3 -3
  15. package/dist/extensions/background/index.js.map +1 -1
  16. package/dist/extensions/question/index.d.ts.map +1 -1
  17. package/dist/extensions/question/index.js +3 -1
  18. package/dist/extensions/question/index.js.map +1 -1
  19. package/dist/extensions/question/schema.d.ts +18 -0
  20. package/dist/extensions/question/schema.d.ts.map +1 -1
  21. package/dist/extensions/question/schema.js +52 -0
  22. package/dist/extensions/question/schema.js.map +1 -1
  23. package/dist/extensions/subagent/index.d.ts.map +1 -1
  24. package/dist/extensions/subagent/index.js +3 -1
  25. package/dist/extensions/subagent/index.js.map +1 -1
  26. package/dist/extensions/subagent/resolve.d.ts +14 -1
  27. package/dist/extensions/subagent/resolve.d.ts.map +1 -1
  28. package/dist/extensions/subagent/resolve.js +32 -18
  29. package/dist/extensions/subagent/resolve.js.map +1 -1
  30. package/dist/extensions/subagent/runner.d.ts.map +1 -1
  31. package/dist/extensions/subagent/runner.js +4 -1
  32. package/dist/extensions/subagent/runner.js.map +1 -1
  33. package/dist/extensions/subagent/schema.d.ts +9 -0
  34. package/dist/extensions/subagent/schema.d.ts.map +1 -1
  35. package/dist/extensions/subagent/schema.js +30 -1
  36. package/dist/extensions/subagent/schema.js.map +1 -1
  37. package/dist/extensions/subagent/types.d.ts +2 -0
  38. package/dist/extensions/subagent/types.d.ts.map +1 -1
  39. package/dist/extensions/subagent/types.js.map +1 -1
  40. package/dist/extensions/todo/constants.d.ts +1 -1
  41. package/dist/extensions/todo/constants.d.ts.map +1 -1
  42. package/dist/extensions/todo/constants.js +8 -8
  43. package/dist/extensions/todo/constants.js.map +1 -1
  44. package/dist/extensions/todo/index.d.ts.map +1 -1
  45. package/dist/extensions/todo/index.js +1 -0
  46. package/dist/extensions/todo/index.js.map +1 -1
  47. package/dist/extensions/todo/schema.d.ts +30 -25
  48. package/dist/extensions/todo/schema.d.ts.map +1 -1
  49. package/dist/extensions/todo/schema.js +26 -20
  50. package/dist/extensions/todo/schema.js.map +1 -1
  51. package/dist/extensions/todo/state.d.ts +12 -8
  52. package/dist/extensions/todo/state.d.ts.map +1 -1
  53. package/dist/extensions/todo/state.js +255 -171
  54. package/dist/extensions/todo/state.js.map +1 -1
  55. package/dist/extensions/todo/view.d.ts +6 -2
  56. package/dist/extensions/todo/view.d.ts.map +1 -1
  57. package/dist/extensions/todo/view.js +234 -144
  58. package/dist/extensions/todo/view.js.map +1 -1
  59. package/docs/bundled/extensions/background.md +1 -1
  60. package/docs/bundled/extensions/question.md +1 -0
  61. package/docs/bundled/extensions/subagent.md +3 -1
  62. package/docs/bundled/extensions/todo.md +35 -23
  63. package/docs/extensions.md +2 -0
  64. package/npm-shrinkwrap.json +2 -2
  65. package/package.json +1 -1
@@ -1,19 +1,18 @@
1
1
  /**
2
- * todo/state.ts — v2 pure state core: closure-scoped store, atomic actions,
3
- * snapshot cloning, and branch replay. No session registry, metadata,
4
- * dependency graph, tombstones, filters, or legacy normalizers.
2
+ * todo/state.ts — v3 pure state core: closure-scoped store, atomic patch
3
+ * application (delete, then update, then create), snapshot cloning, and
4
+ * branch replay. No session registry, metadata, dependency graph, tombstones,
5
+ * filters, or legacy normalizers.
6
+ *
7
+ * The patch shape is designed so every value a strict sampler may fill in for
8
+ * an unused field ([], "", null already stripped by validation) is a no-op:
9
+ * empty groups do nothing, blank update text keeps the current value, and
10
+ * deleting an absent id is recorded rather than rejected.
5
11
  */
6
12
  import { TODO_MAX_BATCH_ITEMS, TODO_MAX_DESCRIPTION_LENGTH, TODO_MAX_ITEMS, TODO_MAX_SUBJECT_LENGTH, TODO_TOOL_NAME, } from "./constants.js";
7
13
  import { EMPTY_TODO_STATE, TODO_DETAILS_SCHEMA_VERSION, } from "./schema.js";
8
14
  const TODO_STATUSES = new Set(["pending", "in_progress", "completed"]);
9
- const TODO_ACTIONS = new Set(["create", "update", "list", "delete"]);
10
- /** Parameters each action accepts; anything else is rejected with guidance. */
11
- const ACTION_PARAMS = {
12
- create: new Set(["items"]),
13
- update: new Set(["id", "subject", "description", "status"]),
14
- list: new Set([]),
15
- delete: new Set(["ids"]),
16
- };
15
+ const GROUP_FIELDS = new Set(["create", "update", "delete"]);
17
16
  function isRecord(value) {
18
17
  return value !== null && typeof value === "object" && !Array.isArray(value);
19
18
  }
@@ -23,12 +22,6 @@ function isPositiveSafeInteger(value) {
23
22
  function isTodoStatus(value) {
24
23
  return typeof value === "string" && TODO_STATUSES.has(value);
25
24
  }
26
- function isTodoAction(value) {
27
- return typeof value === "string" && TODO_ACTIONS.has(value);
28
- }
29
- function findItem(state, id) {
30
- return state.items.find((item) => item.id === id);
31
- }
32
25
  /** De-duplicate ids preserving first-seen input order. */
33
26
  function dedupeIds(ids) {
34
27
  const seen = [];
@@ -42,7 +35,8 @@ function dedupeIds(ids) {
42
35
  function normalizeText(value) {
43
36
  return value.trim().replace(/\s+/g, " ");
44
37
  }
45
- function validateNormalizedText(value, label, maximum) {
38
+ /** Required text (create): normalized, non-empty, within the limit. */
39
+ function validateRequiredText(value, label, maximum) {
46
40
  if (typeof value !== "string")
47
41
  throw new Error(`${label} must be a string`);
48
42
  const normalized = normalizeText(value);
@@ -52,170 +46,260 @@ function validateNormalizedText(value, label, maximum) {
52
46
  throw new Error(`${label} exceeds ${maximum} characters`);
53
47
  return normalized;
54
48
  }
55
- function findInapplicableParam(params) {
56
- const allowed = ACTION_PARAMS[params.action];
57
- for (const [key, value] of Object.entries(params)) {
58
- if (key === "action" || value === undefined)
59
- continue;
60
- if (!allowed.has(key))
61
- return `${key} does not apply to action ${params.action}`;
62
- }
63
- return undefined;
49
+ /**
50
+ * Optional text (update): normalized and within the limit, where blank means
51
+ * "keep the current value" (undefined) so strict-mode filler stays harmless.
52
+ */
53
+ function validateOptionalText(value, label, maximum) {
54
+ if (value === undefined)
55
+ return undefined;
56
+ if (typeof value !== "string")
57
+ throw new Error(`${label} must be a string`);
58
+ const normalized = normalizeText(value);
59
+ if (!normalized)
60
+ return undefined;
61
+ if (normalized.length > maximum)
62
+ throw new Error(`${label} exceeds ${maximum} characters`);
63
+ return normalized;
64
64
  }
65
- /** Snapshot clone: items are plain string/number records, so a shallow copy is safe. */
66
- export function cloneTodoState(state) {
67
- return { items: state.items.map((item) => ({ ...item })), nextId: state.nextId };
65
+ function validateOptionalStatus(value, label) {
66
+ if (value === undefined)
67
+ return undefined;
68
+ if (!isTodoStatus(value))
69
+ throw new Error(`${label} is invalid`);
70
+ return value;
68
71
  }
69
- function applyCreate(state, params) {
70
- const rawItems = params.items;
71
- if (!Array.isArray(rawItems) || rawItems.length === 0)
72
- throw new Error("items required for create");
73
- if (rawItems.length > TODO_MAX_BATCH_ITEMS)
74
- throw new Error(`items exceeds ${TODO_MAX_BATCH_ITEMS} tasks`);
75
- if (state.items.length + rawItems.length > TODO_MAX_ITEMS) {
76
- throw new Error(`todo list is full (max ${TODO_MAX_ITEMS} tasks); delete completed or obsolete tasks first`);
77
- }
78
- if (!Number.isSafeInteger(state.nextId) || state.nextId < 1)
79
- throw new Error("next id is invalid");
80
- if (state.nextId > Number.MAX_SAFE_INTEGER - rawItems.length)
81
- throw new Error("next id is exhausted");
82
- const sources = [];
83
- for (let index = 0; index < rawItems.length; index++) {
84
- const raw = rawItems[index];
85
- if (!isRecord(raw))
86
- throw new Error(`items[${index}] must be an object`);
87
- for (const key of Object.keys(raw)) {
88
- if (key !== "subject" && key !== "description") {
89
- throw new Error(`items[${index}].${key} does not apply to action create`);
72
+ function readCreateEntries(raw) {
73
+ if (raw === undefined)
74
+ return [];
75
+ if (!Array.isArray(raw))
76
+ throw new Error("create must be an array");
77
+ if (raw.length > TODO_MAX_BATCH_ITEMS)
78
+ throw new Error(`create exceeds ${TODO_MAX_BATCH_ITEMS} tasks`);
79
+ const entries = [];
80
+ for (let index = 0; index < raw.length; index++) {
81
+ const item = raw[index];
82
+ if (!isRecord(item))
83
+ throw new Error(`create[${index}] must be an object`);
84
+ for (const key of Object.keys(item)) {
85
+ if (key !== "subject" && key !== "description" && key !== "status") {
86
+ throw new Error(`create[${index}].${key} is not a create field; valid fields: subject, description, status`);
90
87
  }
91
88
  }
92
- sources.push({
93
- subject: validateNormalizedText(raw.subject, `items[${index}].subject`, TODO_MAX_SUBJECT_LENGTH),
94
- description: validateNormalizedText(raw.description, `items[${index}].description`, TODO_MAX_DESCRIPTION_LENGTH),
95
- });
89
+ const entry = {
90
+ subject: validateRequiredText(item.subject, `create[${index}].subject`, TODO_MAX_SUBJECT_LENGTH),
91
+ description: validateRequiredText(item.description, `create[${index}].description`, TODO_MAX_DESCRIPTION_LENGTH),
92
+ };
93
+ const status = validateOptionalStatus(item.status, `create[${index}].status`);
94
+ if (status !== undefined)
95
+ entry.status = status;
96
+ entries.push(entry);
96
97
  }
97
- // Atomic: everything above is validated before the new state is built.
98
- const created = sources.map((source, index) => ({
99
- id: state.nextId + index,
100
- subject: source.subject,
101
- description: source.description,
102
- status: "pending",
103
- }));
104
- return { items: [...state.items, ...created], nextId: state.nextId + created.length };
98
+ return entries;
105
99
  }
106
- function applyUpdate(state, params) {
107
- const id = params.id;
108
- if (!isPositiveSafeInteger(id))
109
- throw new Error("id required for update");
110
- const index = state.items.findIndex((item) => item.id === id);
111
- if (index === -1)
112
- throw new Error(`#${id} not found`);
113
- if (params.subject === undefined && params.description === undefined && params.status === undefined) {
114
- throw new Error("update requires at least one of subject, description, or status");
115
- }
116
- const subject = params.subject === undefined
117
- ? undefined
118
- : validateNormalizedText(params.subject, "subject", TODO_MAX_SUBJECT_LENGTH);
119
- const description = params.description === undefined
120
- ? undefined
121
- : validateNormalizedText(params.description, "description", TODO_MAX_DESCRIPTION_LENGTH);
122
- if (params.status !== undefined && !isTodoStatus(params.status))
123
- throw new Error("status is invalid");
124
- const current = state.items[index];
125
- const updated = {
126
- id: current.id,
127
- subject: subject ?? current.subject,
128
- description: description ?? current.description,
129
- status: params.status ?? current.status,
130
- };
131
- // Exactly one in_progress: setting one demotes every other active task.
132
- const items = state.items.map((item, itemIndex) => {
133
- if (itemIndex === index)
134
- return updated;
135
- if (updated.status === "in_progress" && item.status === "in_progress") {
136
- return { ...item, status: "pending" };
100
+ function readUpdateEntries(raw) {
101
+ if (raw === undefined)
102
+ return [];
103
+ if (!Array.isArray(raw))
104
+ throw new Error("update must be an array");
105
+ if (raw.length > TODO_MAX_BATCH_ITEMS)
106
+ throw new Error(`update exceeds ${TODO_MAX_BATCH_ITEMS} tasks`);
107
+ const entries = [];
108
+ for (let index = 0; index < raw.length; index++) {
109
+ const item = raw[index];
110
+ if (!isRecord(item))
111
+ throw new Error(`update[${index}] must be an object`);
112
+ for (const key of Object.keys(item)) {
113
+ if (key !== "id" && key !== "subject" && key !== "description" && key !== "status") {
114
+ throw new Error(`update[${index}].${key} is not an update field; valid fields: id, subject, description, status`);
115
+ }
137
116
  }
138
- return item;
139
- });
140
- return { items, nextId: state.nextId };
117
+ if (!isPositiveSafeInteger(item.id))
118
+ throw new Error(`update[${index}].id must be a positive integer`);
119
+ const entry = { id: item.id };
120
+ const subject = validateOptionalText(item.subject, `update[${index}].subject`, TODO_MAX_SUBJECT_LENGTH);
121
+ if (subject !== undefined)
122
+ entry.subject = subject;
123
+ const description = validateOptionalText(item.description, `update[${index}].description`, TODO_MAX_DESCRIPTION_LENGTH);
124
+ if (description !== undefined)
125
+ entry.description = description;
126
+ const status = validateOptionalStatus(item.status, `update[${index}].status`);
127
+ if (status !== undefined)
128
+ entry.status = status;
129
+ entries.push(entry);
130
+ }
131
+ return entries;
141
132
  }
142
- function applyDelete(state, params) {
143
- const rawIds = params.ids;
144
- if (!Array.isArray(rawIds) || rawIds.length === 0)
145
- throw new Error("ids required for delete");
146
- if (rawIds.length > TODO_MAX_BATCH_ITEMS)
147
- throw new Error(`ids exceeds ${TODO_MAX_BATCH_ITEMS} ids`);
133
+ function readDeleteIds(raw) {
134
+ if (raw === undefined)
135
+ return [];
136
+ if (!Array.isArray(raw))
137
+ throw new Error("delete must be an array");
138
+ if (raw.length > TODO_MAX_BATCH_ITEMS)
139
+ throw new Error(`delete exceeds ${TODO_MAX_BATCH_ITEMS} ids`);
148
140
  const ids = [];
149
- for (const raw of rawIds) {
150
- if (!isPositiveSafeInteger(raw))
151
- throw new Error("ids must contain positive integer ids");
152
- ids.push(raw);
141
+ for (let index = 0; index < raw.length; index++) {
142
+ if (!isPositiveSafeInteger(raw[index]))
143
+ throw new Error(`delete[${index}] must be a positive integer id`);
144
+ ids.push(raw[index]);
153
145
  }
154
- const idsToRemove = dedupeIds(ids);
155
- // Atomic: every id must exist before anything is removed.
156
- const byId = new Map(state.items.map((item) => [item.id, item]));
157
- for (const candidate of idsToRemove) {
158
- if (!byId.has(candidate))
159
- throw new Error(`#${candidate} not found`);
160
- }
161
- const remove = new Set(idsToRemove);
162
- return { items: state.items.filter((item) => !remove.has(item.id)), nextId: state.nextId };
146
+ return ids;
147
+ }
148
+ function currentIdsLabel(state) {
149
+ if (state.items.length === 0)
150
+ return "the list is empty";
151
+ return `current ids: ${state.items.map((item) => `#${item.id}`).join(", ")}`;
152
+ }
153
+ /** Snapshot clone: items are plain string/number records, so a shallow copy is safe. */
154
+ export function cloneTodoState(state) {
155
+ return { items: state.items.map((item) => ({ ...item })), nextId: state.nextId };
163
156
  }
164
157
  /**
165
- * Pure action application: validates every runtime input and inapplicable
166
- * parameter (tool args can be tampered after schema validation), throws on
167
- * any invalid call, and never mutates the input state.
158
+ * Pure patch application: validates every runtime input (tool args can be
159
+ * tampered after schema validation), throws on any invalid call, and never
160
+ * mutates the input state. Application order is delete, then update, then
161
+ * create, so a delete in the same call frees capacity for the create.
168
162
  */
169
- export function applyTodoAction(state, params) {
163
+ export function applyTodoPatch(before, params) {
170
164
  if (!isRecord(params))
171
165
  throw new Error("todo params must be an object");
172
- if (!isTodoAction(params.action))
173
- throw new Error(`unknown todo action: ${String(params.action)}`);
174
- const inapplicable = findInapplicableParam(params);
175
- if (inapplicable)
176
- throw new Error(inapplicable);
177
- switch (params.action) {
178
- case "create":
179
- return applyCreate(state, params);
180
- case "update":
181
- return applyUpdate(state, params);
182
- case "list":
183
- return cloneTodoState(state);
184
- case "delete":
185
- return applyDelete(state, params);
166
+ for (const key of Object.keys(params)) {
167
+ if (!GROUP_FIELDS.has(key))
168
+ throw new Error(`unknown todo field "${key}"; valid fields: create, update, delete`);
186
169
  }
187
- }
188
- /** Derive the operation model for one call from the before/after states. */
189
- export function buildTodoChange(params, before, after) {
190
- switch (params.action) {
191
- // Derived from the validated before/after states rather than params.items,
192
- // which is external input and could read differently on a second access.
193
- case "create":
194
- return { kind: "create", ids: after.items.slice(before.items.length).map((item) => item.id) };
195
- case "update": {
196
- const id = params.id;
197
- if (!isPositiveSafeInteger(id))
198
- throw new Error("id required for update");
199
- const demotedId = before.items.find((item) => item.id !== id && item.status === "in_progress" && findItem(after, item.id)?.status === "pending")?.id;
200
- return {
201
- kind: "update",
202
- id,
203
- from: findItem(before, id)?.status ?? "pending",
204
- to: findItem(after, id)?.status ?? "pending",
205
- ...(demotedId !== undefined ? { demotedId } : {}),
170
+ // Everything above the apply phase is validation: any throw leaves the
171
+ // caller's state untouched.
172
+ const createEntries = readCreateEntries(params.create);
173
+ const updateEntries = readUpdateEntries(params.update);
174
+ const deleteIds = readDeleteIds(params.delete);
175
+ // Conflicts: one edit per task per call, and a task cannot be both edited
176
+ // and removed in the same call.
177
+ const updatedIds = new Set();
178
+ for (const entry of updateEntries) {
179
+ if (updatedIds.has(entry.id))
180
+ throw new Error(`#${entry.id} appears more than once in update`);
181
+ updatedIds.add(entry.id);
182
+ }
183
+ const deleteSet = new Set(deleteIds);
184
+ for (const entry of updateEntries) {
185
+ if (deleteSet.has(entry.id))
186
+ throw new Error(`#${entry.id} cannot be in both update and delete`);
187
+ }
188
+ // Exactly one activation per call: more than one is a real contradiction,
189
+ // since a single task can be in_progress at any time.
190
+ const activations = [];
191
+ createEntries.forEach((entry, index) => {
192
+ if (entry.status === "in_progress")
193
+ activations.push(`create[${index}]`);
194
+ });
195
+ for (const entry of updateEntries) {
196
+ if (entry.status === "in_progress")
197
+ activations.push(`#${entry.id}`);
198
+ }
199
+ if (activations.length > 1) {
200
+ throw new Error(`at most one task may be set in_progress per call; got ${activations.join(" and ")}`);
201
+ }
202
+ const byId = new Map(before.items.map((item) => [item.id, item]));
203
+ for (const entry of updateEntries) {
204
+ if (!byId.has(entry.id))
205
+ throw new Error(`#${entry.id} not found; ${currentIdsLabel(before)}`);
206
+ }
207
+ if (createEntries.length > 0) {
208
+ if (!Number.isSafeInteger(before.nextId) || before.nextId < 1)
209
+ throw new Error("next id is invalid");
210
+ if (before.nextId > Number.MAX_SAFE_INTEGER - createEntries.length)
211
+ throw new Error("next id is exhausted");
212
+ }
213
+ // --- Apply: delete -------------------------------------------------------
214
+ const deleted = [];
215
+ const absent = [];
216
+ for (const id of dedupeIds(deleteIds)) {
217
+ const item = byId.get(id);
218
+ if (item)
219
+ deleted.push({ id: item.id, subject: item.subject });
220
+ else
221
+ absent.push(id);
222
+ }
223
+ let items = before.items.filter((item) => !deleteSet.has(item.id));
224
+ // --- Apply: update -------------------------------------------------------
225
+ const updated = [];
226
+ if (updateEntries.length > 0) {
227
+ const entriesById = new Map(updateEntries.map((entry) => [entry.id, entry]));
228
+ items = items.map((item) => {
229
+ const entry = entriesById.get(item.id);
230
+ if (!entry)
231
+ return item;
232
+ const next = {
233
+ id: item.id,
234
+ subject: entry.subject ?? item.subject,
235
+ description: entry.description ?? item.description,
236
+ status: entry.status ?? item.status,
237
+ };
238
+ updated.push({ id: item.id, from: item.status, to: next.status });
239
+ return next;
240
+ });
241
+ }
242
+ // --- Apply: create -------------------------------------------------------
243
+ const created = [];
244
+ if (createEntries.length > 0) {
245
+ const newItems = createEntries.map((entry, index) => {
246
+ const item = {
247
+ id: before.nextId + index,
248
+ subject: entry.subject,
249
+ description: entry.description,
250
+ status: entry.status ?? "pending",
206
251
  };
252
+ return item;
253
+ });
254
+ for (const item of newItems)
255
+ created.push(item.id);
256
+ items = [...items, ...newItems];
257
+ }
258
+ // --- Single in_progress: the one task this call activates demotes the rest.
259
+ let demotedId;
260
+ let activatedId;
261
+ for (const entry of updateEntries) {
262
+ if (entry.status === "in_progress")
263
+ activatedId = entry.id;
264
+ }
265
+ if (activatedId === undefined) {
266
+ createEntries.forEach((entry, index) => {
267
+ if (entry.status === "in_progress")
268
+ activatedId = before.nextId + index;
269
+ });
270
+ }
271
+ if (activatedId !== undefined) {
272
+ const demoted = items.find((item) => item.id !== activatedId && item.status === "in_progress");
273
+ if (demoted) {
274
+ demotedId = demoted.id;
275
+ items = items.map((item) => (item.id === demoted.id ? { ...item, status: "pending" } : item));
207
276
  }
208
- case "list":
209
- return { kind: "list" };
210
- case "delete": {
211
- const ids = Array.isArray(params.ids) ? dedupeIds(params.ids) : [];
212
- const removed = ids
213
- .map((id) => findItem(before, id))
214
- .filter((item) => item !== undefined)
215
- .map((item) => ({ id: item.id, subject: item.subject }));
216
- return { kind: "delete", removed };
277
+ }
278
+ // --- Capacity: completed tasks are reclaimed oldest-first before a call
279
+ // may fail for space; only a list with more than the cap in open tasks is
280
+ // rejected.
281
+ const evicted = [];
282
+ if (items.length > TODO_MAX_ITEMS) {
283
+ let overflow = items.length - TODO_MAX_ITEMS;
284
+ const evictIds = new Set();
285
+ const completedOldestFirst = items
286
+ .filter((item) => item.status === "completed")
287
+ .sort((first, second) => first.id - second.id);
288
+ for (const item of completedOldestFirst) {
289
+ if (overflow <= 0)
290
+ break;
291
+ evictIds.add(item.id);
292
+ evicted.push({ id: item.id, subject: item.subject });
293
+ overflow--;
217
294
  }
295
+ if (overflow > 0)
296
+ throw new Error(`at most ${TODO_MAX_ITEMS} open tasks; complete or delete some first`);
297
+ items = items.filter((item) => !evictIds.has(item.id));
218
298
  }
299
+ const change = { created, updated, deleted, absent, evicted };
300
+ if (demotedId !== undefined)
301
+ change.demotedId = demotedId;
302
+ return { state: { items, nextId: before.nextId + created.length }, change };
219
303
  }
220
304
  export function buildTodoDetails(change, state) {
221
305
  return { schemaVersion: TODO_DETAILS_SCHEMA_VERSION, change, state: cloneTodoState(state) };
@@ -237,19 +321,18 @@ export function createTodoStore(initial) {
237
321
  state = requireValidState(next);
238
322
  },
239
323
  execute(params) {
240
- const before = state;
241
- const after = applyTodoAction(before, params);
242
- const details = buildTodoDetails(buildTodoChange(params, before, after), after);
243
- state = after;
324
+ const patch = applyTodoPatch(state, params);
325
+ const details = buildTodoDetails(patch.change, patch.state);
326
+ state = patch.state;
244
327
  return details;
245
328
  },
246
329
  };
247
330
  }
248
331
  /**
249
- * Validate an external v2 snapshot; returns undefined when malformed.
332
+ * Validate an external v3 snapshot; returns undefined when malformed.
250
333
  *
251
334
  * Snapshot text must already be in normalizeText() form, which makes that
252
- * function's output part of the v2 persistence contract: relaxing or changing
335
+ * function's output part of the v3 persistence contract: relaxing or changing
253
336
  * it would silently invalidate every historical snapshot, falling back to an
254
337
  * earlier one or to the empty state with no diagnostic. Change normalizeText
255
338
  * only together with TODO_DETAILS_SCHEMA_VERSION.
@@ -296,9 +379,10 @@ function normalizeSnapshotState(value) {
296
379
  return { items, nextId: value.nextId };
297
380
  }
298
381
  /**
299
- * Replay the newest valid v2 todo snapshot from a branch, scanning tail to
382
+ * Replay the newest valid v3 todo snapshot from a branch, scanning tail to
300
383
  * head so a malformed latest snapshot falls back to an earlier valid one.
301
- * v1 details are ignored, and the `change` field is not validated.
384
+ * Older schema versions (v1/v2) are ignored: restoring a session written
385
+ * before v3 starts from an empty list.
302
386
  */
303
387
  export function replayTodosFromBranch(ctx) {
304
388
  const branch = Array.from(ctx.sessionManager.getBranch());
@@ -321,7 +405,7 @@ export function replayTodosFromBranch(ctx) {
321
405
  return state;
322
406
  }
323
407
  catch {
324
- // Keep scanning for an earlier valid v2 snapshot.
408
+ // Keep scanning for an earlier valid v3 snapshot.
325
409
  }
326
410
  }
327
411
  return cloneTodoState(EMPTY_TODO_STATE);