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/package.json +19 -16
- package/src/context-tools.ts +2 -2
- package/src/contracts.ts +36 -32
- package/src/files.ts +0 -5
- package/src/guidance.ts +41 -0
- package/src/history-entries.ts +16 -16
- package/src/history-pages.ts +1 -1
- package/src/history-search.ts +6 -6
- package/src/history.ts +5 -2
- package/src/notes.ts +1 -4
- package/src/paging.ts +2 -2
- package/src/recovery-note-touches.ts +436 -0
- package/src/recovery-notes.ts +10 -440
- package/src/recovery-records.ts +90 -0
- package/src/recovery-results.ts +7 -6
- package/src/recovery.ts +19 -108
- package/src/reminders.ts +3 -3
- package/src/retention.ts +20 -17
- package/src/rollover.ts +44 -83
- package/src/session-files.ts +22 -18
- package/src/session-lines.ts +1 -2
- package/src/ui/cards.ts +3 -2
- package/src/ui/display.ts +2 -2
- package/src/ui/text.ts +0 -1
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
208
|
+
const remaining = ids.values();
|
|
209
|
+
const first = remaining.next();
|
|
210
|
+
if (first.done === true) {
|
|
300
211
|
return undefined;
|
|
301
212
|
}
|
|
302
|
-
let names =
|
|
213
|
+
let names = first.value;
|
|
303
214
|
let count = 1;
|
|
304
|
-
|
|
305
|
-
const next = `, ${
|
|
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
|
-
|
|
363
|
-
const length =
|
|
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 (
|
|
421
|
-
const line = indexLine(
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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:
|
|
146
|
-
ids: new Set(tail.map((
|
|
148
|
+
firstKeptEntryId: entry.sourceEntry.id,
|
|
149
|
+
ids: new Set(tail.map((retained) => retained.sourceEntry.id)),
|
|
147
150
|
};
|
|
148
151
|
}
|
|
149
152
|
}
|
|
150
|
-
tokens -=
|
|
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
|
|
239
|
-
if (
|
|
240
|
-
|
|
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;
|
package/src/session-files.ts
CHANGED
|
@@ -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((
|
|
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]
|
|
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(
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
}
|
package/src/session-lines.ts
CHANGED
|
@@ -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
|
|
64
|
+
const labels: readonly (readonly [string, string])[] = [
|
|
65
65
|
["offset", "Offset"],
|
|
66
66
|
["imageOffset", "Image offset"],
|
|
67
67
|
["limit", "Limit"],
|
|
68
|
-
]
|
|
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")
|
|
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
|
|