pi-posthorse 0.10.2 → 0.10.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.
package/src/recovery.ts CHANGED
@@ -1,19 +1,12 @@
1
- import {
2
- AUTO_HANDOFF_PREFIX,
3
- isRecord,
4
- isReminderType,
5
- type EntryLike,
6
- type ProjectedEntry,
7
- } from "./contracts.ts";
1
+ import { AUTO_HANDOFF_PREFIX, isRecord, type EntryLike, type ProjectedEntry } from "./contracts.ts";
8
2
  import { contextEdits, contextStart, windowHandoff } from "./history-entries.ts";
3
+ import { boundedBlock, excerpt, toolCalls } from "./message-text.ts";
9
4
  import {
10
- boundedBlock,
11
- excerpt,
12
- imageSummary,
13
- imagesOf,
14
- textOf,
15
- toolCalls,
16
- } from "./message-text.ts";
5
+ formatRecord,
6
+ indexLine,
7
+ recoveryRecord,
8
+ type RecoveryRecord,
9
+ } from "./recovery-records.ts";
17
10
  import { renderSessionNotes, type RetainedSpan } from "./recovery-notes.ts";
18
11
  import {
19
12
  recoverableToolResults,
@@ -66,90 +59,6 @@ function buildRolloverPreamble(span: RetainedSpan | undefined): string {
66
59
  : `entries ${span.firstId} through ${span.lastId} above are the carried-over recent tail`;
67
60
  return `${PREAMBLE}\n${RETAINED_MARKER_PREFIX}${span.lastId}. Earlier turns before entry ${span.firstId} were compacted into session history; ${range} from before this rollover. Recent messages follow separately; their inputs and tool evidence are not repeated in this record. Start the new context window from here.`;
68
61
  }
69
- type RecoveryRecord = {
70
- readonly id: string;
71
- readonly timestamp: string;
72
- readonly kind: "owner" | "coordination";
73
- readonly label: string;
74
- readonly text: string;
75
- };
76
- type RecordSource = {
77
- readonly kind: RecoveryRecord["kind"];
78
- readonly label: string;
79
- readonly content: unknown;
80
- };
81
- function ownerAnswer(entry: EntryLike, ownerQuestion: boolean): boolean {
82
- const message = entry.message;
83
- return (
84
- ownerQuestion &&
85
- entry.type === "message" &&
86
- message?.role === "toolResult" &&
87
- message.toolName === "ask_question" &&
88
- message.namespace === undefined &&
89
- message.isError !== true
90
- );
91
- }
92
- function recordSource(entry: EntryLike, ownerQuestion: boolean): RecordSource | undefined {
93
- if (entry.type === "message" && entry.message?.role === "user") {
94
- return { kind: "owner", label: "owner input", content: entry.message.content };
95
- }
96
- if (ownerAnswer(entry, ownerQuestion)) {
97
- return {
98
- kind: "owner",
99
- label: "owner answer via ask_question",
100
- content: entry.message?.content,
101
- };
102
- }
103
- if (
104
- entry.type === "custom_message" &&
105
- entry.display === true &&
106
- !isReminderType(entry.customType)
107
- ) {
108
- return {
109
- kind: "coordination",
110
- label: `visible ${(entry.customType ?? "custom").slice(0, 80)} coordination input (not direct owner input)`,
111
- content: entry.content,
112
- };
113
- }
114
- return;
115
- }
116
- function recoveryRecord(entry: EntryLike, ownerQuestion: boolean): RecoveryRecord | undefined {
117
- const source = recordSource(entry, ownerQuestion);
118
- if (source === undefined) {
119
- return undefined;
120
- }
121
- const id = entry.id?.slice(0, 120) ?? "unknown";
122
- const text = textOf({ content: source.content }).trim();
123
- const summary =
124
- source.label === "owner answer via ask_question"
125
- ? ""
126
- : imageSummary(imagesOf(source.content));
127
- const body = [text, summary === "" ? "" : `${summary} — recover with history read id ${id}`]
128
- .filter((part) => part !== "")
129
- .join("\n");
130
- return {
131
- id,
132
- timestamp: entry.timestamp?.slice(0, 80) ?? "unknown time",
133
- kind: source.kind,
134
- label: source.label,
135
- text: body === "" ? "(non-text content; recover the entry from history)" : body,
136
- };
137
- }
138
- function formatRecord(record: RecoveryRecord, limit: number): string {
139
- return boundedBlock(
140
- `[${record.label} | ${record.timestamp} | entry ${record.id}]`,
141
- record.text,
142
- limit,
143
- );
144
- }
145
- function indexLine(record: RecoveryRecord): string {
146
- const header = formatRecord(record, 0);
147
- if (record.kind !== "owner") {
148
- return header;
149
- }
150
- const preview = record.text.replace(/\s+/g, " ").trim();
151
- return `${header} ${preview.length > 120 ? `${preview.slice(0, 119)}…` : preview}`;
152
- }
153
62
  function priorCheckpoint(entry: EntryLike | undefined, limit: number): string | undefined {
154
63
  const handoff = windowHandoff(entry)?.trim();
155
64
  if (entry === undefined || handoff === undefined || handoff === "") {
@@ -265,7 +174,7 @@ function recoveryAncestry(
265
174
  ownerQuestionRegistered: boolean,
266
175
  retained: ReadonlySet<string>,
267
176
  ): {
268
- readonly checkpoint?: EntryLike;
177
+ readonly checkpoint?: EntryLike | undefined;
269
178
  readonly carried: readonly RecoveryRecord[];
270
179
  readonly skipped: readonly string[];
271
180
  } {
@@ -296,13 +205,15 @@ function recoveryAncestry(
296
205
  return { checkpoint: boundary, carried: [], skipped };
297
206
  }
298
207
  function skippedRecords(ids: readonly string[], limit: number): string | undefined {
299
- if (ids.length === 0) {
208
+ const remaining = ids.values();
209
+ const first = remaining.next();
210
+ if (first.done === true) {
300
211
  return undefined;
301
212
  }
302
- let names = ids[0];
213
+ let names = first.value;
303
214
  let count = 1;
304
- while (count < ids.length) {
305
- const next = `, ${ids[count]}`;
215
+ for (const id of remaining) {
216
+ const next = `, ${id}`;
306
217
  if (names.length + next.length > limit) {
307
218
  break;
308
219
  }
@@ -335,7 +246,7 @@ function joined(parts: readonly (string | undefined)[]): string {
335
246
  }
336
247
  type BatchPlan = {
337
248
  readonly header?: string;
338
- readonly omission?: string;
249
+ readonly omission?: string | undefined;
339
250
  readonly blocks: readonly RecoveryBlock[];
340
251
  readonly bare?: string;
341
252
  };
@@ -359,8 +270,8 @@ function planBatch(
359
270
  }
360
271
  let budget = available;
361
272
  let first = batch.blocks.length;
362
- while (first > 0) {
363
- const length = batch.blocks[first - 1].header.length + 2;
273
+ for (const block of batch.blocks.toReversed()) {
274
+ const length = block.header.length + 2;
364
275
  if (length > budget) {
365
276
  break;
366
277
  }
@@ -417,8 +328,8 @@ function renderIndex(records: readonly RecoveryRecord[], available: number): str
417
328
  }
418
329
  let budget = available;
419
330
  const listed: string[] = [];
420
- for (let i = records.length - 1; i >= 0; i--) {
421
- const line = indexLine(records[i]);
331
+ for (const record of records.toReversed()) {
332
+ const line = indexLine(record);
422
333
  if (line.length + 1 > budget) {
423
334
  break;
424
335
  }
package/src/reminders.ts CHANGED
@@ -22,7 +22,7 @@ export type ReminderLookup = {
22
22
  };
23
23
  export type StaleReminderPredicate = (message: {
24
24
  readonly role: string;
25
- readonly customType?: string;
25
+ readonly customType?: string | undefined;
26
26
  readonly details?: unknown;
27
27
  }) => boolean;
28
28
 
@@ -53,8 +53,8 @@ function onlyReminders(entries: readonly EntryLike[]): EntryLike[] {
53
53
  );
54
54
  }
55
55
  type ScanProgress = {
56
- readonly boundary?: EntryLike;
57
- readonly window?: EntryLike;
56
+ readonly boundary?: EntryLike | undefined;
57
+ readonly window?: EntryLike | undefined;
58
58
  readonly kept: boolean;
59
59
  };
60
60
  function advanceBoundary(entry: EntryLike, progress: ScanProgress): ScanProgress {
package/src/retention.ts CHANGED
@@ -20,10 +20,10 @@ type RecoveryCapacity = {
20
20
  readonly host: PolicyContext;
21
21
  readonly toolTokens: number;
22
22
  readonly pendingMessages?: readonly MessageLike[];
23
- readonly firstKeptEntryId?: string;
23
+ readonly firstKeptEntryId?: string | undefined;
24
24
  };
25
25
  type RetainedTail = {
26
- readonly firstKeptEntryId?: string;
26
+ readonly firstKeptEntryId?: string | undefined;
27
27
  readonly ids: ReadonlySet<string>;
28
28
  };
29
29
  type Immutable<T> = T extends string | number | boolean | null | undefined
@@ -128,26 +128,29 @@ function boundedTail(
128
128
  start: number,
129
129
  maxTokens: number,
130
130
  ): RetainedTail {
131
- const costs = entries.map((entry) =>
132
- entry.sourceEntry.type === "compaction"
133
- ? 0
134
- : visibleMessages(entry).reduce(
135
- (sum, message) => sum + estimateTokens(nativeMessage(message)),
136
- 0,
137
- ),
138
- );
139
- let tokens = costs.slice(start).reduce((sum, cost) => sum + cost, 0);
140
- for (let index = start; index < entries.length; index++) {
141
- if (tokens > 0 && tokens <= maxTokens && safeStart(entries[index])) {
131
+ const costs = entries.slice(start).map((entry) => ({
132
+ entry,
133
+ cost:
134
+ entry.sourceEntry.type === "compaction"
135
+ ? 0
136
+ : visibleMessages(entry).reduce(
137
+ (sum, message) => sum + estimateTokens(nativeMessage(message)),
138
+ 0,
139
+ ),
140
+ }));
141
+ let tokens = costs.reduce((sum, { cost }) => sum + cost, 0);
142
+ for (const [offset, { entry, cost }] of costs.entries()) {
143
+ const index = start + offset;
144
+ if (tokens > 0 && tokens <= maxTokens && safeStart(entry)) {
142
145
  const tail = entries.slice(index);
143
146
  if (pairedTail(tail)) {
144
147
  return {
145
- firstKeptEntryId: entries[index].sourceEntry.id,
146
- ids: new Set(tail.map((entry) => entry.sourceEntry.id)),
148
+ firstKeptEntryId: entry.sourceEntry.id,
149
+ ids: new Set(tail.map((retained) => retained.sourceEntry.id)),
147
150
  };
148
151
  }
149
152
  }
150
- tokens -= costs[index];
153
+ tokens -= cost;
151
154
  }
152
155
  return { ids: new Set() };
153
156
  }
@@ -181,7 +184,7 @@ export function automaticRecovery(
181
184
  projected: readonly ProjectionEntry[],
182
185
  capacity: RecoveryCapacity,
183
186
  ownerQuestionRegistered: boolean,
184
- ): { readonly handoff: string; readonly firstKeptEntryId?: string } | undefined {
187
+ ): { readonly handoff: string; readonly firstKeptEntryId?: string | undefined } | undefined {
185
188
  const payload = freshPayloadChars(
186
189
  capacity.host,
187
190
  capacity.toolTokens,
package/src/rollover.ts CHANGED
@@ -1,26 +1,22 @@
1
1
  import type {
2
2
  BoundaryResult,
3
+ ContextEvent,
3
4
  ExtensionAPI,
4
5
  ExtensionContext,
5
6
  TurnEndEvent,
6
7
  } from "@earendil-works/pi-coding-agent";
7
- import {
8
- budgetFor,
9
- buildGuidance,
10
- CHECKPOINT_STEPS,
11
- freshPayloadChars,
12
- type Budget,
13
- } from "./budget.ts";
8
+ import { budgetFor, CHECKPOINT_STEPS, freshPayloadChars, type Budget } from "./budget.ts";
14
9
  import {
15
10
  isRecord,
16
11
  isReminderType,
17
12
  REMINDER_TYPE,
18
- type CompactionPolicy,
19
13
  type PolicyContext,
20
14
  type ProjectedEntry,
21
15
  type ReminderFingerprint,
16
+ type SnapshotReader,
22
17
  } from "./contracts.ts";
23
18
  import { registerContextTools } from "./context-tools.ts";
19
+ import { registerGuidance } from "./guidance.ts";
24
20
  import { safeJsonStringify } from "./message-text.ts";
25
21
  import { hasRetainedRolloverMarker } from "./recovery.ts";
26
22
  import { automaticRecovery } from "./retention.ts";
@@ -33,48 +29,7 @@ import {
33
29
  type StaleReminderPredicate,
34
30
  } from "./reminders.ts";
35
31
 
36
- export type SnapshotReader = (
37
- ctx: ExtensionContext,
38
- livePolicy?: CompactionPolicy,
39
- ) => PolicyContext;
40
32
  const REMINDER_BUFFER_TOKENS = 32_000;
41
- function registerGuidance(pi: ExtensionAPI, policy: SnapshotReader): void {
42
- let promptOptions: { readonly forceSystemPrompt?: string } | undefined;
43
- pi.on("before_agent_start", (event) => {
44
- promptOptions = event.systemPromptOptions;
45
- });
46
- pi.on("agent_settled", () => {
47
- // Prompt options belong to one run, never to later idle context evaluation.
48
- promptOptions = undefined;
49
- });
50
- pi.on("context_with_system", (event, ctx) => {
51
- const guidance = buildGuidance(policy(ctx));
52
- const system =
53
- event.messages.findLast(
54
- (message) =>
55
- message.role === "system" && typeof message.sections?.posthorse === "string",
56
- ) ?? event.messages.find((message) => message.role === "system");
57
- if (system === undefined || system.role !== "system") {
58
- return;
59
- }
60
- if (promptOptions?.forceSystemPrompt !== undefined) {
61
- // ponytail: Pi applies forced prompts after this hook; keep request-local guidance
62
- // until the host projects forced text before context_with_system.
63
- event.messages.splice(1, 0, {
64
- role: "custom",
65
- customType: "posthorse-guidance",
66
- content: guidance,
67
- display: false,
68
- timestamp: 0,
69
- });
70
- } else {
71
- system.sections = {
72
- ...system.sections,
73
- posthorse: `<posthorse>\n${guidance}\n</posthorse>`,
74
- };
75
- }
76
- });
77
- }
78
33
  function explicitReset(
79
34
  event: TurnEndEvent,
80
35
  ctx: ExtensionContext,
@@ -150,7 +105,7 @@ function sendReminder(
150
105
  readonly tokens: number;
151
106
  readonly pendingMessages: readonly {
152
107
  readonly role: string;
153
- readonly customType?: string;
108
+ readonly customType?: string | undefined;
154
109
  readonly details?: unknown;
155
110
  }[];
156
111
  },
@@ -188,7 +143,7 @@ const NO_STALE_REMINDER: StaleReminderPredicate = () => false;
188
143
  function staleReminderPredicate(
189
144
  messages: readonly {
190
145
  readonly role: string;
191
- readonly customType?: string;
146
+ readonly customType?: string | undefined;
192
147
  readonly details?: unknown;
193
148
  }[],
194
149
  ctx: ExtensionContext,
@@ -225,6 +180,40 @@ function staleReminderPredicate(
225
180
  isReminderType(message.customType) &&
226
181
  (!enabled || !reminderMatches(message.details, fingerprint));
227
182
  }
183
+ function chronologicalRetainedTail(
184
+ messages: ContextEvent["messages"],
185
+ ctx: ExtensionContext,
186
+ stale: StaleReminderPredicate,
187
+ ): ContextEvent["messages"] {
188
+ const first = messages[0];
189
+ if (
190
+ messages.length >= 2 &&
191
+ first?.role === "compactionSummary" &&
192
+ hasRetainedRolloverMarker(first.summary)
193
+ ) {
194
+ const count = countRetainedMessages(ctx, stale);
195
+ if (count > 0 && 1 + count <= messages.length) {
196
+ const retainedSlice = messages.slice(1, 1 + count);
197
+ const afterSlice = messages.slice(1 + count);
198
+ if (retainedSlice[0]?.role !== "assistant") {
199
+ return [...retainedSlice, first, ...afterSlice];
200
+ }
201
+ return [
202
+ {
203
+ role: "custom",
204
+ customType: "posthorse-retained-tail",
205
+ content: "[Retained pre-rollover tail]",
206
+ display: false,
207
+ timestamp: first.timestamp,
208
+ },
209
+ ...retainedSlice,
210
+ first,
211
+ ...afterSlice,
212
+ ];
213
+ }
214
+ }
215
+ return messages;
216
+ }
228
217
  function registerReminderFilter(
229
218
  pi: ExtensionAPI,
230
219
  policy: SnapshotReader,
@@ -235,37 +224,9 @@ function registerReminderFilter(
235
224
  const filtered = event.messages.some(stale)
236
225
  ? event.messages.filter((message) => !stale(message))
237
226
  : event.messages;
238
- const first = filtered[0];
239
- if (
240
- filtered.length >= 2 &&
241
- first.role === "compactionSummary" &&
242
- hasRetainedRolloverMarker(first.summary)
243
- ) {
244
- const count = countRetainedMessages(ctx, stale);
245
- if (count > 0 && 1 + count <= filtered.length) {
246
- const retainedSlice = filtered.slice(1, 1 + count);
247
- const afterSlice = filtered.slice(1 + count);
248
- if (retainedSlice[0].role !== "assistant") {
249
- return { messages: [...retainedSlice, first, ...afterSlice] };
250
- }
251
- return {
252
- messages: [
253
- {
254
- role: "custom",
255
- customType: "posthorse-retained-tail",
256
- content: "[Retained pre-rollover tail]",
257
- display: false,
258
- timestamp: first.timestamp,
259
- },
260
- ...retainedSlice,
261
- first,
262
- ...afterSlice,
263
- ],
264
- };
265
- }
266
- }
267
- if (filtered !== event.messages) {
268
- return { messages: filtered };
227
+ const ordered = chronologicalRetainedTail(filtered, ctx, stale);
228
+ if (ordered !== event.messages) {
229
+ return { messages: ordered };
269
230
  }
270
231
  return;
271
232
  });
@@ -277,7 +238,7 @@ function automaticDraft(
277
238
  readonly handoff: string;
278
239
  readonly tokensBefore: number;
279
240
  readonly reason: string;
280
- readonly firstKeptEntryId?: string;
241
+ readonly firstKeptEntryId?: string | undefined;
281
242
  },
282
243
  ) {
283
244
  let firstKeptEntryId = recovery.firstKeptEntryId;
@@ -56,7 +56,7 @@ const LABEL_SOURCES: readonly (readonly [label: string, needles: readonly string
56
56
  */
57
57
  export function prefilterNeedles(query: string): string[] | undefined {
58
58
  // A match contains every fragment between characters that JSON escapes or labels may add.
59
- const [fragment] = query
59
+ const [fragment = ""] = query
60
60
  .split(/["\\[\](){}:$\p{Cc}\p{Cs}]/u)
61
61
  .map((part) => part.trim())
62
62
  .sort((a, b) => b.length - a.length);
@@ -143,7 +143,6 @@ function ripgrepFilter(
143
143
  for (const binary of binaries) {
144
144
  try {
145
145
  // Try the host-managed binary first; PATH is a fallback only when it is unavailable.
146
- // oxlint-disable-next-line no-await-in-loop
147
146
  return (await execFileText(binary, [...flags, ...paths], dir, signal)).split("\0");
148
147
  } catch (error) {
149
148
  const code = isRecord(error) ? error.code : undefined;
@@ -162,7 +161,7 @@ function ripgrepFilter(
162
161
  return async (files) => {
163
162
  const paths = files.map((file) => relative(dir, file));
164
163
  const found = new Set((await Promise.all(commandGroups(paths).map(matching))).flat());
165
- return files.filter((_file, index) => found.has(paths[index]));
164
+ return files.filter((file) => found.has(relative(dir, file)));
166
165
  };
167
166
  }
168
167
  function messageFrom(value: unknown): MessageLike | undefined {
@@ -222,7 +221,7 @@ function entryHead(prefix: string): EntryLike | undefined {
222
221
  return {
223
222
  type: head[1],
224
223
  id: head[2],
225
- parentId: head[3] === "null" ? null : head[3].slice(1, -1),
224
+ parentId: head[3] === "null" ? null : head[3]?.slice(1, -1),
226
225
  };
227
226
  }
228
227
  /** Whether a caller may need a line; other lines only advance window ancestry. */
@@ -271,7 +270,6 @@ async function sessionFiles(dir: string): Promise<string[]> {
271
270
  let level = existsSync(dir) ? [dir] : [];
272
271
  while (level.length > 0) {
273
272
  // Each level lists the directories the previous level found.
274
- // oxlint-disable-next-line no-await-in-loop
275
273
  const listings = await Promise.all(
276
274
  level.map((path) => readdir(path, { withFileTypes: true })),
277
275
  );
@@ -293,16 +291,24 @@ async function sessionFiles(dir: string): Promise<string[]> {
293
291
  }
294
292
  type SessionFile = { readonly file: string; readonly size: number; readonly mtime: number };
295
293
  async function newestFirst(files: readonly string[]): Promise<SessionFile[]> {
296
- const stats = await Promise.all(files.map((file) => stat(file)));
297
- return files
298
- .map((file, index) => ({ file, size: stats[index].size, mtime: stats[index].mtimeMs }))
299
- .sort((a, b) => b.mtime - a.mtime);
294
+ const stats = await Promise.all(
295
+ files.map(async (file) => {
296
+ const info = await stat(file);
297
+ return { file, size: info.size, mtime: info.mtimeMs };
298
+ }),
299
+ );
300
+ return stats.sort((a, b) => b.mtime - a.mtime);
300
301
  }
301
302
  /** The newest files up to FIRST_SEARCH_BYTES, then the rest. */
302
303
  function searchPhases(files: readonly SessionFile[]): readonly (readonly string[])[] {
303
304
  let count = 0;
304
- for (let bytes = 0; count < files.length && bytes < FIRST_SEARCH_BYTES; count++) {
305
- bytes += files[count].size;
305
+ let bytes = 0;
306
+ for (const file of files) {
307
+ if (bytes >= FIRST_SEARCH_BYTES) {
308
+ break;
309
+ }
310
+ bytes += file.size;
311
+ count++;
306
312
  }
307
313
  const paths = files.map(({ file }) => file);
308
314
  return [paths.slice(0, count), paths.slice(count)];
@@ -310,7 +316,7 @@ function searchPhases(files: readonly SessionFile[]): readonly (readonly string[
310
316
  async function sessionHeader(
311
317
  file: string,
312
318
  signal?: AbortSignal,
313
- ): Promise<{ readonly cwd?: string } | undefined> {
319
+ ): Promise<{ readonly cwd?: string | undefined } | undefined> {
314
320
  for await (const line of lfLines(file, signal)) {
315
321
  try {
316
322
  const value: unknown = JSON.parse(line.toString("utf8"));
@@ -343,11 +349,11 @@ function parentSession(
343
349
  export type SessionScope = {
344
350
  readonly dir: string;
345
351
  readonly cwd: string;
346
- readonly currentFile?: string;
347
- readonly signal?: AbortSignal;
348
- readonly fileKey?: string;
352
+ readonly currentFile?: string | undefined;
353
+ readonly signal?: AbortSignal | undefined;
354
+ readonly fileKey?: string | undefined;
349
355
  /** Raw-text needles; files without any of them are skipped. */
350
- readonly needles?: readonly string[];
356
+ readonly needles?: readonly string[] | undefined;
351
357
  };
352
358
  /** Scope memoization is owned by one enumeration; nested runs inherit their parent's project. */
353
359
  function membership(
@@ -393,10 +399,8 @@ export async function* scopedSessionFiles(scope: SessionScope): AsyncGenerator<s
393
399
  : ripgrepFilter(scope.dir, scope.needles, scope.signal);
394
400
  for (const phase of searchPhases(selected)) {
395
401
  // Older files are searched only when the newest ones did not satisfy the caller.
396
- // oxlint-disable-next-line no-await-in-loop
397
402
  for (const file of prefilter === undefined ? phase : await prefilter(phase)) {
398
403
  // Preserve newest-first traversal while closing each header stream before opening another.
399
- // oxlint-disable-next-line no-await-in-loop
400
404
  if (await belongs(file)) {
401
405
  yield file;
402
406
  }
@@ -18,7 +18,7 @@ type LineRange = {
18
18
  readonly end: number;
19
19
  readonly head: string;
20
20
  readonly matches: boolean;
21
- readonly bytes?: Readonly<Pick<Buffer, "toString">>;
21
+ readonly bytes?: Readonly<Pick<Buffer, "toString">> | undefined;
22
22
  };
23
23
  async function* lineRanges(
24
24
  file: string,
@@ -73,7 +73,6 @@ async function rangeText(
73
73
  while (offset < buffer.length) {
74
74
  signal?.throwIfAborted();
75
75
  // Positioned reads may be short; each continues the same selected record.
76
- // oxlint-disable-next-line no-await-in-loop
77
76
  const { bytesRead } = await handle.read(
78
77
  buffer,
79
78
  offset,
package/src/ui/cards.ts CHANGED
@@ -61,11 +61,12 @@ function callDetails(name: ToolName, args: Readonly<Record<string, unknown>>): s
61
61
  name === "history" && args.op === "search"
62
62
  ? `Scope: ${args.all === true ? "all project sessions" : "current branch"}`
63
63
  : "";
64
- const offsets = [
64
+ const labels: readonly (readonly [string, string])[] = [
65
65
  ["offset", "Offset"],
66
66
  ["imageOffset", "Image offset"],
67
67
  ["limit", "Limit"],
68
- ].map(([key, label]) => {
68
+ ];
69
+ const offsets = labels.map(([key, label]) => {
69
70
  const value = args[key];
70
71
  return value === undefined
71
72
  ? ""
package/src/ui/display.ts CHANGED
@@ -21,7 +21,7 @@ export type PosthorseDisplay =
21
21
  readonly percent: number | null;
22
22
  };
23
23
  readonly rollover?: "enabled" | "disabled" | "unsupported";
24
- readonly rolloverAt?: number;
24
+ readonly rolloverAt?: number | undefined;
25
25
  }
26
26
  | {
27
27
  readonly kind: "notes-list" | "notes-search";
@@ -241,7 +241,7 @@ function historyReadSummary(
241
241
  ) {
242
242
  return pageSummary(display);
243
243
  }
244
- const summary = `${pageSummary(display).split("\n")[0]}\nImages ${n(display.imageOffset ?? 0)}–${n(display.imageEnd)} of ${n(display.imageTotal)}`;
244
+ const summary = `${pageSummary(display).split("\n", 1).join("")}\nImages ${n(display.imageOffset ?? 0)}–${n(display.imageEnd)} of ${n(display.imageTotal)}`;
245
245
  return display.end < display.total || display.imageEnd < display.imageTotal
246
246
  ? `Next offset ${n(display.end)}\nimageOffset ${n(display.imageEnd)}\n${summary}`
247
247
  : summary;
package/src/ui/text.ts CHANGED
@@ -5,7 +5,6 @@ export type OutputColor = "toolOutput" | "muted" | "dim" | "error" | "warning";
5
5
 
6
6
  export function clean(value: string): string {
7
7
  // Tool previews must not execute terminal controls or Unicode interlinear annotations.
8
- // oxlint-disable-next-line no-control-regex
9
8
  return stripTerminalSequences(value).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\ufff9-\ufffb]/g, "");
10
9
  }
11
10