@howaboua/pi-notes-compaction 0.0.3 → 0.0.5

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,23 @@
1
1
  # @howaboua/pi-notes-compaction
2
2
 
3
+ ## 0.0.5
4
+
5
+ - We've been informed that Astra and Sol currently retain their prompt caches for around an hour. If you use notes-managed idle rollover, consider increasing it from 25 to 55 minutes. Cache retention can vary. YMMV. We'll keep monitoring. Please share your cache-retention and long-context quota observations in [the feedback thread](https://github.com/IgorWarzocha/howaboua-pi-stuff/issues/508).
6
+
7
+ Added configurable task-boundary reminders and a clearer settings layout.
8
+
9
+ - Added optional reminders at preset or custom token counts when context capacity exceeds 272k, showing the remaining percentage.
10
+ - Status text and keyboard hints now wrap to fit narrow terminals.
11
+ - Notes help now lists required arguments. Writes accept `content` when `text` is omitted.
12
+ - Fixed fresh worker startup with Notes enabled.
13
+
14
+ ## 0.0.4
15
+
16
+ - Fixed note queries and manual context rollover with Codex Conversion.
17
+
18
+ - 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.
19
+ - `/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.
20
+
3
21
  ## 0.0.3
4
22
 
5
23
  - 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.5",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/bridge.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
2
  import type {
3
3
  AgentToolResult,
4
+ CustomMessageEntryDraft,
4
5
  ExtensionAPI,
5
6
  ExtensionContext,
7
+ SessionBeforeCompactEvent,
6
8
  SessionEntry,
7
9
  ToolDefinition,
8
10
  } from "@earendil-works/pi-coding-agent";
@@ -68,6 +70,8 @@ export interface NotesOwner {
68
70
  identity(ctx: ExtensionContext): WindowIdentity | undefined;
69
71
  projectMessages(messages: readonly AgentMessage[]): AgentMessage[];
70
72
  projectBranch(entries: readonly SessionEntry[]): SessionEntry[];
73
+ /** Consult before compaction work; the owner's hook performs notes-only rollover. */
74
+ allowsCompaction?(reason: SessionBeforeCompactEvent["reason"]): boolean;
71
75
  canBind?(ctx: ExtensionContext): boolean;
72
76
  snapshotNotes?(
73
77
  entries: readonly SessionEntry[],
@@ -83,6 +87,8 @@ export interface NotesOwner {
83
87
 
84
88
  export interface NotesBridge {
85
89
  protocol: 1;
90
+ /** Preserve the owner's chronological entry while enabling developer-role delivery. */
91
+ prepareReminder?(entry: CustomMessageEntryDraft): CustomMessageEntryDraft;
86
92
  /** Synchronous atomic handoff. No legacy registrations or lifecycle owners remain active. */
87
93
  claim(owner: NotesOwner):
88
94
  | {
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() &&
@@ -53,6 +54,15 @@ export default function notesCompaction(pi: ExtensionAPI): void {
53
54
  entry.customType === "@howaboua/pi-stuff/changelog"
54
55
  )
55
56
  return true;
57
+ // PCC links the fresh worker before binding its shared notes family.
58
+ if (
59
+ entry.type === "custom" &&
60
+ entry.customType === "codex-agent-relationship"
61
+ )
62
+ return (
63
+ (entry.data as { threadId?: unknown } | undefined)?.threadId ===
64
+ ctx.sessionManager.getSessionId()
65
+ );
56
66
  // Herdr activation persists its empty registry before first task delivery.
57
67
  if (
58
68
  entry.type === "custom" &&
@@ -167,10 +177,29 @@ export default function notesCompaction(pi: ExtensionAPI): void {
167
177
  );
168
178
  }
169
179
  });
170
- pi.on("turn_end", (_event, ctx) => {
171
- if (!lifecycle.active || lifecycle.fresh(ctx, lifecycle.runId)) return;
172
- const reminder = lifecycle.windows.reminder(ctx);
173
- return reminder ? { entries: [reminder] } : undefined;
180
+ pi.on("turn_end", (event, ctx) => {
181
+ if (!lifecycle.active) return;
182
+ const reminder = lifecycle.windows.reminder(
183
+ ctx,
184
+ event.message.role === "assistant" &&
185
+ !ctx.signal?.aborted &&
186
+ !["error", "length", "aborted"].includes(event.message.stopReason) &&
187
+ event.toolResults.length > 0 &&
188
+ pi.getActiveTools().includes("new_context")
189
+ ? lifecycle.taskBoundaryReminder
190
+ : 0,
191
+ !lifecycle.fresh(ctx, lifecycle.runId),
192
+ );
193
+ return reminder
194
+ ? {
195
+ entries: [
196
+ ...event.entries,
197
+ windowDetails(reminder)?.kind === "task-boundary"
198
+ ? (lifecycle.bridge?.prepareReminder?.(reminder) ?? reminder)
199
+ : reminder,
200
+ ],
201
+ }
202
+ : undefined;
174
203
  });
175
204
  pi.on("context", (event, ctx) => {
176
205
  if (!lifecycle.active) return;
@@ -239,8 +268,10 @@ export default function notesCompaction(pi: ExtensionAPI): void {
239
268
  settings: () => ({
240
269
  normalCompaction: lifecycle.normalCompaction,
241
270
  idleMinutes: lifecycle.idleMinutes,
271
+ taskBoundaryReminder: lifecycle.taskBoundaryReminder,
242
272
  }),
243
273
  save,
274
+ settingsPath,
244
275
  prune,
245
276
  status: () => {
246
277
  const counts = store.status();
package/src/lifecycle.ts CHANGED
@@ -50,6 +50,7 @@ export class NotesLifecycle {
50
50
  active = false;
51
51
  normalCompaction = false;
52
52
  idleMinutes: IdleMinutes = 0;
53
+ taskBoundaryReminder = 0;
53
54
  bridge: NotesBridge | undefined;
54
55
  runId = "";
55
56
  private checkpoint: Checkpoint | undefined;
@@ -357,11 +358,7 @@ export class NotesLifecycle {
357
358
  event: SessionBeforeCompactEvent,
358
359
  ctx: ExtensionContext,
359
360
  ): { cancel: true } | undefined {
360
- if (
361
- this.normalCompaction ||
362
- this.rollover?.phase === "compacting" ||
363
- event.reason === "overflow"
364
- ) {
361
+ if (this.allowsCompaction(event.reason)) {
365
362
  // PCC owns preparation when bridged, including native checkpoint routes.
366
363
  if (this.bridge) return;
367
364
  try {
@@ -435,6 +432,13 @@ export class NotesLifecycle {
435
432
  };
436
433
  return { cancel: true };
437
434
  }
435
+ allowsCompaction(reason: SessionBeforeCompactEvent["reason"]): boolean {
436
+ return (
437
+ this.normalCompaction ||
438
+ this.rollover?.phase === "compacting" ||
439
+ reason === "overflow"
440
+ );
441
+ }
438
442
  async compactFailed(
439
443
  event: SessionCompactFailedEvent,
440
444
  ctx: ExtensionContext,
@@ -450,7 +454,7 @@ export class NotesLifecycle {
450
454
  )
451
455
  return;
452
456
  if (!pending.instructions?.trim() && this.fresh(ctx))
453
- await this.roll(ctx, true);
457
+ await this.roll(ctx, false);
454
458
  else this.startCheckpoint(ctx, pending.instructions);
455
459
  }
456
460
  async settled(
@@ -517,7 +521,7 @@ export class NotesLifecycle {
517
521
  );
518
522
  return;
519
523
  }
520
- await this.roll(ctx, true);
524
+ await this.roll(ctx, false);
521
525
  return;
522
526
  }
523
527
  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 {
@@ -2,13 +2,19 @@ import {
2
2
  type ExtensionContext,
3
3
  getSettingsListTheme,
4
4
  } from "@earendil-works/pi-coding-agent";
5
- import { SettingsList, truncateToWidth } from "@earendil-works/pi-tui";
5
+ import {
6
+ SettingsList,
7
+ truncateToWidth,
8
+ wrapTextWithAnsi,
9
+ } from "@earendil-works/pi-tui";
6
10
  import { IDLE_MINUTES, type NotesSettings } from "./settings.js";
11
+ import { TASK_BOUNDARY_PRESETS, taskBoundaryLabel } from "./task-boundary.js";
7
12
 
8
13
  export async function openNotesSettings(
9
14
  ctx: ExtensionContext,
10
15
  options: {
11
16
  settings: () => NotesSettings;
17
+ settingsPath: string;
12
18
  save: (settings: NotesSettings) => void;
13
19
  status: () => string[];
14
20
  prune: () => Promise<void>;
@@ -27,10 +33,21 @@ export async function openNotesSettings(
27
33
  id: "compact",
28
34
  label: "Normal compaction",
29
35
  description:
30
- "Include the installed Pi/PCC compaction flow on new_context. Off keeps notes-only rollover.",
36
+ "Use Pi/PCC compaction for /compact and new_context. Off uses notes-only rollover; /compact then waits for your next prompt.",
31
37
  currentValue: settings.normalCompaction ? "on" : "off",
32
38
  values: ["off", "on"],
33
39
  },
40
+ {
41
+ id: "taskBoundaryReminder",
42
+ label: "Task-boundary reminder",
43
+ description:
44
+ "Suggest a separate-task rollover after this many used tokens, only with active capacity above 272k. Custom: edit taskBoundaryReminder in the notes settings JSON (whole tokens); 0 is off.",
45
+ currentValue: taskBoundaryLabel(settings.taskBoundaryReminder),
46
+ values: [
47
+ ...TASK_BOUNDARY_PRESETS.map(taskBoundaryLabel),
48
+ "custom (edit JSON)",
49
+ ],
50
+ },
34
51
  {
35
52
  id: "idle",
36
53
  label: "Idle rollover",
@@ -56,9 +73,31 @@ export async function openNotesSettings(
56
73
  return;
57
74
  }
58
75
  const current = options.settings();
76
+ if (
77
+ id === "taskBoundaryReminder" &&
78
+ value === "custom (edit JSON)"
79
+ ) {
80
+ ctx.ui.notify(
81
+ `Set taskBoundaryReminder to a positive whole token count in ${options.settingsPath}, then reload Pi. 0 is off.`,
82
+ "info",
83
+ );
84
+ list.updateValue(
85
+ id,
86
+ taskBoundaryLabel(current.taskBoundaryReminder),
87
+ );
88
+ return;
89
+ }
59
90
  try {
60
91
  if (id === "compact")
61
92
  options.save({ ...current, normalCompaction: value === "on" });
93
+ else if (id === "taskBoundaryReminder")
94
+ options.save({
95
+ ...current,
96
+ taskBoundaryReminder:
97
+ TASK_BOUNDARY_PRESETS.find(
98
+ (tokens) => taskBoundaryLabel(tokens) === value,
99
+ ) ?? current.taskBoundaryReminder,
100
+ });
62
101
  else {
63
102
  const idleMinutes = IDLE_MINUTES.find(
64
103
  (minutes) => idleValue(minutes) === value,
@@ -69,11 +108,13 @@ export async function openNotesSettings(
69
108
  } catch (error) {
70
109
  list.updateValue(
71
110
  id,
72
- id === "compact"
73
- ? current.normalCompaction
74
- ? "on"
75
- : "off"
76
- : idleValue(current.idleMinutes),
111
+ id === "taskBoundaryReminder"
112
+ ? taskBoundaryLabel(current.taskBoundaryReminder)
113
+ : id === "compact"
114
+ ? current.normalCompaction
115
+ ? "on"
116
+ : "off"
117
+ : idleValue(current.idleMinutes),
77
118
  );
78
119
  ctx.ui.notify(
79
120
  `Could not save notes settings: ${String(error)}`,
@@ -86,20 +127,36 @@ export async function openNotesSettings(
86
127
  { enableSearch: false },
87
128
  );
88
129
  return {
89
- render: (width) =>
90
- [
91
- theme.fg("accent", "Notes & context"),
92
- ...status.map((line) => theme.fg("dim", line)),
130
+ render: (width) => {
131
+ const wrap = (text: string) =>
132
+ wrapTextWithAnsi(text, Math.max(1, width - 2)).map(
133
+ (line) => ` ${line}`,
134
+ );
135
+ const rule = theme.fg("accent", "─".repeat(Math.max(0, width)));
136
+ return [
137
+ rule,
138
+ ` ${theme.bold("Notes & context")}`,
139
+ "",
140
+ ...status.flatMap((line) =>
141
+ wrap(line).map((part) => theme.fg("dim", part)),
142
+ ),
93
143
  "",
94
- ...list.render(width),
144
+ ...list.render(width).slice(0, -2),
95
145
  "",
96
- theme.fg(
97
- "dim",
98
- "↑↓ navigate · Enter/Space change · Esc close · saved immediately",
146
+ ...wrap("↑/↓ select · Enter/Space change · Esc close").map(
147
+ (line) => theme.fg("dim", line),
99
148
  ),
100
- ].map((line) => truncateToWidth(line, width)),
149
+ ...wrap("Settings saved immediately").map((line) =>
150
+ theme.fg("dim", line),
151
+ ),
152
+ rule,
153
+ ].map((line) => truncateToWidth(line, width, ""));
154
+ },
101
155
  invalidate: () => list.invalidate(),
102
- handleInput: (data) => list.handleInput(data),
156
+ handleInput: (data) => {
157
+ list.handleInput(data);
158
+ tui.requestRender();
159
+ },
103
160
  };
104
161
  },
105
162
  );
package/src/settings.ts CHANGED
@@ -6,6 +6,7 @@ export type IdleMinutes = (typeof IDLE_MINUTES)[number];
6
6
  export interface NotesSettings {
7
7
  normalCompaction: boolean;
8
8
  idleMinutes: IdleMinutes;
9
+ taskBoundaryReminder: number;
9
10
  }
10
11
 
11
12
  export function readSettings(path: string): NotesSettings {
@@ -14,7 +15,11 @@ export function readSettings(path: string): NotesSettings {
14
15
  source = readFileSync(path, "utf8");
15
16
  } catch (error) {
16
17
  if ((error as NodeJS.ErrnoException).code === "ENOENT")
17
- return { normalCompaction: false, idleMinutes: 0 };
18
+ return {
19
+ normalCompaction: false,
20
+ idleMinutes: 0,
21
+ taskBoundaryReminder: 0,
22
+ };
18
23
  throw error;
19
24
  }
20
25
  const settings: unknown = JSON.parse(source);
@@ -28,6 +33,16 @@ export function readSettings(path: string): NotesSettings {
28
33
  "Invalid notes settings. Save a setting in /notes, then reload Pi.",
29
34
  );
30
35
  const idleMinutes = "idleMinutes" in settings ? settings.idleMinutes : 0;
36
+ const taskBoundaryReminder =
37
+ "taskBoundaryReminder" in settings ? settings.taskBoundaryReminder : 0;
38
+ if (
39
+ typeof taskBoundaryReminder !== "number" ||
40
+ !Number.isSafeInteger(taskBoundaryReminder) ||
41
+ taskBoundaryReminder < 0
42
+ )
43
+ throw new Error(
44
+ "Invalid task-boundary reminder setting. Save a setting in /notes, then reload Pi.",
45
+ );
31
46
  if (!IDLE_MINUTES.some((value) => value === idleMinutes))
32
47
  throw new Error(
33
48
  "Invalid idle rollover interval. Choose off, 5, 15, 25 or 55 minutes.",
@@ -35,6 +50,7 @@ export function readSettings(path: string): NotesSettings {
35
50
  return {
36
51
  normalCompaction: settings.normalCompaction,
37
52
  idleMinutes: idleMinutes as IdleMinutes,
53
+ taskBoundaryReminder,
38
54
  };
39
55
  }
40
56
 
@@ -0,0 +1,32 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export function renderTaskBoundaryReminder(remainingPercent: number): string {
4
+ return `<context_window_reminder>\nThis window is approaching the recommended token budget. You still have ${remainingPercent}% of your context window remaining, so there is no need to start a new window immediately. If you are working through a sequence of tasks toward a larger goal, finish the current task, save a checkpoint, and start a new window before continuing with the next task. Otherwise, continue here.\n</context_window_reminder>`;
5
+ }
6
+
7
+ export const TASK_BOUNDARY_PRESETS = [
8
+ 0, 250_000, 300_000, 400_000, 450_000,
9
+ ] as const;
10
+ export function taskBoundaryLabel(tokens: number): string {
11
+ return tokens === 0
12
+ ? "off"
13
+ : TASK_BOUNDARY_PRESETS.some((value) => value === tokens)
14
+ ? `${tokens / 1000}k`
15
+ : "custom (edit JSON)";
16
+ }
17
+ export function taskBoundaryEligible(
18
+ ctx: ExtensionContext,
19
+ threshold: number,
20
+ tokens = ctx.getContextUsage()?.tokens,
21
+ ): boolean {
22
+ const capacity =
23
+ ctx.getContextUsage()?.contextWindow ?? ctx.model?.contextWindow ?? 0;
24
+ return (
25
+ threshold > 0 &&
26
+ Number.isFinite(capacity) &&
27
+ capacity > 272_000 &&
28
+ typeof tokens === "number" &&
29
+ Number.isFinite(tokens) &&
30
+ tokens >= threshold
31
+ );
32
+ }
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,
@@ -40,20 +41,23 @@ const HISTORY_ACTIONS = [
40
41
  "read_item",
41
42
  "search_contents",
42
43
  ] as const;
43
- export const NOTES_USAGE = "await tools.notes() // help";
44
+ export const NOTES_USAGE =
45
+ "await tools.notes({ action, ...args }) // no args: help; actions(required args): list_files_by_prefix(); read_file(path); search_contents(query); append_to_file(path,text); write_file(path,text)";
44
46
  export const HISTORY_USAGE = "await tools.history() // help";
45
47
  const STRING = Type.Optional(Type.String());
46
48
  const INTEGER = Type.Optional(Type.Integer({ minimum: 1 }));
49
+ const NULLABLE_STRING = Type.Optional(Type.Union([Type.String(), Type.Null()]));
50
+ const LINE = Type.Optional(Type.Union([Type.Integer(), Type.Null()]));
47
51
  const NOTES_SCHEMA = Type.Object(
48
52
  {
49
53
  action: Type.Optional(StringEnum(NOTES_ACTIONS)),
50
54
  path: STRING,
51
55
  text: STRING,
52
- prefix: STRING,
56
+ prefix: NULLABLE_STRING,
53
57
  query: STRING,
54
- path_prefix: STRING,
55
- start_line: INTEGER,
56
- stop_line: INTEGER,
58
+ path_prefix: NULLABLE_STRING,
59
+ start_line: LINE,
60
+ stop_line: LINE,
57
61
  max_results: INTEGER,
58
62
  max_files: INTEGER,
59
63
  max_matches_per_file: INTEGER,
@@ -146,7 +150,32 @@ function relevant(
146
150
  const key =
147
151
  action === "search_contents" ? `search_${namespace}` : String(action);
148
152
  const fields = FIELDS[key];
149
- if (!fields) throw new Error("Unknown action");
153
+ if (!fields)
154
+ throw new Error(
155
+ namespace === "notes"
156
+ ? "notes requires a supported action"
157
+ : "Unknown action",
158
+ );
159
+ if (namespace === "notes") {
160
+ for (const field of Object.keys(params))
161
+ if (field !== "action" && !fields.includes(field))
162
+ throw new Error(`notes ${String(action)} does not accept ${field}`);
163
+ if (
164
+ ["read_file", "write_file", "append_to_file"].includes(String(action)) &&
165
+ (typeof params["path"] !== "string" || !params["path"])
166
+ )
167
+ throw new Error(`notes ${String(action)} requires path`);
168
+ if (
169
+ action === "search_contents" &&
170
+ (typeof params["query"] !== "string" || !params["query"])
171
+ )
172
+ throw new Error("notes search_contents requires query");
173
+ if (
174
+ ["write_file", "append_to_file"].includes(String(action)) &&
175
+ typeof params["text"] !== "string"
176
+ )
177
+ throw new Error(`notes ${String(action)} requires text`);
178
+ }
150
179
  return Object.fromEntries(
151
180
  Object.entries(params).filter(
152
181
  ([key]) => key === "action" || fields.includes(key),
@@ -192,6 +221,16 @@ function discovery(
192
221
  ).join(" "),
193
222
  ]),
194
223
  ),
224
+ ...(namespace === "notes"
225
+ ? {
226
+ usage:
227
+ 'await tools.notes({ action: "read_file", path: "checkpoint" })',
228
+ ranges:
229
+ "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",
230
+ defaults:
231
+ "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",
232
+ }
233
+ : {}),
195
234
  });
196
235
  }
197
236
  return undefined;
@@ -484,15 +523,37 @@ export function createTools(
484
523
  prepareArguments(args) {
485
524
  if (args === undefined) return {};
486
525
  if (!args || typeof args !== "object" || Array.isArray(args))
487
- return args;
526
+ throw new Error(
527
+ 'Supply an action object: await tools.notes({ action: "read_file", path: "checkpoint" }); tools.notes() returns help',
528
+ );
488
529
  const params = { ...args } as Record<string, unknown>;
530
+ if (
531
+ (params["action"] === "write_file" ||
532
+ params["action"] === "append_to_file") &&
533
+ "content" in params
534
+ ) {
535
+ if (!("text" in params)) params["text"] = params["content"];
536
+ delete params["content"];
537
+ }
489
538
  if (
490
539
  params["action"] === "list_files_by_prefix" &&
491
540
  params["max_files"] !== undefined
492
541
  ) {
493
- params["max_results"] ??= params["max_files"];
542
+ if (!("max_results" in params))
543
+ params["max_results"] = params["max_files"];
494
544
  delete params["max_files"];
495
545
  }
546
+ if (!discovery("notes", params)) relevant("notes", params);
547
+ for (const [field, value] of Object.entries(params)) {
548
+ const schema =
549
+ NOTES_SCHEMA.properties[
550
+ field as keyof typeof NOTES_SCHEMA.properties
551
+ ];
552
+ if (!schema || !Check(schema, value))
553
+ throw new Error(
554
+ `Invalid notes ${field}; call tools.notes() for exact arguments`,
555
+ );
556
+ }
496
557
  return params;
497
558
  },
498
559
  execute: (callId, input, signal, _update, ctx) =>
package/src/windows.ts CHANGED
@@ -10,12 +10,16 @@ import type {
10
10
  import { keyHint } from "@earendil-works/pi-coding-agent";
11
11
  import { Box, Text, TruncatedText } from "@earendil-works/pi-tui";
12
12
  import type { WindowIdentity } from "./bridge.js";
13
+ import {
14
+ renderTaskBoundaryReminder,
15
+ taskBoundaryEligible,
16
+ } from "./task-boundary.js";
13
17
 
14
18
  export const WINDOW_MESSAGE = "notes-compaction:window:v1";
15
19
  export const SETTLEMENT_ENTRY = "notes-compaction:settled:v1";
16
20
  export interface WindowDetails {
17
21
  protocol: 1;
18
- kind: "window" | "identity" | "reminder" | "urgent";
22
+ kind: "window" | "identity" | "reminder" | "urgent" | "task-boundary";
19
23
  identity: WindowIdentity;
20
24
  trim: boolean;
21
25
  }
@@ -32,11 +36,13 @@ export function registerWindowRenderer(pi: ExtensionAPI): void {
32
36
  const details = parseWindowDetails(message.details);
33
37
  if (!details || typeof message.content !== "string") return undefined;
34
38
  const title =
35
- details.kind === "urgent"
36
- ? "Context nearly full · Save notes and start a new window now"
37
- : details.kind === "reminder"
38
- ? "Context checkpoint reminder · Save notes and start a new window"
39
- : `Context window ${details.identity.windowNumber + 1} · Notes and history`;
39
+ details.kind === "task-boundary"
40
+ ? "Task-boundary reminder"
41
+ : details.kind === "urgent"
42
+ ? "Context nearly full · Save notes and start a new window now"
43
+ : details.kind === "reminder"
44
+ ? "Context checkpoint reminder · Save notes and start a new window"
45
+ : `Context window ${details.identity.windowNumber + 1} · Notes and history`;
40
46
  const box = new Box(outputPad, 1, (text) =>
41
47
  theme.bg("customMessageBg", text),
42
48
  );
@@ -85,7 +91,7 @@ function parseWindowDetails(value: unknown): WindowDetails | undefined {
85
91
  if (
86
92
  details.protocol !== 1 ||
87
93
  !ids ||
88
- !["window", "identity", "reminder", "urgent"].includes(
94
+ !["window", "identity", "reminder", "urgent", "task-boundary"].includes(
89
95
  details.kind ?? "",
90
96
  ) ||
91
97
  typeof details.trim !== "boolean"
@@ -100,7 +106,9 @@ function parseWindowDetails(value: unknown): WindowDetails | undefined {
100
106
  }
101
107
 
102
108
  /** Understand previous PCC window IDs for familiar history lookup, without importing its runtime. */
103
- export function windowDetails(entry: SessionEntry): WindowDetails | undefined {
109
+ export function windowDetails(
110
+ entry: SessionEntry | CustomMessageEntryDraft,
111
+ ): WindowDetails | undefined {
104
112
  if (entry.type !== "custom_message") return;
105
113
  return messageWindowDetails(entry.customType, entry.details);
106
114
  }
@@ -122,14 +130,14 @@ function messageWindowDetails(
122
130
  !details ||
123
131
  typeof details !== "object" ||
124
132
  !("kind" in details) ||
125
- details.kind !== "window"
133
+ (details.kind !== "window" && details.kind !== "task-boundary")
126
134
  )
127
135
  return;
128
136
  const ids = identity(details);
129
137
  return ids
130
138
  ? {
131
139
  protocol: 1,
132
- kind: "window",
140
+ kind: details.kind,
133
141
  identity: ids,
134
142
  trim:
135
143
  "trimPreviousWindow" in details &&
@@ -182,13 +190,18 @@ export class Windows {
182
190
  this.compactedSinceBoundary = false;
183
191
  } else if (
184
192
  details &&
185
- (details.kind === "reminder" || details.kind === "urgent")
193
+ (details.kind === "reminder" ||
194
+ details.kind === "urgent" ||
195
+ details.kind === "task-boundary")
186
196
  )
187
197
  this.reminders.add(
188
198
  `${details.identity.currentWindowId}:${details.kind}`,
189
199
  );
190
200
  if (entry.type === "compaction") {
191
- this.reminders.clear();
201
+ // Compaction frees capacity, but does not create a new window identity.
202
+ this.reminders = new Set(
203
+ [...this.reminders].filter((key) => key.endsWith(":task-boundary")),
204
+ );
192
205
  this.compactedSinceBoundary = true;
193
206
  }
194
207
  }
@@ -226,21 +239,47 @@ export class Windows {
226
239
  : Math.round((remainingTokens / contextWindow) * 1000) / 10,
227
240
  };
228
241
  }
229
- reminder(ctx: ExtensionContext): CustomMessageEntryDraft | undefined {
242
+ reminder(
243
+ ctx: ExtensionContext,
244
+ taskBoundaryReminder = 0,
245
+ checkpointReminder = true,
246
+ ): CustomMessageEntryDraft | undefined {
230
247
  const { remainingTokens, contextWindow, remainingPercent } =
231
248
  this.remaining(ctx);
232
- if (!this.current || remainingTokens === undefined) return;
233
- const used = 100 * (1 - remainingTokens / contextWindow);
234
- const kind = used >= 90 ? "urgent" : used >= 85 ? "reminder" : undefined;
235
- if (!kind || this.reminders.has(`${this.current.currentWindowId}:${kind}`))
249
+ if (
250
+ !this.current ||
251
+ remainingTokens === undefined ||
252
+ remainingPercent === undefined
253
+ )
236
254
  return;
237
- this.reminders.add(`${this.current.currentWindowId}:reminder`);
255
+ const used = 100 * (1 - remainingTokens / contextWindow);
256
+ let kind: WindowDetails["kind"] | undefined = checkpointReminder
257
+ ? used >= 90
258
+ ? "urgent"
259
+ : used >= 85
260
+ ? "reminder"
261
+ : undefined
262
+ : undefined;
263
+ if (kind && this.reminders.has(`${this.current.currentWindowId}:${kind}`))
264
+ kind = undefined;
265
+ if (
266
+ !kind &&
267
+ taskBoundaryReminder &&
268
+ !this.reminders.has(`${this.current.currentWindowId}:task-boundary`) &&
269
+ taskBoundaryEligible(ctx, taskBoundaryReminder)
270
+ )
271
+ kind = "task-boundary";
272
+ if (!kind) return;
273
+ this.reminders.add(`${this.current.currentWindowId}:${kind}`);
238
274
  if (kind === "urgent")
239
- this.reminders.add(`${this.current.currentWindowId}:urgent`);
275
+ this.reminders.add(`${this.current.currentWindowId}:reminder`);
240
276
  return {
241
277
  type: "custom_message",
242
278
  customType: WINDOW_MESSAGE,
243
- content: `<context_window_reminder>\n${kind === "urgent" ? "Urgent: " : ""}${remainingPercent}% remaining. Checkpoint the active request, state and known history IDs in notes, then call new_context ${kind === "urgent" ? "now, before other work" : "before continuing work"}.\n</context_window_reminder>`,
279
+ content:
280
+ kind === "task-boundary"
281
+ ? renderTaskBoundaryReminder(remainingPercent)
282
+ : `<context_window_reminder>\n${kind === "urgent" ? "Urgent: " : ""}${remainingPercent}% remaining. Checkpoint the active request, state and known history IDs in notes, then call new_context ${kind === "urgent" ? "now, before other work" : "before continuing work"}.\n</context_window_reminder>`,
244
283
  display: true,
245
284
  details: {
246
285
  protocol: 1,