pi-posthorse 0.9.3 → 0.10.1
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 +33 -26
- package/index.ts +1 -1
- package/package.json +10 -9
- package/src/budget.ts +5 -5
- package/src/context-tools.ts +3 -3
- package/src/contracts.ts +1 -0
- package/src/files.ts +87 -25
- 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-results.ts +5 -2
- package/src/recovery.ts +59 -21
- package/src/retention.ts +203 -0
- package/src/rollover.ts +29 -20
- package/src/session-files.ts +37 -24
- package/src/session-lines.ts +108 -0
package/src/rollover.ts
CHANGED
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
import {
|
|
15
15
|
isRecord,
|
|
16
16
|
isReminderType,
|
|
17
|
-
MIN_PAGE_CHARS,
|
|
18
17
|
REMINDER_TYPE,
|
|
19
18
|
type CompactionPolicy,
|
|
20
19
|
type PolicyContext,
|
|
@@ -23,7 +22,7 @@ import {
|
|
|
23
22
|
} from "./contracts.ts";
|
|
24
23
|
import { registerContextTools } from "./context-tools.ts";
|
|
25
24
|
import { safeJsonStringify } from "./message-text.ts";
|
|
26
|
-
import {
|
|
25
|
+
import { automaticRecovery } from "./retention.ts";
|
|
27
26
|
import {
|
|
28
27
|
createReminderLookup,
|
|
29
28
|
hasReminder,
|
|
@@ -42,7 +41,7 @@ function registerGuidance(pi: ExtensionAPI, policy: SnapshotReader): void {
|
|
|
42
41
|
promptOptions = event.systemPromptOptions;
|
|
43
42
|
});
|
|
44
43
|
pi.on("agent_settled", () => {
|
|
45
|
-
//
|
|
44
|
+
// Prompt options belong to one run, never to later idle context evaluation.
|
|
46
45
|
promptOptions = undefined;
|
|
47
46
|
});
|
|
48
47
|
pi.on("context_with_system", (event, ctx) => {
|
|
@@ -175,7 +174,7 @@ function sendReminder(
|
|
|
175
174
|
pi.sendMessage(
|
|
176
175
|
{
|
|
177
176
|
customType: REMINDER_TYPE,
|
|
178
|
-
content: `[posthorse]
|
|
177
|
+
content: `[posthorse] Notes checkpoint: ${(budget.rolloverAt - tokens).toLocaleString("en-US")} tokens remain before Pi's automatic rollover line. Please ${CHECKPOINT_STEPS}.`,
|
|
179
178
|
display: true,
|
|
180
179
|
details: fingerprint,
|
|
181
180
|
},
|
|
@@ -232,12 +231,20 @@ function registerReminderFilter(
|
|
|
232
231
|
function automaticDraft(
|
|
233
232
|
pi: ExtensionAPI,
|
|
234
233
|
ctx: ExtensionContext,
|
|
235
|
-
recovery: {
|
|
234
|
+
recovery: {
|
|
235
|
+
readonly handoff: string;
|
|
236
|
+
readonly tokensBefore: number;
|
|
237
|
+
readonly reason: string;
|
|
238
|
+
readonly firstKeptEntryId?: string;
|
|
239
|
+
},
|
|
236
240
|
) {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
+
let firstKeptEntryId = recovery.firstKeptEntryId;
|
|
242
|
+
if (firstKeptEntryId === undefined) {
|
|
243
|
+
// Public compaction requires an entry id even when no prior conversation safely fits.
|
|
244
|
+
pi.appendEntry("posthorse-boundary", {});
|
|
245
|
+
firstKeptEntryId = ctx.sessionManager.getLeafId() ?? undefined;
|
|
246
|
+
}
|
|
247
|
+
if (firstKeptEntryId === undefined || firstKeptEntryId === "") {
|
|
241
248
|
throw new Error("Pi did not persist the context boundary.");
|
|
242
249
|
}
|
|
243
250
|
return {
|
|
@@ -316,17 +323,16 @@ function thresholdFallback(
|
|
|
316
323
|
return undefined;
|
|
317
324
|
}
|
|
318
325
|
const projected = event.context.contextEntries;
|
|
319
|
-
const limit = freshPayloadChars(host, toolTokens(), event.context.pendingMessages);
|
|
320
326
|
const ownerQuestionRegistered = pi
|
|
321
327
|
.getAllTools()
|
|
322
328
|
.some((tool) => tool.name === "ask_question" && tool.namespace === undefined);
|
|
323
|
-
const
|
|
329
|
+
const recovery = automaticRecovery(
|
|
324
330
|
ctx.sessionManager.getBranch(),
|
|
325
331
|
projected,
|
|
326
|
-
|
|
332
|
+
{ host, toolTokens: toolTokens(), pendingMessages: event.context.pendingMessages },
|
|
327
333
|
ownerQuestionRegistered,
|
|
328
334
|
);
|
|
329
|
-
if (
|
|
335
|
+
if (recovery === undefined) {
|
|
330
336
|
return undefined;
|
|
331
337
|
}
|
|
332
338
|
// ponytail: turn_end drafts are eager. Later projection editors must replace/remove this draft;
|
|
@@ -336,8 +342,8 @@ function thresholdFallback(
|
|
|
336
342
|
...event.entries,
|
|
337
343
|
{
|
|
338
344
|
type: "compaction",
|
|
339
|
-
summary,
|
|
340
|
-
firstKeptEntryId: null,
|
|
345
|
+
summary: recovery.handoff,
|
|
346
|
+
firstKeptEntryId: recovery.firstKeptEntryId ?? null,
|
|
341
347
|
details: { posthorse: 1, reason: "threshold" },
|
|
342
348
|
},
|
|
343
349
|
],
|
|
@@ -362,17 +368,20 @@ function registerAutomaticRollover(
|
|
|
362
368
|
if (budgetFor(host)?.supported !== true) {
|
|
363
369
|
return;
|
|
364
370
|
}
|
|
365
|
-
const limit = freshPayloadChars(host, activeToolTokens());
|
|
366
371
|
const ownerQuestionRegistered = pi
|
|
367
372
|
.getAllTools()
|
|
368
373
|
.some((tool) => tool.name === "ask_question" && tool.namespace === undefined);
|
|
369
|
-
const
|
|
374
|
+
const recovery = automaticRecovery(
|
|
370
375
|
event.branchEntries,
|
|
371
376
|
ctx.sessionManager.buildSessionProjection().entries,
|
|
372
|
-
|
|
377
|
+
{
|
|
378
|
+
host,
|
|
379
|
+
toolTokens: activeToolTokens(),
|
|
380
|
+
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
|
381
|
+
},
|
|
373
382
|
ownerQuestionRegistered,
|
|
374
383
|
);
|
|
375
|
-
if (
|
|
384
|
+
if (recovery === undefined) {
|
|
376
385
|
throw new Error(
|
|
377
386
|
"Too little fresh context capacity for an automatic recovery record.",
|
|
378
387
|
);
|
|
@@ -381,7 +390,7 @@ function registerAutomaticRollover(
|
|
|
381
390
|
return { cancel: true };
|
|
382
391
|
}
|
|
383
392
|
return automaticDraft(pi, ctx, {
|
|
384
|
-
|
|
393
|
+
...recovery,
|
|
385
394
|
tokensBefore: event.preparation.tokensBefore,
|
|
386
395
|
reason: event.reason,
|
|
387
396
|
});
|
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
|
+
}
|