@howaboua/pi-notes-compaction 0.0.3 → 0.0.4

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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # @howaboua/pi-notes-compaction
2
2
 
3
+ ## 0.0.4
4
+
5
+ - Fixed note queries and manual context rollover with Codex Conversion.
6
+
7
+ - Restored negative line ranges, default limits, result ordering, metadata and complete read and search content. Help now shows concrete call syntax, and invalid arguments are reported before execution.
8
+ - `/compact` now honours the Normal compaction setting. With it off, Notes Compaction reuses fresh saved notes or requests a checkpoint, then opens a new context window without starting a continuation turn.
9
+
3
10
  ## 0.0.3
4
11
 
5
12
  - Fixed shared agent startup after package updates when the startup changelog is present.
@@ -35,7 +35,7 @@ Required boundaries in the current source:
35
35
 
36
36
  - `context-management/tools.ts` and `extension/tools.ts`: replace the four tools through the registration callback. Active-tool and schema rewrites must preserve delegated definitions. Do not reinstall legacy routers on model changes or settings changes.
37
37
  - `extension/session-lifecycle.ts` and `extension/turn-lifecycle.ts`: skip legacy notes/window initialization, input holding, checkpoint admission, note freshness, reminders, settlement records and continuation scheduling. Leave PCC's prompt preparation, runtime synchronization, Notebook status, voice, quota and provider lifecycle active.
38
- - `extension/compaction-lifecycle.ts`: skip only legacy `contextWindows.prepareCompaction` and notes checkpoint overrides. When this extension allows normal compaction, run the unchanged installed PCC normal-compaction path. Overflow stays in the same window. Notebook checkpointing, native compaction, replay, transport reset and presentation remain PCC-owned.
38
+ - `extension/compaction-lifecycle.ts`: consult `owner.allowsCompaction(reason)` before any compaction work. When false, return without work so the owner's hook can cancel and handle notes-only rollover, regardless of extension order. Skip legacy `contextWindows.prepareCompaction` and notes checkpoint overrides. When this extension allows normal compaction, run the unchanged installed PCC normal-compaction path. Overflow stays in the same window. Notebook checkpointing, native compaction, replay, transport reset and presentation remain PCC-owned.
39
39
  - `context-management/window-manager.ts`, `window-request.ts` and native replay serializers: use `owner.identity(ctx)` and `owner.projectBranch(entries)` for the active window and retirement boundary. Preserve PCC request metadata and zero-based generation semantics. Do not recreate remote history-ingest requirements for local notes.
40
40
  - PCC message preparation and `adapter/developer-messages.ts`: preserve `notes-compaction:window:v1` and `notes-compaction:checkpoint:v1` custom messages on every model. Compatible Responses routes may promote them to developer messages. Ordinary Pi conversion must retain them on other routes. Use `owner.projectMessages(messages)` before native replay/prewarm, without overwriting another extension's preparation.
41
41
  - Settings UI: show notes continuity as externally managed. Preserve old PCC values for sessions without the external extension. Do not add another compaction setting to this extension.
package/README.md CHANGED
@@ -20,7 +20,9 @@ The agent keeps the familiar `notes`, `history`, `new_context` and `get_context_
20
20
 
21
21
  By default, `new_context` starts a fresh window without generating a summary. The agent receives recent note paths, and Pi's saved history remains available for lookup.
22
22
 
23
- Reminders arrive at 85% and 90% context use. `/compact` requests a notes checkpoint and opens a new window only after a completed run with fresh saved notes. Abort, errors and failed writes retain the old context. Overflow recovery still uses Pi's normal compaction.
23
+ Reminders arrive at 85% and 90% context use. With normal compaction off, `/compact` opens a fresh window immediately if the latest completed turn saved fresh notes. Otherwise it requests a checkpoint first. The new window waits for your next prompt; it does not start a continuation turn. Abort, errors and failed writes retain the old context. Overflow recovery still uses Pi's normal compaction.
24
+
25
+ Pi rejects `/compact` when there is too little history to prepare compaction, before extensions run. In that case, the agent can still use `new_context`.
24
26
 
25
27
  Idle rollover is off by default. Choose 5, 15, 25 or 55 minutes in `/notes`. After that interval, the next input waits for rollover only if the latest completed turn saved fresh notes. Otherwise it proceeds in the current window without requesting a checkpoint. These intervals are user preferences, not provider cache-expiry guarantees. Held input retains its original text, attachments and SDK options. A failed rollover keeps input pending. Submit another prompt to retry, or reload to cancel it.
26
28
 
@@ -28,7 +30,7 @@ Idle rollover is off by default. Choose 5, 15, 25 or 55 minutes in `/notes`. Aft
28
30
 
29
31
  `/notes` opens the settings panel in interactive Pi. It shows continuity status, storage counts and remote capabilities. Changes save immediately.
30
32
 
31
- - **Normal compaction** includes the installed Pi or PCC compaction flow on `new_context`. Off keeps notes-only rollover, the default.
33
+ - **Normal compaction** uses the installed Pi or PCC compaction flow for `/compact` and `new_context`. Off keeps notes-only rollover, the default.
32
34
  - **Idle rollover** selects the interval or disables it.
33
35
  - **Prune missing sessions** removes records for missing session files after confirmation. Unknown paths, permission errors and the active session are retained.
34
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@howaboua/pi-notes-compaction",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/bridge.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  AgentToolResult,
4
4
  ExtensionAPI,
5
5
  ExtensionContext,
6
+ SessionBeforeCompactEvent,
6
7
  SessionEntry,
7
8
  ToolDefinition,
8
9
  } from "@earendil-works/pi-coding-agent";
@@ -68,6 +69,8 @@ export interface NotesOwner {
68
69
  identity(ctx: ExtensionContext): WindowIdentity | undefined;
69
70
  projectMessages(messages: readonly AgentMessage[]): AgentMessage[];
70
71
  projectBranch(entries: readonly SessionEntry[]): SessionEntry[];
72
+ /** Consult before compaction work; the owner's hook performs notes-only rollover. */
73
+ allowsCompaction?(reason: SessionBeforeCompactEvent["reason"]): boolean;
71
74
  canBind?(ctx: ExtensionContext): boolean;
72
75
  snapshotNotes?(
73
76
  entries: readonly SessionEntry[],
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export default function notesCompaction(pi: ExtensionAPI): void {
43
43
  lifecycle.windows.current ? { ...lifecycle.windows.current } : undefined,
44
44
  projectMessages: (messages) => lifecycle.windows.projectMessages(messages),
45
45
  projectBranch: (entries) => lifecycle.windows.projectBranch(entries),
46
+ allowsCompaction: (reason) => lifecycle.allowsCompaction(reason),
46
47
  canBind: (ctx) =>
47
48
  lifecycle.active &&
48
49
  ctx.isIdle() &&
package/src/lifecycle.ts CHANGED
@@ -357,11 +357,7 @@ export class NotesLifecycle {
357
357
  event: SessionBeforeCompactEvent,
358
358
  ctx: ExtensionContext,
359
359
  ): { cancel: true } | undefined {
360
- if (
361
- this.normalCompaction ||
362
- this.rollover?.phase === "compacting" ||
363
- event.reason === "overflow"
364
- ) {
360
+ if (this.allowsCompaction(event.reason)) {
365
361
  // PCC owns preparation when bridged, including native checkpoint routes.
366
362
  if (this.bridge) return;
367
363
  try {
@@ -435,6 +431,13 @@ export class NotesLifecycle {
435
431
  };
436
432
  return { cancel: true };
437
433
  }
434
+ allowsCompaction(reason: SessionBeforeCompactEvent["reason"]): boolean {
435
+ return (
436
+ this.normalCompaction ||
437
+ this.rollover?.phase === "compacting" ||
438
+ reason === "overflow"
439
+ );
440
+ }
438
441
  async compactFailed(
439
442
  event: SessionCompactFailedEvent,
440
443
  ctx: ExtensionContext,
@@ -450,7 +453,7 @@ export class NotesLifecycle {
450
453
  )
451
454
  return;
452
455
  if (!pending.instructions?.trim() && this.fresh(ctx))
453
- await this.roll(ctx, true);
456
+ await this.roll(ctx, false);
454
457
  else this.startCheckpoint(ctx, pending.instructions);
455
458
  }
456
459
  async settled(
@@ -517,7 +520,7 @@ export class NotesLifecycle {
517
520
  );
518
521
  return;
519
522
  }
520
- await this.roll(ctx, true);
523
+ await this.roll(ctx, false);
521
524
  return;
522
525
  }
523
526
  if (this.held?.phase === "draining") {
package/src/notes.ts CHANGED
@@ -216,25 +216,34 @@ export function exportNotesSnapshot(
216
216
  }
217
217
 
218
218
  function canonicalPath(path: unknown, agent: string, required = true): string {
219
- if (typeof path !== "string" || !path.trim()) {
220
- if (required) throw new Error("Supply a note path");
219
+ if (path === undefined || path === null || path === "") {
220
+ if (required) throw new Error("Note file path is required");
221
221
  return "";
222
222
  }
223
+ if (typeof path !== "string")
224
+ throw new Error(
225
+ required ? "Note file path is required" : "Note prefix must be a string",
226
+ );
223
227
  const root = `${agent}/notes`;
224
228
  let value = path;
225
- if (value === root) value = "";
226
- else if (value.startsWith(`${root}/`)) value = value.slice(root.length + 1);
229
+ if (value === root) {
230
+ if (required)
231
+ throw new Error("Absolute note paths must use <agent>/notes/<path>");
232
+ value = "";
233
+ } else if (value.startsWith(`${root}/`)) value = value.slice(root.length + 1);
227
234
  else if (value.startsWith("/"))
228
235
  throw new Error(
229
236
  "That agent's notes are not available here. Use a relative path for this agent.",
230
237
  );
231
238
  if (
232
239
  (required && !value) ||
233
- value.split("/").some((part) => part === ".." || part === ".") ||
234
- /[\0\r\n]/.test(value)
240
+ (value !== "" &&
241
+ value.split("/").some((part) => !part || part === ".." || part === "."))
235
242
  )
236
243
  throw new Error(
237
- "Use a virtual note path without dot segments or control characters",
244
+ required
245
+ ? "Note paths cannot contain empty, . or .. components"
246
+ : "Note prefixes cannot contain empty, . or .. components",
238
247
  );
239
248
  return value;
240
249
  }
@@ -244,11 +253,8 @@ export function normalizeNoteParams(
244
253
  agent: string,
245
254
  ): Record<string, unknown> {
246
255
  const result = { ...params };
247
- const needsPath = ["read_file", "write_file", "append_to_file"].includes(
248
- String(params["action"]),
249
- );
250
256
  for (const field of ["path", "prefix", "path_prefix"])
251
- if (field in params || field !== "path" || needsPath)
257
+ if (field in params)
252
258
  result[field] = canonicalPath(params[field], agent, field === "path");
253
259
  return result;
254
260
  }
@@ -285,12 +291,13 @@ export function useNotes(
285
291
  if (action === "write_file" || action === "append_to_file") {
286
292
  const path = params["path"] as string;
287
293
  const text = params["text"];
288
- if (typeof text !== "string") throw new Error("Supply note text");
294
+ if (typeof text !== "string")
295
+ throw new Error(`notes ${action} requires text`);
289
296
  const previous = notes.get(path);
290
297
  const next =
291
298
  action === "append_to_file" ? (previous?.text ?? "") + text : text;
292
299
  if (Buffer.byteLength(next) > 1_000_000)
293
- throw new Error("Note exceeds 1 MB. Split it into separate paths.");
300
+ throw new Error("Note file exceeds the 1,000,000-byte limit");
294
301
  const now = Date.now();
295
302
  // Append never pretends to decode a remote base. The remote fragment remains separate.
296
303
  const mode =
@@ -324,113 +331,101 @@ export function useNotes(
324
331
  );
325
332
  }
326
333
  return {
327
- path: `${root}${path}`,
328
- chars: next.length,
329
- operation:
330
- mode === "append" ? "local_append_overlay" : "local_replacement",
334
+ source: "pi-session",
335
+ file: noteMetadata(revision, root),
331
336
  };
332
337
  }
338
+
333
339
  if (action === "read_file") {
334
340
  const path = params["path"] as string;
335
341
  const note = notes.get(path);
336
- if (!note) return { path: `${root}${path}`, file: null };
342
+ if (!note) return { source: "pi-session", file: null };
337
343
  const lines = note.text.split("\n");
338
- const start = integer(params["start_line"], 1, lines.length + 1);
339
- const stop = integer(params["stop_line"], lines.length, lines.length);
340
- let text = "";
341
- let next = start;
342
- while (next <= stop) {
343
- const line = lines[next - 1] ?? "";
344
- if (text.length + line.length > 32_000) {
345
- if (!text)
346
- throw new Error(
347
- "This note line exceeds 32000 characters. Rewrite it as shorter lines to read it.",
348
- );
349
- break;
350
- }
351
- text += `${next > start ? "\n" : ""}${line}`;
352
- next++;
353
- }
344
+ const start = lineIndex(params["start_line"], lines.length, 0);
345
+ const stop = lineIndex(params["stop_line"], lines.length, lines.length - 1);
354
346
  return {
355
- path: `${root}${path}`,
356
- text,
357
- start_line: start,
358
- total_lines: lines.length,
359
- ...(next <= stop ? { next_start_line: next } : {}),
360
- operation:
361
- note.mode === "replace" ? "local_replacement" : "local_append_overlay",
347
+ source: "pi-session",
348
+ file: {
349
+ ...noteMetadata(note, root),
350
+ content: start <= stop ? lines.slice(start, stop + 1).join("\n") : "",
351
+ start_line: start + 1,
352
+ stop_line: Math.max(start, stop) + 1,
353
+ total_lines: lines.length,
354
+ },
362
355
  };
363
356
  }
364
357
  if (action === "list_files_by_prefix") {
365
- let files = [...notes.values()].filter((note) =>
366
- note.path.startsWith(String(params["prefix"] ?? "")),
367
- );
368
358
  const by = params["file_order_by"];
369
- files.sort((a, b) =>
370
- by === "created_at"
371
- ? a.createdAt - b.createdAt
372
- : by === "updated_at"
373
- ? a.updatedAt - b.updatedAt
374
- : a.path.localeCompare(b.path),
375
- );
376
- if (params["file_order"] === "descending") files.reverse();
377
- const maximum = integer(params["max_results"], 100, 100);
378
- return {
379
- files: files.slice(0, maximum).map((note) => ({
380
- path: `${root}${note.path}`,
381
- chars: note.text.length,
382
- updated_at: note.updatedAt,
383
- })),
384
- truncated: files.length > maximum,
385
- };
359
+ const direction = params["file_order"] === "descending" ? -1 : 1;
360
+ const files = [...notes.values()]
361
+ .filter((note) => note.path.startsWith(String(params["prefix"] ?? "")))
362
+ .sort(
363
+ (a, b) =>
364
+ (by === "created_at"
365
+ ? a.createdAt - b.createdAt
366
+ : by === "updated_at"
367
+ ? a.updatedAt - b.updatedAt
368
+ : a.path.localeCompare(b.path)) * direction,
369
+ )
370
+ .slice(0, integer(params["max_results"], 20, 100))
371
+ .map((note) => noteMetadata(note, root));
372
+ return { source: "pi-session", files };
386
373
  }
387
- if (
388
- action !== "search_contents" ||
389
- typeof params["query"] !== "string" ||
390
- !params["query"]
391
- )
392
- throw new Error("Supply a nonempty search query");
374
+ if (action !== "search_contents")
375
+ throw new Error("notes requires a supported action");
376
+ const query = params["query"];
377
+ if (typeof query !== "string" || !query)
378
+ throw new Error("notes search_contents requires query");
393
379
  const files = [...notes.values()].filter((note) =>
394
380
  note.path.startsWith(String(params["path_prefix"] ?? "")),
395
381
  );
396
- if (params["recent_file_first"] === true)
397
- files.sort((a, b) => b.updatedAt - a.updatedAt);
398
- const found: {
399
- path: string;
400
- matches: { line: number; text: string }[];
401
- truncated: boolean;
402
- }[] = [];
403
- let chars = 0;
404
- let truncated = false;
382
+ files.sort(
383
+ params["recent_file_first"] === true
384
+ ? (a, b) => b.createdAt - a.createdAt
385
+ : (a, b) => a.path.localeCompare(b.path),
386
+ );
387
+ const found: Record<string, unknown>[] = [];
405
388
  for (const note of files) {
406
- const all = note.text
389
+ const matches = note.text
407
390
  .split("\n")
408
- .flatMap((line, i) =>
409
- line.includes(params["query"] as string)
410
- ? [{ line: i + 1, text: line.slice(0, 1000) }]
411
- : [],
412
- );
413
- if (!all.length) continue;
414
- const matches = all.slice(
415
- 0,
416
- integer(params["max_matches_per_file"], 10, 100),
417
- );
418
- const size = JSON.stringify(matches).length;
419
- if (
420
- found.length >= integer(params["max_files"], 20, 100) ||
421
- chars + size > 32_000
422
- ) {
423
- truncated = true;
424
- break;
425
- }
391
+ .map((line, index) => ({ line_number: index + 1, line }))
392
+ .filter(({ line }) => line.includes(query))
393
+ .slice(0, integer(params["max_matches_per_file"], 20, 100));
394
+ if (!matches.length) continue;
426
395
  found.push({
427
396
  path: `${root}${note.path}`,
428
397
  matches,
429
- truncated: matches.length < all.length,
398
+ operation:
399
+ note.mode === "replace" ? "local_replacement" : "local_append_overlay",
430
400
  });
431
- chars += size;
401
+ if (found.length >= integer(params["max_files"], 20, 100)) break;
432
402
  }
433
- return { files: found, truncated };
403
+ return { source: "pi-session", files: found };
404
+ }
405
+
406
+ function noteMetadata(
407
+ note: NoteRevision,
408
+ root: string,
409
+ ): Record<string, unknown> {
410
+ return {
411
+ operation:
412
+ note.mode === "replace" ? "local_replacement" : "local_append_overlay",
413
+ path: `${root}${note.path}`,
414
+ bytes: Buffer.byteLength(note.text, "utf8"),
415
+ created_at: new Date(note.createdAt).toISOString(),
416
+ updated_at: new Date(note.updatedAt).toISOString(),
417
+ };
418
+ }
419
+
420
+ function lineIndex(
421
+ value: unknown,
422
+ lineCount: number,
423
+ fallback: number,
424
+ ): number {
425
+ if (typeof value !== "number" || !Number.isInteger(value) || value === 0)
426
+ return fallback;
427
+ const index = value > 0 ? value - 1 : lineCount + value;
428
+ return Math.max(0, Math.min(index, Math.max(0, lineCount - 1)));
434
429
  }
435
430
 
436
431
  function integer(value: unknown, fallback: number, max: number): number {
@@ -27,7 +27,7 @@ export async function openNotesSettings(
27
27
  id: "compact",
28
28
  label: "Normal compaction",
29
29
  description:
30
- "Include the installed Pi/PCC compaction flow on new_context. Off keeps notes-only rollover.",
30
+ "Use Pi/PCC compaction for /compact and new_context. Off uses notes-only rollover; /compact then waits for your next prompt.",
31
31
  currentValue: settings.normalCompaction ? "on" : "off",
32
32
  values: ["off", "on"],
33
33
  },
package/src/tools.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  ToolDefinition,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import { Type } from "typebox";
9
+ import { Check } from "typebox/value";
9
10
  import type {
10
11
  CachedResponse,
11
12
  LookupQuery,
@@ -44,16 +45,18 @@ export const NOTES_USAGE = "await tools.notes() // help";
44
45
  export const HISTORY_USAGE = "await tools.history() // help";
45
46
  const STRING = Type.Optional(Type.String());
46
47
  const INTEGER = Type.Optional(Type.Integer({ minimum: 1 }));
48
+ const NULLABLE_STRING = Type.Optional(Type.Union([Type.String(), Type.Null()]));
49
+ const LINE = Type.Optional(Type.Union([Type.Integer(), Type.Null()]));
47
50
  const NOTES_SCHEMA = Type.Object(
48
51
  {
49
52
  action: Type.Optional(StringEnum(NOTES_ACTIONS)),
50
53
  path: STRING,
51
54
  text: STRING,
52
- prefix: STRING,
55
+ prefix: NULLABLE_STRING,
53
56
  query: STRING,
54
- path_prefix: STRING,
55
- start_line: INTEGER,
56
- stop_line: INTEGER,
57
+ path_prefix: NULLABLE_STRING,
58
+ start_line: LINE,
59
+ stop_line: LINE,
57
60
  max_results: INTEGER,
58
61
  max_files: INTEGER,
59
62
  max_matches_per_file: INTEGER,
@@ -146,7 +149,32 @@ function relevant(
146
149
  const key =
147
150
  action === "search_contents" ? `search_${namespace}` : String(action);
148
151
  const fields = FIELDS[key];
149
- if (!fields) throw new Error("Unknown action");
152
+ if (!fields)
153
+ throw new Error(
154
+ namespace === "notes"
155
+ ? "notes requires a supported action"
156
+ : "Unknown action",
157
+ );
158
+ if (namespace === "notes") {
159
+ for (const field of Object.keys(params))
160
+ if (field !== "action" && !fields.includes(field))
161
+ throw new Error(`notes ${String(action)} does not accept ${field}`);
162
+ if (
163
+ ["read_file", "write_file", "append_to_file"].includes(String(action)) &&
164
+ (typeof params["path"] !== "string" || !params["path"])
165
+ )
166
+ throw new Error(`notes ${String(action)} requires path`);
167
+ if (
168
+ action === "search_contents" &&
169
+ (typeof params["query"] !== "string" || !params["query"])
170
+ )
171
+ throw new Error("notes search_contents requires query");
172
+ if (
173
+ ["write_file", "append_to_file"].includes(String(action)) &&
174
+ typeof params["text"] !== "string"
175
+ )
176
+ throw new Error(`notes ${String(action)} requires text`);
177
+ }
150
178
  return Object.fromEntries(
151
179
  Object.entries(params).filter(
152
180
  ([key]) => key === "action" || fields.includes(key),
@@ -192,6 +220,16 @@ function discovery(
192
220
  ).join(" "),
193
221
  ]),
194
222
  ),
223
+ ...(namespace === "notes"
224
+ ? {
225
+ usage:
226
+ 'await tools.notes({ action: "read_file", path: "checkpoint" })',
227
+ ranges:
228
+ "start_line/stop_line inclusive, 1-based; negative counts from end; 0/null/omitted uses first/last line; out-of-range clamps; reversed range returns empty content",
229
+ defaults:
230
+ "list max_results 20; search max_files/max_matches_per_file 20; limits clamp to 1..100; list name ascending; search name ascending, recent_file_first sorts created_at descending",
231
+ }
232
+ : {}),
195
233
  });
196
234
  }
197
235
  return undefined;
@@ -484,15 +522,29 @@ export function createTools(
484
522
  prepareArguments(args) {
485
523
  if (args === undefined) return {};
486
524
  if (!args || typeof args !== "object" || Array.isArray(args))
487
- return args;
525
+ throw new Error(
526
+ 'Supply an action object: await tools.notes({ action: "read_file", path: "checkpoint" }); tools.notes() returns help',
527
+ );
488
528
  const params = { ...args } as Record<string, unknown>;
489
529
  if (
490
530
  params["action"] === "list_files_by_prefix" &&
491
531
  params["max_files"] !== undefined
492
532
  ) {
493
- params["max_results"] ??= params["max_files"];
533
+ if (!("max_results" in params))
534
+ params["max_results"] = params["max_files"];
494
535
  delete params["max_files"];
495
536
  }
537
+ if (!discovery("notes", params)) relevant("notes", params);
538
+ for (const [field, value] of Object.entries(params)) {
539
+ const schema =
540
+ NOTES_SCHEMA.properties[
541
+ field as keyof typeof NOTES_SCHEMA.properties
542
+ ];
543
+ if (!schema || !Check(schema, value))
544
+ throw new Error(
545
+ `Invalid notes ${field}; call tools.notes() for exact arguments`,
546
+ );
547
+ }
496
548
  return params;
497
549
  },
498
550
  execute: (callId, input, signal, _update, ctx) =>