@howaboua/pi-notes-compaction 0.0.4 → 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,16 @@
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
+
3
14
  ## 0.0.4
4
15
 
5
16
  - Fixed note queries and manual context rollover with Codex Conversion.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@howaboua/pi-notes-compaction",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/bridge.ts CHANGED
@@ -1,6 +1,7 @@
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,
6
7
  SessionBeforeCompactEvent,
@@ -86,6 +87,8 @@ export interface NotesOwner {
86
87
 
87
88
  export interface NotesBridge {
88
89
  protocol: 1;
90
+ /** Preserve the owner's chronological entry while enabling developer-role delivery. */
91
+ prepareReminder?(entry: CustomMessageEntryDraft): CustomMessageEntryDraft;
89
92
  /** Synchronous atomic handoff. No legacy registrations or lifecycle owners remain active. */
90
93
  claim(owner: NotesOwner):
91
94
  | {
package/src/index.ts CHANGED
@@ -54,6 +54,15 @@ export default function notesCompaction(pi: ExtensionAPI): void {
54
54
  entry.customType === "@howaboua/pi-stuff/changelog"
55
55
  )
56
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
+ );
57
66
  // Herdr activation persists its empty registry before first task delivery.
58
67
  if (
59
68
  entry.type === "custom" &&
@@ -168,10 +177,29 @@ export default function notesCompaction(pi: ExtensionAPI): void {
168
177
  );
169
178
  }
170
179
  });
171
- pi.on("turn_end", (_event, ctx) => {
172
- if (!lifecycle.active || lifecycle.fresh(ctx, lifecycle.runId)) return;
173
- const reminder = lifecycle.windows.reminder(ctx);
174
- 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;
175
203
  });
176
204
  pi.on("context", (event, ctx) => {
177
205
  if (!lifecycle.active) return;
@@ -240,8 +268,10 @@ export default function notesCompaction(pi: ExtensionAPI): void {
240
268
  settings: () => ({
241
269
  normalCompaction: lifecycle.normalCompaction,
242
270
  idleMinutes: lifecycle.idleMinutes,
271
+ taskBoundaryReminder: lifecycle.taskBoundaryReminder,
243
272
  }),
244
273
  save,
274
+ settingsPath,
245
275
  prune,
246
276
  status: () => {
247
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;
@@ -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>;
@@ -31,6 +37,17 @@ export async function openNotesSettings(
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
@@ -41,7 +41,8 @@ const HISTORY_ACTIONS = [
41
41
  "read_item",
42
42
  "search_contents",
43
43
  ] as const;
44
- 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)";
45
46
  export const HISTORY_USAGE = "await tools.history() // help";
46
47
  const STRING = Type.Optional(Type.String());
47
48
  const INTEGER = Type.Optional(Type.Integer({ minimum: 1 }));
@@ -526,6 +527,14 @@ export function createTools(
526
527
  'Supply an action object: await tools.notes({ action: "read_file", path: "checkpoint" }); tools.notes() returns help',
527
528
  );
528
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
+ }
529
538
  if (
530
539
  params["action"] === "list_files_by_prefix" &&
531
540
  params["max_files"] !== undefined
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,