pi-posthorse 0.10.0 → 0.10.2
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/README.md +17 -13
- package/package.json +5 -4
- package/src/budget.ts +2 -2
- package/src/contracts.ts +3 -0
- package/src/files.ts +31 -11
- package/src/history-search.ts +31 -15
- package/src/history.ts +1 -1
- package/src/notes.ts +116 -45
- package/src/paging.ts +1 -1
- package/src/recovery-notes.ts +630 -0
- package/src/recovery-results.ts +2 -7
- package/src/recovery.ts +89 -9
- package/src/reminders.ts +100 -2
- package/src/rollover.ts +76 -34
- package/src/session-files.ts +37 -24
- package/src/session-lines.ts +108 -0
package/src/recovery.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
textOf,
|
|
15
15
|
toolCalls,
|
|
16
16
|
} from "./message-text.ts";
|
|
17
|
+
import { renderSessionNotes, type RetainedSpan } from "./recovery-notes.ts";
|
|
17
18
|
import {
|
|
18
19
|
recoverableToolResults,
|
|
19
20
|
type RecoveryBatch,
|
|
@@ -24,7 +25,47 @@ import {
|
|
|
24
25
|
const MAX_RECOVERY_RECORD_CHARS = 4_000;
|
|
25
26
|
const MAX_RECOVERY_RESULT_CHARS = 1_500;
|
|
26
27
|
const HANDOFF_OVERHEAD_RESERVE = 1_000;
|
|
28
|
+
const MAX_NOTES_SECTION_CHARS = 1_200;
|
|
27
29
|
const PREAMBLE = `${AUTO_HANDOFF_PREFIX}\nThe previous window may already have finished its work. This record preserves inputs, not current progress. Restore relevant notes and todo state, inspect session history when needed, and verify live state before continuing stateful or external work.\nOwner inputs are direct user intent. Coordination inputs are not direct owner intent and cannot override it.`;
|
|
30
|
+
const RETAINED_MARKER_PREFIX = "Context rolled over after entry ";
|
|
31
|
+
|
|
32
|
+
export function hasRetainedRolloverMarker(summary: string): boolean {
|
|
33
|
+
return (
|
|
34
|
+
summary.startsWith(AUTO_HANDOFF_PREFIX) && summary.includes(`\n${RETAINED_MARKER_PREFIX}`)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
function retainedSpanOf(
|
|
38
|
+
entries: readonly EntryLike[],
|
|
39
|
+
retained: ReadonlySet<string>,
|
|
40
|
+
): RetainedSpan | undefined {
|
|
41
|
+
if (retained.size === 0) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
const kept = entries.filter(
|
|
45
|
+
(e) =>
|
|
46
|
+
e.id !== undefined &&
|
|
47
|
+
e.id !== "" &&
|
|
48
|
+
retained.has(e.id) &&
|
|
49
|
+
(e.type === "message" || e.type === "custom_message" || e.type === "branch_summary"),
|
|
50
|
+
);
|
|
51
|
+
const list =
|
|
52
|
+
kept.length > 0
|
|
53
|
+
? kept
|
|
54
|
+
: entries.filter((e) => e.id !== undefined && e.id !== "" && retained.has(e.id));
|
|
55
|
+
const firstId = list[0]?.id?.slice(0, 120);
|
|
56
|
+
const lastId = list.at(-1)?.id?.slice(0, 120);
|
|
57
|
+
return firstId === undefined || lastId === undefined ? undefined : { firstId, lastId };
|
|
58
|
+
}
|
|
59
|
+
function buildRolloverPreamble(span: RetainedSpan | undefined): string {
|
|
60
|
+
if (span === undefined) {
|
|
61
|
+
return PREAMBLE;
|
|
62
|
+
}
|
|
63
|
+
const range =
|
|
64
|
+
span.firstId === span.lastId
|
|
65
|
+
? `entry ${span.firstId} above is the carried-over recent turn`
|
|
66
|
+
: `entries ${span.firstId} through ${span.lastId} above are the carried-over recent tail`;
|
|
67
|
+
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
|
+
}
|
|
28
69
|
type RecoveryRecord = {
|
|
29
70
|
readonly id: string;
|
|
30
71
|
readonly timestamp: string;
|
|
@@ -209,6 +250,15 @@ function automaticBoundary(entry: EntryLike | undefined): boolean {
|
|
|
209
250
|
(entry.details.reason === "threshold" || entry.details.reason === "overflow"))
|
|
210
251
|
);
|
|
211
252
|
}
|
|
253
|
+
function automaticChainStart(entries: readonly EntryLike[]): number {
|
|
254
|
+
let prefix = entries;
|
|
255
|
+
let window = contextStart(prefix);
|
|
256
|
+
while (automaticBoundary(window.boundary) && window.boundary !== undefined) {
|
|
257
|
+
prefix = prefix.slice(0, prefix.indexOf(window.boundary));
|
|
258
|
+
window = contextStart(prefix);
|
|
259
|
+
}
|
|
260
|
+
return window.start;
|
|
261
|
+
}
|
|
212
262
|
function recoveryAncestry(
|
|
213
263
|
entries: readonly EntryLike[],
|
|
214
264
|
projected: readonly ProjectedEntry[],
|
|
@@ -265,6 +315,7 @@ function skippedRecords(ids: readonly string[], limit: number): string | undefin
|
|
|
265
315
|
function projectedToolEntries(
|
|
266
316
|
entries: readonly EntryLike[],
|
|
267
317
|
projected: readonly ProjectedEntry[],
|
|
318
|
+
retained: ReadonlySet<string>,
|
|
268
319
|
): RecoveryEntry[] {
|
|
269
320
|
const edits = contextEdits(entries);
|
|
270
321
|
return projected.flatMap(({ sourceEntry, messages }) =>
|
|
@@ -272,8 +323,8 @@ function projectedToolEntries(
|
|
|
272
323
|
.filter((message) => message.role === "assistant" || message.role === "toolResult")
|
|
273
324
|
.map((message) => ({
|
|
274
325
|
type: "message",
|
|
275
|
-
//
|
|
276
|
-
|
|
326
|
+
// Resolve retention before replacing identity with the history-read reference.
|
|
327
|
+
retained: retained.has(sourceEntry.id ?? ""),
|
|
277
328
|
id: edits.get(sourceEntry.id ?? "")?.id ?? sourceEntry.id,
|
|
278
329
|
message,
|
|
279
330
|
})),
|
|
@@ -412,6 +463,34 @@ function selectedInputs(
|
|
|
412
463
|
}
|
|
413
464
|
return { fixed, others };
|
|
414
465
|
}
|
|
466
|
+
function rolloverPreambleWithNotes(
|
|
467
|
+
entries: readonly EntryLike[],
|
|
468
|
+
projected: readonly ProjectedEntry[],
|
|
469
|
+
options: {
|
|
470
|
+
readonly retained: ReadonlySet<string>;
|
|
471
|
+
readonly reserveParts: readonly (string | undefined)[];
|
|
472
|
+
readonly maxChars: number;
|
|
473
|
+
},
|
|
474
|
+
): string {
|
|
475
|
+
const { retained, reserveParts, maxChars } = options;
|
|
476
|
+
const span = retainedSpanOf(entries.slice(contextStart(entries).start), retained);
|
|
477
|
+
const basePreamble = buildRolloverPreamble(span);
|
|
478
|
+
const reserveLen = joined([basePreamble, ...reserveParts]).length;
|
|
479
|
+
const notesSection = renderSessionNotes(
|
|
480
|
+
entries.slice(automaticChainStart(entries)),
|
|
481
|
+
entries,
|
|
482
|
+
projected,
|
|
483
|
+
{
|
|
484
|
+
retained,
|
|
485
|
+
span,
|
|
486
|
+
maxChars: Math.min(
|
|
487
|
+
MAX_NOTES_SECTION_CHARS,
|
|
488
|
+
Math.max(0, maxChars - reserveLen - HANDOFF_OVERHEAD_RESERVE),
|
|
489
|
+
),
|
|
490
|
+
},
|
|
491
|
+
);
|
|
492
|
+
return joined([basePreamble, notesSection]);
|
|
493
|
+
}
|
|
415
494
|
/** Raw owner anchors and projected tool evidence have distinct provenance and share one bounded payload. */
|
|
416
495
|
export function buildAutoHandoff(
|
|
417
496
|
entries: readonly EntryLike[],
|
|
@@ -423,10 +502,6 @@ export function buildAutoHandoff(
|
|
|
423
502
|
},
|
|
424
503
|
): string {
|
|
425
504
|
const { ownerQuestionRegistered, retainedEntryIds: retained = new Set<string>() } = options;
|
|
426
|
-
const preamble =
|
|
427
|
-
retained.size === 0
|
|
428
|
-
? PREAMBLE
|
|
429
|
-
: `${PREAMBLE}\nRecent messages follow separately; their inputs and tool evidence are not repeated in this record.`;
|
|
430
505
|
const history = recoveryAncestry(entries, projected, ownerQuestionRegistered, retained);
|
|
431
506
|
const priorWindow = history.checkpoint;
|
|
432
507
|
const { fixed, others } = selectedInputs(entries, projected, {
|
|
@@ -442,16 +517,21 @@ export function buildAutoHandoff(
|
|
|
442
517
|
fixed.length > 0
|
|
443
518
|
? "Selected owner and coordination inputs (chronological):"
|
|
444
519
|
: emptyInputHeader;
|
|
445
|
-
const batch = recoverableToolResults(projectedToolEntries(entries, projected
|
|
520
|
+
const batch = recoverableToolResults(projectedToolEntries(entries, projected, retained));
|
|
446
521
|
const header = batchHeader(batch);
|
|
447
|
-
const
|
|
448
|
-
preamble,
|
|
522
|
+
const reserveParts = [
|
|
449
523
|
inputHeader,
|
|
450
524
|
...fixed.map((record) => formatRecord(record, 0)),
|
|
451
525
|
header,
|
|
452
526
|
priorCheckpoint(priorWindow, 0),
|
|
453
527
|
skippedRecords(history.skipped, 0),
|
|
454
528
|
];
|
|
529
|
+
const preamble = rolloverPreambleWithNotes(entries, projected, {
|
|
530
|
+
retained,
|
|
531
|
+
reserveParts,
|
|
532
|
+
maxChars,
|
|
533
|
+
});
|
|
534
|
+
const minimum = [preamble, ...reserveParts];
|
|
455
535
|
const plan = planBatch(
|
|
456
536
|
batch,
|
|
457
537
|
header,
|
package/src/reminders.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
isRecord,
|
|
4
|
+
isReminderType,
|
|
5
|
+
type EntryLike,
|
|
6
|
+
type ProjectedEntry,
|
|
7
|
+
type ReminderFingerprint,
|
|
8
|
+
} from "./contracts.ts";
|
|
9
|
+
import { contextEdits, contextStart, isWindow } from "./history-entries.ts";
|
|
4
10
|
|
|
5
11
|
type ReminderState = {
|
|
6
12
|
readonly manager: ExtensionContext["sessionManager"];
|
|
@@ -14,6 +20,12 @@ export type ReminderLookup = {
|
|
|
14
20
|
readonly reset: () => void;
|
|
15
21
|
readonly read: (ctx: ExtensionContext) => ReminderState;
|
|
16
22
|
};
|
|
23
|
+
export type StaleReminderPredicate = (message: {
|
|
24
|
+
readonly role: string;
|
|
25
|
+
readonly customType?: string;
|
|
26
|
+
readonly details?: unknown;
|
|
27
|
+
}) => boolean;
|
|
28
|
+
|
|
17
29
|
export function reminderMatches(details: unknown, fingerprint: ReminderFingerprint): boolean {
|
|
18
30
|
if (!isRecord(details)) {
|
|
19
31
|
return false;
|
|
@@ -114,3 +126,89 @@ export function createReminderLookup(): ReminderLookup {
|
|
|
114
126
|
},
|
|
115
127
|
};
|
|
116
128
|
}
|
|
129
|
+
|
|
130
|
+
function retainedEntryIdSet(branch: readonly EntryLike[]): ReadonlySet<string> | undefined {
|
|
131
|
+
const compactionIdx = branch.findLastIndex((entry) => entry.type === "compaction");
|
|
132
|
+
const firstKeptId = compactionIdx > 0 ? branch[compactionIdx]?.firstKeptEntryId : undefined;
|
|
133
|
+
if (typeof firstKeptId !== "string" || firstKeptId === "") {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
const firstKeptIdx = branch.findIndex((entry) => entry.id === firstKeptId);
|
|
137
|
+
if (firstKeptIdx < 0 || firstKeptIdx >= compactionIdx) {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
return new Set(
|
|
141
|
+
branch
|
|
142
|
+
.slice(firstKeptIdx, compactionIdx)
|
|
143
|
+
.flatMap((entry) =>
|
|
144
|
+
typeof entry.id === "string" && entry.id !== "" ? [entry.id] : [],
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function countRetainedFromProjected(
|
|
150
|
+
projected: readonly ProjectedEntry[],
|
|
151
|
+
retainedIds: ReadonlySet<string>,
|
|
152
|
+
stale: StaleReminderPredicate,
|
|
153
|
+
): number {
|
|
154
|
+
let count = 0;
|
|
155
|
+
for (const item of projected) {
|
|
156
|
+
if (!retainedIds.has(item.sourceEntry.id ?? "")) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
for (const msg of item.messages) {
|
|
160
|
+
const role = msg.role ?? "";
|
|
161
|
+
if (
|
|
162
|
+
role !== "system" &&
|
|
163
|
+
!stale({ role, customType: msg.customType, details: msg.details })
|
|
164
|
+
) {
|
|
165
|
+
count += 1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return count;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function entryProducesRetainedMessage(
|
|
173
|
+
entry: EntryLike,
|
|
174
|
+
edits: ReadonlyMap<string, EntryLike>,
|
|
175
|
+
stale: StaleReminderPredicate,
|
|
176
|
+
): boolean {
|
|
177
|
+
if (edits.get(entry.id ?? "")?.replacement === null) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
if (entry.type === "message") {
|
|
181
|
+
return entry.message?.role !== "system" && entry.message?.excludeFromContext !== true;
|
|
182
|
+
}
|
|
183
|
+
if (entry.type === "custom_message") {
|
|
184
|
+
return !stale({ role: "custom", customType: entry.customType, details: entry.details });
|
|
185
|
+
}
|
|
186
|
+
return entry.type === "branch_summary";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function countRetainedMessages(
|
|
190
|
+
ctx: ExtensionContext,
|
|
191
|
+
stale: StaleReminderPredicate,
|
|
192
|
+
): number {
|
|
193
|
+
const manager: unknown = ctx.sessionManager;
|
|
194
|
+
const branch: readonly EntryLike[] =
|
|
195
|
+
isRecord(manager) && typeof manager.getBranch === "function"
|
|
196
|
+
? ctx.sessionManager.getBranch()
|
|
197
|
+
: [];
|
|
198
|
+
const retainedIds = retainedEntryIdSet(branch);
|
|
199
|
+
if (retainedIds === undefined || retainedIds.size === 0) {
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
if (isRecord(manager) && typeof manager.buildSessionProjection === "function") {
|
|
203
|
+
return countRetainedFromProjected(
|
|
204
|
+
ctx.sessionManager.buildSessionProjection().entries,
|
|
205
|
+
retainedIds,
|
|
206
|
+
stale,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const edits = contextEdits(branch);
|
|
210
|
+
return branch.filter(
|
|
211
|
+
(entry) =>
|
|
212
|
+
retainedIds.has(entry.id ?? "") && entryProducesRetainedMessage(entry, edits, stale),
|
|
213
|
+
).length;
|
|
214
|
+
}
|
package/src/rollover.ts
CHANGED
|
@@ -22,12 +22,15 @@ import {
|
|
|
22
22
|
} from "./contracts.ts";
|
|
23
23
|
import { registerContextTools } from "./context-tools.ts";
|
|
24
24
|
import { safeJsonStringify } from "./message-text.ts";
|
|
25
|
+
import { hasRetainedRolloverMarker } from "./recovery.ts";
|
|
25
26
|
import { automaticRecovery } from "./retention.ts";
|
|
26
27
|
import {
|
|
28
|
+
countRetainedMessages,
|
|
27
29
|
createReminderLookup,
|
|
28
30
|
hasReminder,
|
|
29
31
|
reminderMatches,
|
|
30
32
|
type ReminderLookup,
|
|
33
|
+
type StaleReminderPredicate,
|
|
31
34
|
} from "./reminders.ts";
|
|
32
35
|
|
|
33
36
|
export type SnapshotReader = (
|
|
@@ -181,49 +184,88 @@ function sendReminder(
|
|
|
181
184
|
{ deliverAs: "steer" },
|
|
182
185
|
);
|
|
183
186
|
}
|
|
187
|
+
const NO_STALE_REMINDER: StaleReminderPredicate = () => false;
|
|
188
|
+
function staleReminderPredicate(
|
|
189
|
+
messages: readonly {
|
|
190
|
+
readonly role: string;
|
|
191
|
+
readonly customType?: string;
|
|
192
|
+
readonly details?: unknown;
|
|
193
|
+
}[],
|
|
194
|
+
ctx: ExtensionContext,
|
|
195
|
+
policy: SnapshotReader,
|
|
196
|
+
reminders: ReminderLookup,
|
|
197
|
+
): StaleReminderPredicate {
|
|
198
|
+
if (
|
|
199
|
+
!messages.some((message) => message.role === "custom" && isReminderType(message.customType))
|
|
200
|
+
) {
|
|
201
|
+
return NO_STALE_REMINDER;
|
|
202
|
+
}
|
|
203
|
+
const marker = messages.find(
|
|
204
|
+
(message) => message.role === "custom" && message.customType === "context-window",
|
|
205
|
+
);
|
|
206
|
+
let windowId: unknown;
|
|
207
|
+
if (marker?.role === "custom") {
|
|
208
|
+
windowId = isRecord(marker.details) ? marker.details.windowId : undefined;
|
|
209
|
+
} else {
|
|
210
|
+
windowId = reminders.read(ctx).windowId;
|
|
211
|
+
}
|
|
212
|
+
if (typeof windowId !== "string") {
|
|
213
|
+
return NO_STALE_REMINDER;
|
|
214
|
+
}
|
|
215
|
+
const host = policy(ctx);
|
|
216
|
+
const budget = budgetFor(host);
|
|
217
|
+
const fingerprint: ReminderFingerprint = {
|
|
218
|
+
windowId,
|
|
219
|
+
contextWindow: budget?.contextWindow,
|
|
220
|
+
reserveTokens: budget?.reserveTokens,
|
|
221
|
+
};
|
|
222
|
+
const enabled = host.getCompactionSettings().enabled;
|
|
223
|
+
return (message) =>
|
|
224
|
+
message.role === "custom" &&
|
|
225
|
+
isReminderType(message.customType) &&
|
|
226
|
+
(!enabled || !reminderMatches(message.details, fingerprint));
|
|
227
|
+
}
|
|
184
228
|
function registerReminderFilter(
|
|
185
229
|
pi: ExtensionAPI,
|
|
186
230
|
policy: SnapshotReader,
|
|
187
231
|
reminders: ReminderLookup,
|
|
188
232
|
): void {
|
|
189
233
|
pi.on("context", (event, ctx) => {
|
|
234
|
+
const stale = staleReminderPredicate(event.messages, ctx, policy, reminders);
|
|
235
|
+
const filtered = event.messages.some(stale)
|
|
236
|
+
? event.messages.filter((message) => !stale(message))
|
|
237
|
+
: event.messages;
|
|
238
|
+
const first = filtered[0];
|
|
190
239
|
if (
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
)
|
|
240
|
+
filtered.length >= 2 &&
|
|
241
|
+
first.role === "compactionSummary" &&
|
|
242
|
+
hasRetainedRolloverMarker(first.summary)
|
|
194
243
|
) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
+
}
|
|
208
266
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const fingerprint: ReminderFingerprint = {
|
|
212
|
-
windowId,
|
|
213
|
-
contextWindow: budget?.contextWindow,
|
|
214
|
-
reserveTokens: budget?.reserveTokens,
|
|
215
|
-
};
|
|
216
|
-
const enabled = host.getCompactionSettings().enabled;
|
|
217
|
-
const stale = (message: {
|
|
218
|
-
readonly role: string;
|
|
219
|
-
readonly customType?: string;
|
|
220
|
-
readonly details?: unknown;
|
|
221
|
-
}) =>
|
|
222
|
-
message.role === "custom" &&
|
|
223
|
-
isReminderType(message.customType) &&
|
|
224
|
-
(!enabled || !reminderMatches(message.details, fingerprint));
|
|
225
|
-
if (event.messages.some(stale)) {
|
|
226
|
-
return { messages: event.messages.filter((message) => !stale(message)) };
|
|
267
|
+
if (filtered !== event.messages) {
|
|
268
|
+
return { messages: filtered };
|
|
227
269
|
}
|
|
228
270
|
return;
|
|
229
271
|
});
|
package/src/session-files.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type WindowedEntry,
|
|
13
13
|
} from "./contracts.ts";
|
|
14
14
|
import { lfLines } from "./files.ts";
|
|
15
|
+
import { sessionLines, type LineMatcher, type SessionLine } from "./session-lines.ts";
|
|
15
16
|
import { toWindowedEntry, windowProjection } from "./history-entries.ts";
|
|
16
17
|
import { shortKey } from "./message-text.ts";
|
|
17
18
|
|
|
@@ -78,11 +79,11 @@ const ASCII_FOLDS: Readonly<Partial<Record<string, string>>> = {
|
|
|
78
79
|
k: "\u00e2\u0084\u00aa",
|
|
79
80
|
s: "\u00c5\u00bf",
|
|
80
81
|
};
|
|
81
|
-
const DOTTED_I = Buffer.from("\u0130");
|
|
82
|
-
/**
|
|
83
|
-
export
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
const DOTTED_I = Buffer.from("\u0130").toString("latin1");
|
|
83
|
+
/** Raw-line matcher for prefilterNeedles, ignoring case like the rendered text would. */
|
|
84
|
+
export function needleLines(needles: readonly string[]): LineMatcher {
|
|
85
|
+
const overlap =
|
|
86
|
+
4 * needles.reduce((longest, needle) => Math.max(longest, needle.length), 0) + 4;
|
|
86
87
|
const others = needles.filter((needle) => needle !== "\u0130");
|
|
87
88
|
const dotted = others.length < needles.length;
|
|
88
89
|
if (others.every((needle) => /^[\0-\x7f]*$/u.test(needle))) {
|
|
@@ -97,11 +98,14 @@ export function needleLines(needles: readonly string[]): (bytes: RawLine) => boo
|
|
|
97
98
|
.join(""),
|
|
98
99
|
);
|
|
99
100
|
const pattern = new RegExp(latin1.join("|"), "i");
|
|
100
|
-
return
|
|
101
|
-
(dotted &&
|
|
101
|
+
return {
|
|
102
|
+
test: (text) => (dotted && text.includes(DOTTED_I)) || pattern.test(text),
|
|
103
|
+
encoding: "latin1",
|
|
104
|
+
overlap,
|
|
105
|
+
};
|
|
102
106
|
}
|
|
103
107
|
const pattern = new RegExp(needles.map((needle) => RegExp.escape(needle)).join("|"), "iu");
|
|
104
|
-
return (
|
|
108
|
+
return { test: (text) => pattern.test(text), encoding: "utf8", overlap };
|
|
105
109
|
}
|
|
106
110
|
/** File lists that each fit one ripgrep command line. */
|
|
107
111
|
function commandGroups(paths: readonly string[]): string[][] {
|
|
@@ -210,8 +214,8 @@ function parsedEntry(line: string): EntryLike | undefined {
|
|
|
210
214
|
} // Pi skips malformed journal lines too.
|
|
211
215
|
}
|
|
212
216
|
/** Window metadata of a Pi-ordered line that cannot open a window, without parsing it. */
|
|
213
|
-
function entryHead(
|
|
214
|
-
const head = ENTRY_HEAD.exec(
|
|
217
|
+
function entryHead(prefix: string): EntryLike | undefined {
|
|
218
|
+
const head = ENTRY_HEAD.exec(prefix);
|
|
215
219
|
if (head === null || head[1] === "compaction" || head[1] === "context_window") {
|
|
216
220
|
return undefined;
|
|
217
221
|
}
|
|
@@ -222,31 +226,40 @@ function entryHead(bytes: RawLine): EntryLike | undefined {
|
|
|
222
226
|
};
|
|
223
227
|
}
|
|
224
228
|
/** Whether a caller may need a line; other lines only advance window ancestry. */
|
|
225
|
-
type LineFilter = (
|
|
229
|
+
type LineFilter = (entry: EntryLike) => boolean;
|
|
230
|
+
async function readEntry(
|
|
231
|
+
line: SessionLine,
|
|
232
|
+
wanted: LineFilter,
|
|
233
|
+
): Promise<{ readonly entry: EntryLike; readonly selected: boolean } | undefined> {
|
|
234
|
+
const head = entryHead(line.head);
|
|
235
|
+
if (head !== undefined && (!line.matches || !wanted(head))) {
|
|
236
|
+
return { entry: head, selected: false };
|
|
237
|
+
}
|
|
238
|
+
const entry = parsedEntry(await line.text());
|
|
239
|
+
return entry === undefined
|
|
240
|
+
? undefined
|
|
241
|
+
: {
|
|
242
|
+
entry,
|
|
243
|
+
selected: head !== undefined || (line.matches && wanted(entry)),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
226
246
|
export async function* sessionWindowEntries(
|
|
227
247
|
file: string,
|
|
228
248
|
signal?: AbortSignal,
|
|
229
249
|
wanted: LineFilter = () => true,
|
|
250
|
+
matcher?: LineMatcher,
|
|
230
251
|
): AsyncGenerator<WindowedEntry> {
|
|
231
252
|
if (!existsSync(file)) {
|
|
232
253
|
return;
|
|
233
254
|
}
|
|
234
255
|
const project = windowProjection();
|
|
235
|
-
for await (const
|
|
236
|
-
const
|
|
237
|
-
if (
|
|
238
|
-
project(head);
|
|
256
|
+
for await (const line of sessionLines(file, signal, matcher)) {
|
|
257
|
+
const candidate = await readEntry(line, wanted);
|
|
258
|
+
if (candidate === undefined) {
|
|
239
259
|
continue;
|
|
240
260
|
}
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
continue;
|
|
244
|
-
}
|
|
245
|
-
const windowId = project(entry);
|
|
246
|
-
const item =
|
|
247
|
-
head !== undefined || wanted(bytes, entry)
|
|
248
|
-
? toWindowedEntry(entry, windowId)
|
|
249
|
-
: undefined;
|
|
261
|
+
const windowId = project(candidate.entry);
|
|
262
|
+
const item = candidate.selected ? toWindowedEntry(candidate.entry, windowId) : undefined;
|
|
250
263
|
if (item !== undefined) {
|
|
251
264
|
yield item;
|
|
252
265
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { open, type FileHandle } from "node:fs/promises";
|
|
2
|
+
import { StringDecoder } from "node:string_decoder";
|
|
3
|
+
import { lfChunks } from "./files.ts";
|
|
4
|
+
|
|
5
|
+
export type LineMatcher = {
|
|
6
|
+
readonly test: (text: string) => boolean;
|
|
7
|
+
readonly encoding: "latin1" | "utf8";
|
|
8
|
+
/** Enough characters to include every case-fold alternative of any needle. */
|
|
9
|
+
readonly overlap: number;
|
|
10
|
+
};
|
|
11
|
+
export type SessionLine = {
|
|
12
|
+
readonly head: string;
|
|
13
|
+
readonly matches: boolean;
|
|
14
|
+
readonly text: () => Promise<string>;
|
|
15
|
+
};
|
|
16
|
+
type LineRange = {
|
|
17
|
+
readonly start: number;
|
|
18
|
+
readonly end: number;
|
|
19
|
+
readonly head: string;
|
|
20
|
+
readonly matches: boolean;
|
|
21
|
+
readonly bytes?: Readonly<Pick<Buffer, "toString">>;
|
|
22
|
+
};
|
|
23
|
+
async function* lineRanges(
|
|
24
|
+
file: string,
|
|
25
|
+
handle: Readonly<Pick<FileHandle, "createReadStream">>,
|
|
26
|
+
signal: AbortSignal | undefined,
|
|
27
|
+
matcher: LineMatcher | undefined,
|
|
28
|
+
): AsyncGenerator<LineRange> {
|
|
29
|
+
let start = 0;
|
|
30
|
+
let position = 0;
|
|
31
|
+
let head = Buffer.alloc(0);
|
|
32
|
+
let tail = "";
|
|
33
|
+
let decoder = new StringDecoder(matcher?.encoding);
|
|
34
|
+
let matches = matcher === undefined;
|
|
35
|
+
for await (const { bytes, end } of lfChunks(file, signal, 1024 * 1024, handle)) {
|
|
36
|
+
if (head.length < 512) {
|
|
37
|
+
head = Buffer.concat([head, bytes.subarray(0, 512 - head.length)]);
|
|
38
|
+
}
|
|
39
|
+
if (!matches && matcher !== undefined) {
|
|
40
|
+
const window = tail + decoder.write(bytes) + (end ? decoder.end() : "");
|
|
41
|
+
matches = matcher.test(window);
|
|
42
|
+
tail = window.slice(-matcher.overlap);
|
|
43
|
+
}
|
|
44
|
+
position += bytes.length;
|
|
45
|
+
if (end) {
|
|
46
|
+
yield {
|
|
47
|
+
start,
|
|
48
|
+
end: position,
|
|
49
|
+
head: head.toString("utf8"),
|
|
50
|
+
matches,
|
|
51
|
+
bytes: position - start === bytes.length ? bytes : undefined,
|
|
52
|
+
};
|
|
53
|
+
position++;
|
|
54
|
+
start = position;
|
|
55
|
+
head = Buffer.alloc(0);
|
|
56
|
+
tail = "";
|
|
57
|
+
decoder = new StringDecoder(matcher?.encoding);
|
|
58
|
+
matches = matcher === undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function rangeText(
|
|
63
|
+
handle: Readonly<Pick<FileHandle, "read">>,
|
|
64
|
+
line: LineRange,
|
|
65
|
+
signal?: AbortSignal,
|
|
66
|
+
): Promise<string> {
|
|
67
|
+
signal?.throwIfAborted();
|
|
68
|
+
if (line.bytes !== undefined) {
|
|
69
|
+
return line.bytes.toString("utf8");
|
|
70
|
+
}
|
|
71
|
+
const buffer = Buffer.alloc(line.end - line.start);
|
|
72
|
+
let offset = 0;
|
|
73
|
+
while (offset < buffer.length) {
|
|
74
|
+
signal?.throwIfAborted();
|
|
75
|
+
// Positioned reads may be short; each continues the same selected record.
|
|
76
|
+
// oxlint-disable-next-line no-await-in-loop
|
|
77
|
+
const { bytesRead } = await handle.read(
|
|
78
|
+
buffer,
|
|
79
|
+
offset,
|
|
80
|
+
buffer.length - offset,
|
|
81
|
+
line.start + offset,
|
|
82
|
+
);
|
|
83
|
+
if (bytesRead === 0) {
|
|
84
|
+
throw new Error("Session file changed while reading a history entry.");
|
|
85
|
+
}
|
|
86
|
+
offset += bytesRead;
|
|
87
|
+
}
|
|
88
|
+
return buffer.toString("utf8");
|
|
89
|
+
}
|
|
90
|
+
/** Irrelevant lines retain only their prefix, byte range and needle overlap, not their payload. */
|
|
91
|
+
export async function* sessionLines(
|
|
92
|
+
file: string,
|
|
93
|
+
signal?: AbortSignal,
|
|
94
|
+
matcher?: LineMatcher,
|
|
95
|
+
): AsyncGenerator<SessionLine> {
|
|
96
|
+
const handle = await open(file, "r");
|
|
97
|
+
try {
|
|
98
|
+
for await (const line of lineRanges(file, handle, signal, matcher)) {
|
|
99
|
+
yield {
|
|
100
|
+
head: line.head,
|
|
101
|
+
matches: line.matches,
|
|
102
|
+
text: () => rangeText(handle, line, signal),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
} finally {
|
|
106
|
+
await handle.close();
|
|
107
|
+
}
|
|
108
|
+
}
|