pi-posthorse 0.6.0 → 0.8.0
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 +39 -37
- package/index.ts +397 -314
- package/package.json +6 -6
- package/ui.ts +8 -7
package/index.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Posthorse — fresh context, same journey.
|
|
3
3
|
*
|
|
4
|
-
* No-summary context rollover for Pi
|
|
5
|
-
*
|
|
4
|
+
* No-summary context rollover for Pi: a retain-none public compaction whose summary is
|
|
5
|
+
* the handoff, on official Pi and the maintained fork.
|
|
6
6
|
* Pi owns the persisted boundary. Posthorse owns the policy: sparse budget reminders, rollover
|
|
7
7
|
* tools, durable notes, and history recovery.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
10
11
|
import { createHash, randomUUID } from "node:crypto";
|
|
11
12
|
import {
|
|
12
13
|
appendFileSync,
|
|
@@ -20,15 +21,18 @@ import {
|
|
|
20
21
|
} from "node:fs";
|
|
21
22
|
import { access, constants, lstat, open, readlink, realpath, rename, stat, unlink } from "node:fs/promises";
|
|
22
23
|
import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
23
|
-
import {
|
|
24
|
+
import { promisify } from "node:util";
|
|
25
|
+
import { estimateTokens, formatSize, getAgentDir, SettingsManager, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
24
26
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
25
27
|
import { Type } from "typebox";
|
|
26
28
|
import { registerPosthorseMessages, toolCards, type PosthorseDisplay } from "./ui.ts";
|
|
27
29
|
|
|
28
30
|
const REMINDER_BUFFER_TOKENS = 32_000;
|
|
29
|
-
/** Absolute
|
|
31
|
+
/** Absolute ceilings; handoffs and pages shrink to the live remaining budget. */
|
|
30
32
|
const MAX_HANDOFF_CHARS = 20_000;
|
|
33
|
+
const MAX_PAGE_CHARS = 40_000;
|
|
31
34
|
const MAX_RECOVERY_RECORD_CHARS = 4_000;
|
|
35
|
+
const MAX_RECOVERY_RESULT_CHARS = 1_500;
|
|
32
36
|
const HANDOFF_OVERHEAD_RESERVE = 1_000;
|
|
33
37
|
const PAGE_MARGIN_TOKENS = 1_000;
|
|
34
38
|
const MIN_PAGE_CHARS = 1_000;
|
|
@@ -42,31 +46,13 @@ const EMPTY_HANDOFF = "Fresh context. Restore relevant notes and history before
|
|
|
42
46
|
type CompactionPolicy = { enabled: boolean; reserveTokens: number };
|
|
43
47
|
type ContextUsage = { tokens: number | null; contextWindow: number; percent: number | null };
|
|
44
48
|
|
|
45
|
-
/**
|
|
49
|
+
/** Settings come from the injected policy; the compaction hook provides Pi's live policy. */
|
|
46
50
|
type PolicyContext = {
|
|
47
|
-
model?: { contextWindow: number };
|
|
48
51
|
getCompactionSettings(): CompactionPolicy;
|
|
49
52
|
getContextUsage(): ContextUsage | undefined;
|
|
50
53
|
getSystemPrompt(): string;
|
|
51
54
|
};
|
|
52
55
|
|
|
53
|
-
/** Fork-only extension API; the published Pi types do not declare it. */
|
|
54
|
-
type NativeExtensionAPI = {
|
|
55
|
-
registerContextWindowHook(
|
|
56
|
-
handler: (
|
|
57
|
-
event: { contextEntries: ProjectedEntry[]; pendingMessages: MessageLike[] },
|
|
58
|
-
ctx: ExtensionContext,
|
|
59
|
-
) => ReceiptEdit[] | undefined,
|
|
60
|
-
): void;
|
|
61
|
-
on(
|
|
62
|
-
event: "session_before_auto_compact",
|
|
63
|
-
handler: (
|
|
64
|
-
event: { reason: "overflow" | "threshold"; branchEntries: EntryLike[]; pendingMessages?: MessageLike[]; retainedToolResultIds: string[] },
|
|
65
|
-
ctx: unknown,
|
|
66
|
-
) => { newContext: { handoff?: string } } | undefined,
|
|
67
|
-
): void;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
56
|
type ImageLike = { type: "image"; data: string; mimeType: string };
|
|
71
57
|
type ToolCallLike = {
|
|
72
58
|
type?: string;
|
|
@@ -82,6 +68,7 @@ type MessageLike = {
|
|
|
82
68
|
stopReason?: string;
|
|
83
69
|
errorMessage?: string;
|
|
84
70
|
content?: unknown;
|
|
71
|
+
summary?: string;
|
|
85
72
|
toolName?: string;
|
|
86
73
|
namespace?: string;
|
|
87
74
|
toolCallId?: string;
|
|
@@ -109,7 +96,6 @@ type EntryLike = {
|
|
|
109
96
|
|
|
110
97
|
type WindowedEntry = { entry: EntryLike; windowId: string; text: string; images: ImageLike[] };
|
|
111
98
|
type ProjectedEntry = { sourceEntry: EntryLike; messages: MessageLike[] };
|
|
112
|
-
type ReceiptEdit = { type: "context_edit"; targetId: string; replacement: { content: string } };
|
|
113
99
|
type HistoryHit = { id: string; text: string; headerLength: number; priority: 0 | 1 };
|
|
114
100
|
type RecoveryRecord = {
|
|
115
101
|
id: string;
|
|
@@ -119,6 +105,7 @@ type RecoveryRecord = {
|
|
|
119
105
|
text: string;
|
|
120
106
|
};
|
|
121
107
|
type ReminderFingerprint = { windowId: string; contextWindow?: number; reserveTokens?: number };
|
|
108
|
+
type NoteFile = { path: string; size: number; mtimeMs: number };
|
|
122
109
|
type Budget = {
|
|
123
110
|
contextWindow: number;
|
|
124
111
|
reserveTokens: number;
|
|
@@ -128,7 +115,7 @@ type Budget = {
|
|
|
128
115
|
supported: boolean;
|
|
129
116
|
};
|
|
130
117
|
|
|
131
|
-
/** A fork context window, or the retain-none compaction Posthorse commits on
|
|
118
|
+
/** A legacy fork context window, or the retain-none compaction Posthorse commits on both hosts. */
|
|
132
119
|
function isWindow(entry: EntryLike): boolean {
|
|
133
120
|
return entry.type === "context_window" || (entry.type === "compaction" && (entry.details as { posthorse?: unknown } | undefined)?.posthorse === 1);
|
|
134
121
|
}
|
|
@@ -349,8 +336,22 @@ function isRecoveryCall(part: unknown): boolean {
|
|
|
349
336
|
return block?.type === "toolCall" && isRecoveryTool(block.name);
|
|
350
337
|
}
|
|
351
338
|
|
|
352
|
-
|
|
353
|
-
|
|
339
|
+
/** Ten base64url characters of SHA-256; 0.6 file keys were the whole digest, which starts with these. */
|
|
340
|
+
function shortKey(text: string): string {
|
|
341
|
+
return createHash("sha256").update(text).digest("base64url").slice(0, 10);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Nested session files are subagent runs, whose user-role input came from a parent agent. */
|
|
345
|
+
function sourceTag(source: string): string {
|
|
346
|
+
return source.includes(sep) ? " [subagent]" : "";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function contextEdits(entries: readonly EntryLike[]): Map<string, EntryLike> {
|
|
350
|
+
const edits = new Map<string, EntryLike>();
|
|
351
|
+
for (const entry of entries) {
|
|
352
|
+
if (entry.type === "context_edit" && entry.targetId) edits.set(entry.targetId, entry);
|
|
353
|
+
}
|
|
354
|
+
return edits;
|
|
354
355
|
}
|
|
355
356
|
|
|
356
357
|
function historyHit(item: WindowedEntry, query: string, source = ""): HistoryHit | undefined {
|
|
@@ -391,8 +392,8 @@ function historyHit(item: WindowedEntry, query: string, source = ""): HistoryHit
|
|
|
391
392
|
matchIndex = text.toLowerCase().indexOf(query);
|
|
392
393
|
}
|
|
393
394
|
}
|
|
394
|
-
const id = source ? `${entry.id}@${
|
|
395
|
-
const header = `${
|
|
395
|
+
const id = source ? `${entry.id}@${shortKey(source)}` : entry.id!;
|
|
396
|
+
const header = `${entry.timestamp ?? ""}${sourceTag(source)} [window ${item.windowId}] [${id}] `;
|
|
396
397
|
return {
|
|
397
398
|
id,
|
|
398
399
|
priority,
|
|
@@ -419,8 +420,8 @@ function* windowEntries(entries: Iterable<EntryLike>): Generator<WindowedEntry>
|
|
|
419
420
|
}
|
|
420
421
|
}
|
|
421
422
|
|
|
422
|
-
/**
|
|
423
|
-
async function*
|
|
423
|
+
/** Split on LF only: JSONL strings and notes can contain Unicode separators that Node's readline also splits on. */
|
|
424
|
+
async function* lfLines(file: string, signal?: AbortSignal): AsyncGenerator<string> {
|
|
424
425
|
const stream = createReadStream(file, { encoding: "utf8", signal });
|
|
425
426
|
let pending: string[] = [];
|
|
426
427
|
for await (const chunk of stream) {
|
|
@@ -436,10 +437,56 @@ async function* jsonlLines(file: string, signal?: AbortSignal): AsyncGenerator<s
|
|
|
436
437
|
if (last) yield last;
|
|
437
438
|
}
|
|
438
439
|
|
|
440
|
+
/** Git's heuristic: a NUL byte in the first 8,000 bytes marks binary content. */
|
|
441
|
+
async function isBinaryFile(path: string): Promise<boolean> {
|
|
442
|
+
const file = await open(path, "r");
|
|
443
|
+
try {
|
|
444
|
+
const { buffer, bytesRead } = await file.read(Buffer.alloc(8_000), 0, 8_000, 0);
|
|
445
|
+
return buffer.subarray(0, bytesRead).includes(0);
|
|
446
|
+
} finally {
|
|
447
|
+
await file.close();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const execFileAsync = promisify(execFile);
|
|
452
|
+
/**
|
|
453
|
+
* ripgrep needles whose raw JSONL matches cover every normalized match, or undefined when only a full scan is exact.
|
|
454
|
+
* Raw JSONL escapes quotes, backslashes, and control characters; flattenEntry adds brackets, parentheses, braces,
|
|
455
|
+
* colons, `$`, spaces beside those labels, and label-only phrases. Image summaries ("3 images: image/png,
|
|
456
|
+
* image/jpeg") come only from entries with an image block, so their text needs that block instead.
|
|
457
|
+
*/
|
|
458
|
+
function prefilterNeedles(query: string): string[] | undefined {
|
|
459
|
+
if (/["\\[\](){}:$\p{Cc}]/u.test(query) || query.trim() !== query) return undefined;
|
|
460
|
+
if (["call id", "execution arguments", "no handoff", "excluded from model context by pi"].some((phrase) => phrase.includes(query))) return undefined;
|
|
461
|
+
const image = query.includes(",") || /^\d+ ?i?m?a?g?e?s?$/.test(query) || ["images", "unknown type"].some((phrase) => phrase.includes(query));
|
|
462
|
+
return image ? [query, '"type":"image"'] : [query];
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Session files whose raw JSONL contains any needle in any case, via Pi's managed ripgrep or PATH; undefined means scan every file. */
|
|
466
|
+
async function filesContaining(dir: string, needles: string[], signal?: AbortSignal): Promise<Set<string> | undefined> {
|
|
467
|
+
const args = [
|
|
468
|
+
"--files-with-matches", "--fixed-strings", "--ignore-case", "--no-ignore", "--hidden", "--no-messages",
|
|
469
|
+
"--glob", "*.jsonl", ...needles.flatMap((needle) => ["--regexp", needle]), dir,
|
|
470
|
+
];
|
|
471
|
+
for (const rg of [join(getAgentDir(), "bin", "rg"), "rg"]) {
|
|
472
|
+
try {
|
|
473
|
+
const { stdout } = await execFileAsync(rg, args, { signal, maxBuffer: 64 * 1024 * 1024 });
|
|
474
|
+
return new Set(stdout.split("\n").filter(Boolean).map((file) => resolve(file)));
|
|
475
|
+
} catch (error) {
|
|
476
|
+
const { code } = error as { code?: unknown };
|
|
477
|
+
if (code === 1) return new Set();
|
|
478
|
+
if (code === "ENOENT") continue;
|
|
479
|
+
signal?.throwIfAborted();
|
|
480
|
+
return undefined;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
|
|
439
486
|
async function* sessionWindowEntries(file: string, signal?: AbortSignal): AsyncGenerator<WindowedEntry> {
|
|
440
487
|
if (!existsSync(file)) return;
|
|
441
488
|
const windows = new Map<string, string>();
|
|
442
|
-
for await (const line of
|
|
489
|
+
for await (const line of lfLines(file, signal)) {
|
|
443
490
|
let entry: EntryLike;
|
|
444
491
|
try {
|
|
445
492
|
entry = JSON.parse(line) as EntryLike;
|
|
@@ -454,18 +501,15 @@ async function* sessionWindowEntries(file: string, signal?: AbortSignal): AsyncG
|
|
|
454
501
|
|
|
455
502
|
function sessionFiles(dir: string): string[] {
|
|
456
503
|
if (!existsSync(dir)) return [];
|
|
457
|
-
|
|
504
|
+
return readdirSync(dir, { recursive: true, withFileTypes: true })
|
|
458
505
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl") && !entry.name.includes(".intent."))
|
|
459
506
|
.map((entry) => join(entry.parentPath, entry.name));
|
|
460
|
-
if (files.length < 2) return files;
|
|
461
|
-
return files
|
|
462
|
-
.map((file) => ({ file, mtime: statSync(file).mtimeMs }))
|
|
463
|
-
.sort((a, b) => b.mtime - a.mtime)
|
|
464
|
-
.map(({ file }) => file);
|
|
465
507
|
}
|
|
466
508
|
|
|
509
|
+
/** Project session files, newest-modified first, optionally limited to one file key or to prefiltered candidates. */
|
|
467
510
|
async function* scopedSessionFiles(
|
|
468
|
-
dir: string, cwd: string, currentFile: string | undefined, signal?: AbortSignal,
|
|
511
|
+
dir: string, cwd: string, currentFile: string | undefined, signal?: AbortSignal,
|
|
512
|
+
{ fileKey, candidates }: { fileKey?: string; candidates?: Set<string> } = {},
|
|
469
513
|
): AsyncGenerator<string> {
|
|
470
514
|
const files = sessionFiles(dir);
|
|
471
515
|
const fileSet = new Set(files);
|
|
@@ -474,7 +518,7 @@ async function* scopedSessionFiles(
|
|
|
474
518
|
const known = visible.get(file);
|
|
475
519
|
if (known !== undefined) return known;
|
|
476
520
|
let header: { cwd?: string } | null = null;
|
|
477
|
-
for await (const line of
|
|
521
|
+
for await (const line of lfLines(file, signal)) {
|
|
478
522
|
try {
|
|
479
523
|
const entry = JSON.parse(line) as { type?: string; id?: unknown; cwd?: unknown } | null;
|
|
480
524
|
if (!entry) continue;
|
|
@@ -501,20 +545,16 @@ async function* scopedSessionFiles(
|
|
|
501
545
|
visible.set(file, allowed);
|
|
502
546
|
return allowed;
|
|
503
547
|
};
|
|
504
|
-
|
|
505
|
-
|
|
548
|
+
const selected = files
|
|
549
|
+
.filter((file) => (!candidates || candidates.has(resolve(file))) &&
|
|
550
|
+
(fileKey === undefined || fileKey.startsWith(shortKey(relative(dir, file)))))
|
|
551
|
+
.map((file) => ({ file, mtime: statSync(file).mtimeMs }))
|
|
552
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
553
|
+
for (const { file } of selected) {
|
|
506
554
|
if (await belongs(file)) yield file;
|
|
507
555
|
}
|
|
508
556
|
}
|
|
509
557
|
|
|
510
|
-
function currentWindowId(entries: readonly EntryLike[]): string {
|
|
511
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
512
|
-
const entry = entries[i];
|
|
513
|
-
if (isWindow(entry) && entry.id) return entry.id;
|
|
514
|
-
}
|
|
515
|
-
return "initial";
|
|
516
|
-
}
|
|
517
|
-
|
|
518
558
|
function excerpt(text: string, limit: number): string {
|
|
519
559
|
if (text.length <= limit) return text;
|
|
520
560
|
const marker = "\n… middle omitted …\n";
|
|
@@ -528,7 +568,7 @@ function boundedBlock(header: string, text: string, limit: number): string {
|
|
|
528
568
|
return textLimit ? `${header}\n${excerpt(text, textLimit)}` : header;
|
|
529
569
|
}
|
|
530
570
|
|
|
531
|
-
function recoveryRecord(entry: EntryLike): RecoveryRecord | undefined {
|
|
571
|
+
function recoveryRecord(entry: EntryLike, ownerQuestion: boolean): RecoveryRecord | undefined {
|
|
532
572
|
let kind: RecoveryRecord["kind"];
|
|
533
573
|
let label: string;
|
|
534
574
|
let text: string;
|
|
@@ -543,6 +583,7 @@ function recoveryRecord(entry: EntryLike): RecoveryRecord | undefined {
|
|
|
543
583
|
entry.message?.role === "toolResult" &&
|
|
544
584
|
entry.message.toolName === "ask_question" &&
|
|
545
585
|
entry.message.namespace === undefined &&
|
|
586
|
+
ownerQuestion &&
|
|
546
587
|
entry.message.isError !== true
|
|
547
588
|
) {
|
|
548
589
|
kind = "owner";
|
|
@@ -571,6 +612,14 @@ function formatRecoveryRecord(record: RecoveryRecord, limit: number): string {
|
|
|
571
612
|
return boundedBlock(`[${record.label} | ${record.timestamp} | entry ${record.id}]`, record.text, limit);
|
|
572
613
|
}
|
|
573
614
|
|
|
615
|
+
/** Owner inputs keep a one-line preview: they are direct intent, and most are short. */
|
|
616
|
+
function indexLine(record: RecoveryRecord): string {
|
|
617
|
+
const header = formatRecoveryRecord(record, 0);
|
|
618
|
+
if (record.kind !== "owner") return header;
|
|
619
|
+
const preview = record.text.replace(/\s+/g, " ").trim();
|
|
620
|
+
return `${header} ${preview.length > 120 ? `${preview.slice(0, 119)}…` : preview}`;
|
|
621
|
+
}
|
|
622
|
+
|
|
574
623
|
function formatPriorCheckpoint(entry: EntryLike | undefined, limit: number): string | undefined {
|
|
575
624
|
const handoff = windowHandoff(entry)?.trim();
|
|
576
625
|
if (!entry || !handoff) return undefined;
|
|
@@ -642,13 +691,10 @@ function recoverableToolResults(
|
|
|
642
691
|
return { callId: allLinked ? (call?.id ?? "unknown").slice(0, 120) : undefined, blocks };
|
|
643
692
|
}
|
|
644
693
|
|
|
645
|
-
function buildAutoHandoff(entries: readonly EntryLike[], projected: readonly ProjectedEntry[], maxChars: number): string {
|
|
694
|
+
function buildAutoHandoff(entries: readonly EntryLike[], projected: readonly ProjectedEntry[], maxChars: number, ownerQuestionRegistered: boolean): string {
|
|
646
695
|
const { start, boundary: priorWindow } = contextStart(entries);
|
|
647
696
|
const current = entries.slice(start);
|
|
648
|
-
const edits =
|
|
649
|
-
for (const entry of entries) {
|
|
650
|
-
if (entry.type === "context_edit" && entry.targetId) edits.set(entry.targetId, entry);
|
|
651
|
-
}
|
|
697
|
+
const edits = contextEdits(entries);
|
|
652
698
|
const omittedProjectedIds = new Set(projected.filter(({ messages }) => !messages.length).map(({ sourceEntry }) => sourceEntry.id));
|
|
653
699
|
const edited = current.flatMap((entry) => {
|
|
654
700
|
if (entry.id && omittedProjectedIds.has(entry.id)) return [];
|
|
@@ -659,8 +705,15 @@ function buildAutoHandoff(entries: readonly EntryLike[], projected: readonly Pro
|
|
|
659
705
|
if (entry.type === "custom_message") return [{ ...entry, id: edit.id, content: edit.replacement.content }];
|
|
660
706
|
return [entry];
|
|
661
707
|
});
|
|
708
|
+
const projectedCalls = new Map(projected.flatMap(({ messages }) => messages.flatMap((message) =>
|
|
709
|
+
message.role === "assistant" && Array.isArray(message.content)
|
|
710
|
+
? (message.content as ToolCallLike[]).filter((call) => call.type === "toolCall").map((call) => [call.id, call] as const) : [])));
|
|
662
711
|
const records = edited
|
|
663
|
-
.map((entry) =>
|
|
712
|
+
.map((entry) => {
|
|
713
|
+
// Providers can omit call namespaces; both registration and projected call must prove ownership.
|
|
714
|
+
const call = projectedCalls.get(entry.message?.toolCallId);
|
|
715
|
+
return recoveryRecord(entry, ownerQuestionRegistered && call?.name === "ask_question" && call.namespace === undefined);
|
|
716
|
+
})
|
|
664
717
|
.filter((record): record is RecoveryRecord => record !== undefined);
|
|
665
718
|
const firstOwnerRequest = records.find((record) => record.label === "owner input") ?? records[0];
|
|
666
719
|
const latestOwner = [...records].reverse().find((record) => record.kind === "owner");
|
|
@@ -721,16 +774,10 @@ function buildAutoHandoff(entries: readonly EntryLike[], projected: readonly Pro
|
|
|
721
774
|
? Math.min(MAX_RECOVERY_RECORD_CHARS, Math.floor(fixedBudget / fixedCount))
|
|
722
775
|
: MAX_RECOVERY_RECORD_CHARS;
|
|
723
776
|
const prior = formatPriorCheckpoint(priorWindow, fixedLimit);
|
|
724
|
-
const
|
|
725
|
-
records.map((record) => [
|
|
726
|
-
record,
|
|
727
|
-
formatRecoveryRecord(record, selected.has(record) ? fixedLimit : MAX_RECOVERY_RECORD_CHARS),
|
|
728
|
-
]),
|
|
729
|
-
);
|
|
730
|
-
const fixedParts = () => [
|
|
777
|
+
const fixedParts = [
|
|
731
778
|
preamble,
|
|
732
779
|
currentHeader,
|
|
733
|
-
...records.filter((record) => selected.has(record)).map((record) =>
|
|
780
|
+
...records.filter((record) => selected.has(record)).map((record) => formatRecoveryRecord(record, fixedLimit)),
|
|
734
781
|
];
|
|
735
782
|
|
|
736
783
|
let batch: string | undefined;
|
|
@@ -738,11 +785,11 @@ function buildAutoHandoff(entries: readonly EntryLike[], projected: readonly Pro
|
|
|
738
785
|
const availableText = Math.max(
|
|
739
786
|
0,
|
|
740
787
|
maxChars -
|
|
741
|
-
joinedLength([...fixedParts
|
|
788
|
+
joinedLength([...fixedParts, bareBatch, prior]) -
|
|
742
789
|
HANDOFF_OVERHEAD_RESERVE -
|
|
743
790
|
batchBlocks.length,
|
|
744
791
|
);
|
|
745
|
-
const perBlock = Math.min(
|
|
792
|
+
const perBlock = Math.min(MAX_RECOVERY_RESULT_CHARS, Math.floor(availableText / batchBlocks.length));
|
|
746
793
|
batch = [
|
|
747
794
|
batchHeader,
|
|
748
795
|
batchOmission,
|
|
@@ -752,21 +799,22 @@ function buildAutoHandoff(entries: readonly EntryLike[], projected: readonly Pro
|
|
|
752
799
|
.join("\n\n");
|
|
753
800
|
}
|
|
754
801
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
802
|
+
// Other inputs are indexed, newest kept first when space runs out; history read recovers their full text.
|
|
803
|
+
const others = records.filter((record) => !selected.has(record));
|
|
804
|
+
const indexHeader = "Other current-window inputs, oldest first (recover with history read):";
|
|
805
|
+
let indexBudget = Math.max(0, maxChars - joinedLength([...fixedParts, indexHeader, batch, prior]) - HANDOFF_OVERHEAD_RESERVE);
|
|
806
|
+
const listed: string[] = [];
|
|
807
|
+
for (let i = others.length - 1; i >= 0; i--) {
|
|
808
|
+
const line = indexLine(others[i]);
|
|
809
|
+
if (line.length + 1 > indexBudget) break;
|
|
810
|
+
listed.unshift(line);
|
|
811
|
+
indexBudget -= line.length + 1;
|
|
763
812
|
}
|
|
764
|
-
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
? `Omitted ${omitted.length} current-window input(s) to stay within the handoff limit (${omitted.filter((record) => record.kind === "owner").length} owner, ${omitted.filter((record) => record.kind === "coordination").length} coordination; ${omitted[0].timestamp} through ${omitted.at(-1)!.timestamp}). Use history search/read to recover them.`
|
|
813
|
+
const unlisted = others.length - listed.length;
|
|
814
|
+
const index = others.length
|
|
815
|
+
? [indexHeader, ...(unlisted ? [`${unlisted} earlier input(s) not listed; use history search to find them.`] : []), ...listed].join("\n")
|
|
768
816
|
: undefined;
|
|
769
|
-
return [...fixedParts
|
|
817
|
+
return [...fixedParts, index, batch, prior].filter((part): part is string => Boolean(part)).join("\n\n");
|
|
770
818
|
}
|
|
771
819
|
|
|
772
820
|
function reminderMatches(details: unknown, fingerprint: ReminderFingerprint): boolean {
|
|
@@ -784,7 +832,7 @@ function hasReminder(entries: readonly EntryLike[], fingerprint: ReminderFingerp
|
|
|
784
832
|
);
|
|
785
833
|
}
|
|
786
834
|
|
|
787
|
-
function budgetFor(ctx: PolicyContext, contextWindow = ctx.
|
|
835
|
+
function budgetFor(ctx: PolicyContext, contextWindow = ctx.getContextUsage()?.contextWindow): Budget | undefined {
|
|
788
836
|
if (!contextWindow || contextWindow <= 0) return undefined;
|
|
789
837
|
const { enabled, reserveTokens } = ctx.getCompactionSettings();
|
|
790
838
|
const usable = contextWindow - reserveTokens;
|
|
@@ -792,92 +840,24 @@ function budgetFor(ctx: PolicyContext, contextWindow = ctx.model?.contextWindow)
|
|
|
792
840
|
}
|
|
793
841
|
|
|
794
842
|
/** Half the fresh capacity after prompt/tool/input overhead remains for continued work. */
|
|
795
|
-
function freshPayloadChars(ctx: PolicyContext, toolTokens: number, pendingMessages: readonly MessageLike[] = []): number {
|
|
796
|
-
const
|
|
797
|
-
if (!
|
|
798
|
-
const
|
|
799
|
-
const
|
|
800
|
-
const promptTokens = Math.ceil(ctx.getSystemPrompt().length / 4);
|
|
843
|
+
function freshPayloadChars(ctx: PolicyContext, toolTokens: number, pendingMessages: readonly MessageLike[] = [], cap = MAX_HANDOFF_CHARS): number {
|
|
844
|
+
const budget = budgetFor(ctx);
|
|
845
|
+
if (!budget) return cap;
|
|
846
|
+
const line = budget.enabled && budget.supported ? budget.rolloverAt : budget.contextWindow;
|
|
847
|
+
const promptTokens = Math.ceil(ctx.getSystemPrompt().length / 4) + guidanceTokens(ctx);
|
|
801
848
|
const pendingTokens = pendingMessages.reduce(
|
|
802
849
|
(total, message) => total + Math.ceil(textOf(message).length / 4) + imagesOf(message.content).length * (ESTIMATED_IMAGE_CHARS / 4),
|
|
803
850
|
0,
|
|
804
851
|
);
|
|
805
852
|
return Math.min(
|
|
806
|
-
|
|
853
|
+
cap,
|
|
807
854
|
Math.max(0, Math.floor((line - PAGE_MARGIN_TOKENS - promptTokens - toolTokens - pendingTokens) / 2)) * 4,
|
|
808
855
|
);
|
|
809
856
|
}
|
|
810
857
|
|
|
811
|
-
/**
|
|
812
|
-
function boundWindowReceipts(
|
|
813
|
-
ctx: ExtensionContext & PolicyContext,
|
|
814
|
-
projected: readonly ProjectedEntry[],
|
|
815
|
-
pending: readonly MessageLike[],
|
|
816
|
-
toolTokens: number,
|
|
817
|
-
): ReceiptEdit[] | undefined {
|
|
818
|
-
if (!ctx.model) return undefined;
|
|
819
|
-
const estimate = estimateTokens as unknown as (message: MessageLike) => number;
|
|
820
|
-
const messages = projected.flatMap((entry) => entry.messages);
|
|
821
|
-
const budget = budgetFor(ctx);
|
|
822
|
-
const reserve = budget?.enabled && budget.supported ? budget.reserveTokens : 0;
|
|
823
|
-
const overhead =
|
|
824
|
-
reserve +
|
|
825
|
-
PAGE_MARGIN_TOKENS +
|
|
826
|
-
Math.max(
|
|
827
|
-
Math.ceil(ctx.getSystemPrompt().length / 4) + toolTokens,
|
|
828
|
-
messages.filter((message) => message.role === "system").reduce((sum, message) => sum + estimate(message), 0),
|
|
829
|
-
) +
|
|
830
|
-
pending.reduce((sum, message) => sum + estimate(message), 0);
|
|
831
|
-
const fixed = messages.filter((message) => message.role !== "system" && message.role !== "toolResult");
|
|
832
|
-
const available = ctx.model.contextWindow - overhead - fixed.reduce((sum, message) => sum + estimate(message), 0);
|
|
833
|
-
const edits = new Map<string, EntryLike>();
|
|
834
|
-
for (const entry of ctx.sessionManager.getBranch() as EntryLike[]) {
|
|
835
|
-
if (entry.type === "context_edit" && entry.targetId) edits.set(entry.targetId, entry);
|
|
836
|
-
}
|
|
837
|
-
const receipts = projected.flatMap(({ sourceEntry, messages }) =>
|
|
838
|
-
messages.filter((message) => message.role === "toolResult").map((message) => {
|
|
839
|
-
if (!sourceEntry.id) throw new Error("Posthorse: retained receipt has no history entry");
|
|
840
|
-
const recoveryId = edits.get(sourceEntry.id)?.id ?? sourceEntry.id;
|
|
841
|
-
const images = imageSummary(imagesOf(message.content));
|
|
842
|
-
const header = `Retained ${message.isError ? "error" : "result"} excerpt. Full content: history read id ${recoveryId}.${images ? `\n${images}` : ""}\n`;
|
|
843
|
-
const cost = estimate(message);
|
|
844
|
-
const minimum = Math.min(cost, Math.ceil(header.length / 4));
|
|
845
|
-
return { id: sourceEntry.id, message, header, cost, minimum };
|
|
846
|
-
}),
|
|
847
|
-
);
|
|
848
|
-
if (receipts.reduce((sum, receipt) => sum + receipt.cost, 0) <= available) return undefined;
|
|
849
|
-
let extra = available - receipts.reduce((sum, receipt) => sum + receipt.minimum, 0);
|
|
850
|
-
if (extra < 0) throw new Error("Posthorse: fresh context cannot fit fixed input and receipt recovery references");
|
|
851
|
-
// Like handoffs, oversized receipt excerpts leave half the remaining capacity for continued work.
|
|
852
|
-
extra = Math.floor(extra / 2);
|
|
853
|
-
const drafts: ReceiptEdit[] = [];
|
|
854
|
-
receipts.sort((a, b) => a.cost - a.minimum - (b.cost - b.minimum));
|
|
855
|
-
for (const [index, receipt] of receipts.entries()) {
|
|
856
|
-
const allowance = receipt.minimum + Math.min(receipt.cost - receipt.minimum, Math.floor(extra / (receipts.length - index)));
|
|
857
|
-
extra -= allowance - receipt.minimum;
|
|
858
|
-
if (receipt.cost <= allowance) continue;
|
|
859
|
-
const content = receipt.header + excerpt(textOf(receipt.message), allowance * 4 - receipt.header.length);
|
|
860
|
-
drafts.push({ type: "context_edit", targetId: receipt.id, replacement: { content } });
|
|
861
|
-
}
|
|
862
|
-
const replacements = new Map(drafts.map((draft) => [draft.targetId, draft.replacement.content]));
|
|
863
|
-
const total = projected.reduce(
|
|
864
|
-
(sum, entry) =>
|
|
865
|
-
sum + entry.messages.reduce(
|
|
866
|
-
(tokens, message) => tokens + (message.role === "system" ? 0 : estimate(
|
|
867
|
-
replacements.has(entry.sourceEntry.id ?? "")
|
|
868
|
-
? { ...message, content: replacements.get(entry.sourceEntry.id!) }
|
|
869
|
-
: message,
|
|
870
|
-
)),
|
|
871
|
-
0,
|
|
872
|
-
),
|
|
873
|
-
overhead,
|
|
874
|
-
);
|
|
875
|
-
if (total > ctx.model.contextWindow) throw new Error("Posthorse: retained receipts exceed fresh context capacity");
|
|
876
|
-
return drafts;
|
|
877
|
-
}
|
|
878
|
-
|
|
879
|
-
/** Official Pi cannot expose its live settings to extensions; say so wherever the persisted snapshot is used. */
|
|
858
|
+
/** Official Pi cannot expose its live settings to extensions; budget reports say when they used the persisted snapshot. */
|
|
880
859
|
const SNAPSHOT_NOTE = "Reminder and budget policy uses available settings (a persisted CLI snapshot by default); Pi's live settings control automatic compaction.";
|
|
860
|
+
const CHECKPOINT_STEPS = "update the task's current-state note (goal, progress, decisions, next steps) with available file-editing tools at its absolute path; use notes write only for creation, substantial restructuring, or when editing tools are unavailable. If the note is already current, leave it unchanged. Then call new_context";
|
|
881
861
|
|
|
882
862
|
function unsupportedMessage(budget: Budget): string {
|
|
883
863
|
const n = (value: number) => value.toLocaleString("en-US");
|
|
@@ -887,33 +867,38 @@ function unsupportedMessage(budget: Budget): string {
|
|
|
887
867
|
/** Capacity before Pi's automatic line (or configured context limit), including page metadata and refusals. */
|
|
888
868
|
function pageCapacity(ctx: PolicyContext, toolTokens: number): number {
|
|
889
869
|
const usage = ctx.getContextUsage();
|
|
890
|
-
if (!usage || usage.tokens == null) return freshPayloadChars(ctx, toolTokens) / 4 + PAGE_MARGIN_TOKENS;
|
|
870
|
+
if (!usage || usage.tokens == null) return freshPayloadChars(ctx, toolTokens, [], MAX_PAGE_CHARS) / 4 + PAGE_MARGIN_TOKENS;
|
|
891
871
|
const budget = budgetFor(ctx, usage.contextWindow);
|
|
892
872
|
const line = budget?.enabled && budget.supported ? budget.rolloverAt : usage.contextWindow;
|
|
893
|
-
return line - usage.tokens;
|
|
873
|
+
return line - usage.tokens - guidanceTokens(ctx);
|
|
894
874
|
}
|
|
895
875
|
|
|
896
|
-
function buildGuidance(ctx: PolicyContext
|
|
876
|
+
function buildGuidance(ctx: PolicyContext): string {
|
|
897
877
|
const budget = budgetFor(ctx);
|
|
898
878
|
const enabled = budget?.enabled ?? ctx.getCompactionSettings().enabled;
|
|
879
|
+
const n = (value: number) => value.toLocaleString("en-US");
|
|
880
|
+
const capacity = budget
|
|
881
|
+
? `Configured context capacity: ${n(budget.contextWindow)} tokens. This is Pi's best available native limit, not remaining space or a measured provider boundary. Fresh windows use the active configuration; system instructions, tools, the handoff, and new messages consume part of it.`
|
|
882
|
+
: "Configured context capacity is unknown.";
|
|
899
883
|
let automatic: string;
|
|
900
884
|
if (!enabled) {
|
|
901
|
-
automatic =
|
|
902
|
-
? "Pi compaction is disabled in the available settings, so Posthorse sends no checkpoint reminder. new_context remains available."
|
|
903
|
-
: "Pi compaction is disabled, so Posthorse sends no checkpoint reminder and performs no automatic rollover. new_context remains available.";
|
|
885
|
+
automatic = "Pi compaction is disabled in the available settings, so Posthorse sends no checkpoint reminder. new_context remains available.";
|
|
904
886
|
} else if (budget && !budget.supported) {
|
|
905
887
|
automatic = unsupportedMessage(budget);
|
|
906
888
|
} else {
|
|
907
889
|
const deadline = budget
|
|
908
890
|
? `${Math.max(1, Math.round((budget.rolloverAt / budget.contextWindow) * 100))}% used`
|
|
909
891
|
: "the configured Pi context limit";
|
|
910
|
-
|
|
892
|
+
const line = budget ? ` The rollover line is ${n(budget.rolloverAt)} tokens used under the available Pi settings.` : "";
|
|
893
|
+
automatic = `Automatic rollover follows Pi's compaction setting.${line} At most one checkpoint reminder may arrive before the rollover line (${deadline}); when it does, stop normal work, ${CHECKPOINT_STEPS}.`;
|
|
911
894
|
}
|
|
912
895
|
return `## Context self-management (Posthorse)
|
|
913
|
-
|
|
896
|
+
${capacity}
|
|
897
|
+
Do not guess context capacity or remaining space from response-token limits or reasoning budgets. Before reporting a remaining-token count or changing your work plan because of context limits, call get_context_remaining. If usage is unknown, report it as unknown. No routine budget checks are needed.
|
|
914
898
|
${automatic}
|
|
915
|
-
|
|
916
|
-
|
|
899
|
+
After a rollover, earlier conversation stays in history. Restore notes and todos, then verify live state before stateful or external work; automatic handoffs record inputs, not progress.
|
|
900
|
+
Keep one concise current-state note per task. Preserve decisions and safety constraints; link fuller evidence and history instead of copying them. Edit changed sections rather than resending unchanged content.
|
|
901
|
+
Write handoffs and notes as normal readable prose; notes and history search match literal text.`;
|
|
917
902
|
}
|
|
918
903
|
|
|
919
904
|
function persistedPolicy(ctx: ExtensionContext): CompactionPolicy {
|
|
@@ -923,24 +908,74 @@ function persistedPolicy(ctx: ExtensionContext): CompactionPolicy {
|
|
|
923
908
|
return settings.getCompactionSettings(ctx.model);
|
|
924
909
|
}
|
|
925
910
|
|
|
926
|
-
/**
|
|
911
|
+
/** ponytail: native estimates can omit request-local guidance; reserve its full section until post-transform usage is exposed. */
|
|
912
|
+
function guidanceTokens(ctx: PolicyContext): number {
|
|
913
|
+
return estimateTokens({ role: "system", content: `<posthorse>\n${buildGuidance(ctx)}\n</posthorse>`, timestamp: 0 });
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/** SDK hosts can inject live settings instead of the persisted CLI snapshot. */
|
|
927
917
|
export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => CompactionPolicy = persistedPolicy) => (pi: ExtensionAPI) => {
|
|
928
|
-
const
|
|
929
|
-
|
|
930
|
-
const policy = (ctx: ExtensionContext): PolicyContext =>
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
918
|
+
const resetRequests = new Map<string, string>();
|
|
919
|
+
// One synchronous safety evaluation only. Never carry this snapshot across an await.
|
|
920
|
+
const policy = (ctx: ExtensionContext, livePolicy?: CompactionPolicy): PolicyContext => {
|
|
921
|
+
let usageRead = false;
|
|
922
|
+
let usage: ContextUsage | undefined;
|
|
923
|
+
let settings: CompactionPolicy | undefined;
|
|
924
|
+
return {
|
|
925
|
+
getCompactionSettings: () => settings ??= { ...(livePolicy ?? getPolicy(ctx)) },
|
|
926
|
+
getContextUsage: () => {
|
|
927
|
+
if (!usageRead) {
|
|
928
|
+
usage = ctx.getContextUsage();
|
|
929
|
+
usageRead = true;
|
|
930
|
+
}
|
|
931
|
+
return usage;
|
|
932
|
+
},
|
|
935
933
|
getSystemPrompt: () => ctx.getSystemPrompt(),
|
|
936
934
|
};
|
|
935
|
+
};
|
|
936
|
+
type ReminderState = {
|
|
937
|
+
manager: ExtensionContext["sessionManager"];
|
|
938
|
+
sessionId: string;
|
|
939
|
+
file: string | undefined;
|
|
940
|
+
leaf: EntryLike | undefined;
|
|
941
|
+
windowId: string;
|
|
942
|
+
reminders: EntryLike[];
|
|
943
|
+
};
|
|
944
|
+
let reminderState: ReminderState | undefined;
|
|
945
|
+
const remindersFor = (ctx: ExtensionContext): ReminderState => {
|
|
946
|
+
const manager = ctx.sessionManager;
|
|
947
|
+
const sessionId = manager.getSessionId(), file = manager.getSessionFile();
|
|
948
|
+
const leaf = manager.getEntry(manager.getLeafId() ?? "") as EntryLike | undefined;
|
|
949
|
+
const previous = reminderState?.manager === manager && reminderState.sessionId === sessionId && reminderState.file === file
|
|
950
|
+
? reminderState : undefined;
|
|
951
|
+
if (previous && previous.leaf === leaf) return previous;
|
|
952
|
+
const reversed: EntryLike[] = [];
|
|
953
|
+
let boundary: EntryLike | undefined, window: EntryLike | undefined;
|
|
954
|
+
let kept = false;
|
|
955
|
+
for (let entry = leaf; entry; entry = entry.parentId ? manager.getEntry(entry.parentId) as EntryLike | undefined : undefined) {
|
|
956
|
+
// Certify append-only ancestry by entry identity, not just a reusable id.
|
|
957
|
+
if (!boundary && previous?.leaf === entry) {
|
|
958
|
+
return reminderState = { manager, sessionId, file, leaf, windowId: previous.windowId,
|
|
959
|
+
reminders: [...previous.reminders, ...reversed.reverse().filter((entry) => entry.type === "custom_message" && isReminderType(entry.customType))] };
|
|
960
|
+
}
|
|
961
|
+
reversed.push(entry);
|
|
962
|
+
if (!boundary && (entry.type === "compaction" || entry.type === "context_window")) boundary = entry;
|
|
963
|
+
if (!window && isWindow(entry)) window = entry;
|
|
964
|
+
if (boundary && (boundary.type === "context_window" || entry.id === boundary.firstKeptEntryId)) kept = true;
|
|
965
|
+
// An ordinary compaction can keep a tail preceding the Posthorse boundary.
|
|
966
|
+
// Missing kept ids or a negative window lookup must reach the root once.
|
|
967
|
+
if (window && kept) break;
|
|
968
|
+
}
|
|
969
|
+
const entries = reversed.reverse();
|
|
970
|
+
return reminderState = { manager, sessionId, file, leaf, windowId: window?.id ?? "initial",
|
|
971
|
+
reminders: entries.slice(contextStart(entries).start).filter((entry) => entry.type === "custom_message" && isReminderType(entry.customType)) };
|
|
972
|
+
};
|
|
937
973
|
registerPosthorseMessages(pi);
|
|
938
974
|
const activeToolTokens = () => {
|
|
939
975
|
const active = new Set(pi.getActiveTools());
|
|
940
976
|
return pi
|
|
941
977
|
.getAllTools()
|
|
942
|
-
|
|
943
|
-
.filter((tool) => active.has((tool as typeof tool & { id?: string }).id ?? tool.name))
|
|
978
|
+
.filter((tool) => active.has(tool.name))
|
|
944
979
|
.reduce(
|
|
945
980
|
(total, tool) =>
|
|
946
981
|
total +
|
|
@@ -952,12 +987,6 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
|
|
|
952
987
|
);
|
|
953
988
|
};
|
|
954
989
|
|
|
955
|
-
if (native) {
|
|
956
|
-
nativePi.registerContextWindowHook((event, ctx) =>
|
|
957
|
-
boundWindowReceipts(ctx as ExtensionContext & PolicyContext, event.contextEntries, event.pendingMessages, activeToolTokens()),
|
|
958
|
-
);
|
|
959
|
-
}
|
|
960
|
-
|
|
961
990
|
let pendingPageTokens = 0;
|
|
962
991
|
let previousPageUsage: number | null | undefined;
|
|
963
992
|
const pageSize = (ctx: PolicyContext, offset: number, imageCount: number, cursor?: string, imageOffset?: number) => {
|
|
@@ -969,7 +998,7 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
|
|
|
969
998
|
previousPageUsage = usage;
|
|
970
999
|
const remaining = pageCapacity(ctx, activeToolTokens()) - pendingPageTokens;
|
|
971
1000
|
const chars = Math.min(
|
|
972
|
-
|
|
1001
|
+
MAX_PAGE_CHARS,
|
|
973
1002
|
Math.max(0, remaining - PAGE_MARGIN_TOKENS) * 4 - imageCount * ESTIMATED_IMAGE_CHARS,
|
|
974
1003
|
);
|
|
975
1004
|
if (chars < MIN_PAGE_CHARS) {
|
|
@@ -991,61 +1020,75 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
|
|
|
991
1020
|
pendingPageTokens += Math.ceil(message.length / 4);
|
|
992
1021
|
throw new Error(message);
|
|
993
1022
|
};
|
|
994
|
-
const notesPage = (ctx: PolicyContext, rows: string[], offset: number, kind: "notes-list" | "notes-search", empty: string) => {
|
|
1023
|
+
const notesPage = (ctx: PolicyContext, rows: string[], offset: number, kind: "notes-list" | "notes-search", empty: string, header = "") => {
|
|
995
1024
|
const text = rows.length ? rows.join("\n") : empty;
|
|
996
|
-
const chars = pageSize(ctx, offset, 0);
|
|
997
|
-
if (offset && offset >= text.length) pageError(ctx, "Offset is past the end; restart with offset 0.");
|
|
1025
|
+
const chars = pageSize(ctx, offset, 0) - header.length;
|
|
998
1026
|
// Reserve the complete continuation, even when one path or excerpt needs several pages.
|
|
999
1027
|
const footer = (end: number) => `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]`;
|
|
1028
|
+
if (chars <= footer(text.length).length) pageError(ctx, `Too little context remains to include the notes path. Call new_context first, then retry with offset ${offset}.`);
|
|
1029
|
+
if (offset && offset >= text.length) pageError(ctx, "Offset is past the end; restart with offset 0.");
|
|
1000
1030
|
let end = Math.min(text.length, offset + chars);
|
|
1001
1031
|
let start = 0;
|
|
1002
1032
|
const starts: number[] = [];
|
|
1033
|
+
const maxRows = kind === "notes-search" ? 20 : 100;
|
|
1003
1034
|
for (const row of rows) {
|
|
1004
1035
|
if (start >= end) break;
|
|
1005
1036
|
if (start + row.length > offset) starts.push(start);
|
|
1006
1037
|
start += row.length + 1;
|
|
1007
|
-
if (
|
|
1038
|
+
if (starts.length === maxRows) { end = Math.min(end, start - 1); break; }
|
|
1008
1039
|
}
|
|
1009
1040
|
// A result-count cap can require continuation even when the character cap did not.
|
|
1010
1041
|
if (end < text.length) end = Math.min(end, offset + chars - footer(text.length).length);
|
|
1011
|
-
return pageResult(text.slice(offset, end) + (end < text.length ? footer(end) : ""), [], { kind, count: starts.filter((start) => start < end).length, page: { offset, end, total: text.length } });
|
|
1042
|
+
return pageResult(header + text.slice(offset, end) + (end < text.length ? footer(end) : ""), [], { kind, headerLength: header.length, count: starts.filter((start) => start < end).length, page: { offset, end, total: text.length } });
|
|
1012
1043
|
};
|
|
1013
1044
|
pi.on("turn_start", () => {
|
|
1045
|
+
resetRequests.clear();
|
|
1014
1046
|
pendingPageTokens = 0;
|
|
1015
1047
|
previousPageUsage = undefined;
|
|
1016
1048
|
});
|
|
1017
1049
|
|
|
1018
1050
|
pi.on("session_start", () => {
|
|
1051
|
+
resetRequests.clear();
|
|
1019
1052
|
pendingPageTokens = 0;
|
|
1020
1053
|
previousPageUsage = undefined;
|
|
1054
|
+
reminderState = undefined;
|
|
1021
1055
|
});
|
|
1056
|
+
pi.on("session_tree", () => { reminderState = undefined; });
|
|
1022
1057
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1058
|
+
let runPromptOptions: { forceSystemPrompt?: string } | undefined;
|
|
1059
|
+
pi.on("before_agent_start", (event) => {
|
|
1060
|
+
runPromptOptions = event.systemPromptOptions;
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
// Continuations do not rerun before_agent_start, even after a model switch or reset.
|
|
1064
|
+
pi.on("context_with_system", (event, ctx) => {
|
|
1065
|
+
const guidance = buildGuidance(policy(ctx));
|
|
1066
|
+
const system = event.messages.findLast((message) => message.role === "system" && typeof message.sections?.posthorse === "string")
|
|
1067
|
+
?? event.messages.find((message) => message.role === "system");
|
|
1068
|
+
if (!system || system.role !== "system") return;
|
|
1069
|
+
if (runPromptOptions?.forceSystemPrompt !== undefined) {
|
|
1070
|
+
// ponytail: official Pi applies forced prompts after these hooks; keep guidance in
|
|
1071
|
+
// request-local conversation until the host projects forced text before the hooks.
|
|
1072
|
+
event.messages.splice(1, 0, { role: "custom", customType: "posthorse-guidance", content: guidance, display: false, timestamp: 0 });
|
|
1073
|
+
} else {
|
|
1074
|
+
system.sections = { ...system.sections, posthorse: `<posthorse>\n${guidance}\n</posthorse>` };
|
|
1028
1075
|
}
|
|
1029
|
-
event.systemPromptOptions.sections.posthorse = guidance;
|
|
1030
1076
|
});
|
|
1031
1077
|
|
|
1032
1078
|
pi.on("turn_end", (event, ctx) => {
|
|
1079
|
+
const requested = event.toolResults.find((result) => result.toolName === "new_context" && resetRequests.has(result.toolCallId));
|
|
1080
|
+
const summary = requested ? resetRequests.get(requested.toolCallId) : undefined;
|
|
1081
|
+
resetRequests.clear();
|
|
1033
1082
|
const host = policy(ctx);
|
|
1034
1083
|
if (
|
|
1035
1084
|
event.message.role === "assistant" &&
|
|
1036
1085
|
(event.message.stopReason === "error" || event.message.stopReason === "aborted")
|
|
1037
1086
|
)
|
|
1038
1087
|
return;
|
|
1039
|
-
const requested = event.toolResults.find((result) => result.toolName === "new_context");
|
|
1040
1088
|
// Suppress clear successes; turn_end can also include unrelated background errors.
|
|
1041
1089
|
// If Pi still rolls over, context filtering below removes any stale reminder.
|
|
1042
|
-
if (
|
|
1043
|
-
|
|
1044
|
-
if (native || event.outcome === "aborted" || ctx.signal?.aborted) return;
|
|
1045
|
-
const content = (event.message as MessageLike).content;
|
|
1046
|
-
const call = (Array.isArray(content) ? content as ToolCallLike[] : []).find((block) => block?.type === "toolCall" && block.id === requested.toolCallId);
|
|
1047
|
-
const handoff = (call?.arguments as { handoff?: unknown } | undefined)?.handoff;
|
|
1048
|
-
const summary = (typeof handoff === "string" && handoff.trim()) || EMPTY_HANDOFF;
|
|
1090
|
+
if (summary !== undefined && !event.toolResults.some((result) => result.isError)) {
|
|
1091
|
+
if (event.outcome === "aborted" || ctx.signal?.aborted) return;
|
|
1049
1092
|
// Input steered in after execute() checked capacity can leave too little room.
|
|
1050
1093
|
if (summary.length > freshPayloadChars(host, activeToolTokens(), event.context.pendingMessages)) {
|
|
1051
1094
|
return { entries: [...event.entries, { type: "custom_message" as const, customType: "posthorse-reset-deferred", display: true,
|
|
@@ -1062,18 +1105,18 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
|
|
|
1062
1105
|
const remindAt = budget.rolloverAt - reminderBuffer;
|
|
1063
1106
|
if (usage.tokens < remindAt) return;
|
|
1064
1107
|
|
|
1065
|
-
const
|
|
1108
|
+
const state = remindersFor(ctx);
|
|
1066
1109
|
const fingerprint: ReminderFingerprint = {
|
|
1067
|
-
windowId:
|
|
1110
|
+
windowId: state.windowId,
|
|
1068
1111
|
contextWindow: budget.contextWindow,
|
|
1069
1112
|
reserveTokens: budget.reserveTokens,
|
|
1070
1113
|
};
|
|
1071
1114
|
// A reminder a compaction summarized away no longer reaches the model.
|
|
1072
|
-
if (hasReminder(
|
|
1115
|
+
if (hasReminder(state.reminders, fingerprint)) return;
|
|
1073
1116
|
pi.sendMessage(
|
|
1074
1117
|
{
|
|
1075
1118
|
customType: REMINDER_TYPE,
|
|
1076
|
-
content: `[posthorse] Checkpoint now: ${(budget.rolloverAt - usage.tokens).toLocaleString("en-US")} tokens remain before Pi's automatic rollover line. Stop normal work,
|
|
1119
|
+
content: `[posthorse] Checkpoint now: ${(budget.rolloverAt - usage.tokens).toLocaleString("en-US")} tokens remain before Pi's automatic rollover line. Stop normal work, ${CHECKPOINT_STEPS}.`,
|
|
1077
1120
|
display: true,
|
|
1078
1121
|
details: fingerprint,
|
|
1079
1122
|
},
|
|
@@ -1084,88 +1127,69 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
|
|
|
1084
1127
|
// Filter active input only; raw reminders remain in history for deduplication and recovery.
|
|
1085
1128
|
pi.on("context", (event, ctx) => {
|
|
1086
1129
|
if (!event.messages.some((message) => message.role === "custom" && isReminderType(message.customType))) return;
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1130
|
+
// The fork's window marker saves a branch walk, but a compaction inside the window drops it.
|
|
1131
|
+
const marker = event.messages.find((message) => message.role === "custom" && message.customType === "context-window");
|
|
1132
|
+
const windowId = marker?.role === "custom"
|
|
1133
|
+
? (marker.details as { windowId?: unknown } | undefined)?.windowId
|
|
1134
|
+
: remindersFor(ctx).windowId;
|
|
1135
|
+
if (typeof windowId !== "string") return;
|
|
1093
1136
|
const host = policy(ctx);
|
|
1094
1137
|
const budget = budgetFor(host);
|
|
1095
1138
|
const fingerprint: ReminderFingerprint = {
|
|
1096
|
-
windowId
|
|
1139
|
+
windowId,
|
|
1097
1140
|
contextWindow: budget?.contextWindow,
|
|
1098
1141
|
reserveTokens: budget?.reserveTokens,
|
|
1099
1142
|
};
|
|
1143
|
+
const enabled = host.getCompactionSettings().enabled;
|
|
1100
1144
|
const stale = (message: (typeof event.messages)[number]) =>
|
|
1101
1145
|
message.role === "custom" &&
|
|
1102
1146
|
isReminderType(message.customType) &&
|
|
1103
|
-
(!
|
|
1147
|
+
(!enabled || !reminderMatches(message.details, fingerprint));
|
|
1104
1148
|
if (event.messages.some(stale)) return { messages: event.messages.filter((message) => !stale(message)) };
|
|
1105
1149
|
});
|
|
1106
1150
|
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
const
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
return { newContext: { handoff } };
|
|
1122
|
-
});
|
|
1123
|
-
} else {
|
|
1124
|
-
// Official Pi owns thresholds, recovery retries, and whether another request follows.
|
|
1125
|
-
pi.on("session_before_compact", (event, ctx) => {
|
|
1126
|
-
if (event.reason === "manual") return;
|
|
1127
|
-
const cancelled = () => event.signal.aborted || ctx.signal?.aborted;
|
|
1151
|
+
// Pi owns thresholds, recovery retries, and whether another request follows.
|
|
1152
|
+
pi.on("session_before_compact", (event, ctx) => {
|
|
1153
|
+
if (event.reason === "manual") return;
|
|
1154
|
+
const cancelled = () => event.signal.aborted || ctx.signal?.aborted;
|
|
1155
|
+
if (cancelled()) return { cancel: true };
|
|
1156
|
+
try {
|
|
1157
|
+
// The event carries Pi's live settings; prefer them over the persisted snapshot.
|
|
1158
|
+
const host = policy(ctx, event.preparation.settings);
|
|
1159
|
+
// Small/unknown context windows deliberately keep Pi's own compaction.
|
|
1160
|
+
if (!budgetFor(host)?.supported) return;
|
|
1161
|
+
const limit = freshPayloadChars(host, activeToolTokens());
|
|
1162
|
+
const ownerQuestionRegistered = pi.getAllTools().some((tool) => tool.name === "ask_question" && tool.namespace === undefined);
|
|
1163
|
+
const handoff = buildAutoHandoff(event.branchEntries, ctx.sessionManager.buildSessionProjection().entries, limit, ownerQuestionRegistered);
|
|
1164
|
+
if (limit < MIN_PAGE_CHARS || handoff.length > limit) throw new Error("Too little fresh context capacity for an automatic recovery record.");
|
|
1128
1165
|
if (cancelled()) return { cancel: true };
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
const firstKeptEntryId = ctx.sessionManager.getLeafId();
|
|
1141
|
-
if (!firstKeptEntryId) throw new Error("Pi did not persist the context boundary.");
|
|
1142
|
-
return { compaction: { summary: handoff, firstKeptEntryId, tokensBefore: event.preparation.tokensBefore, details: { posthorse: 1, reason: event.reason } } };
|
|
1143
|
-
} catch (error) {
|
|
1144
|
-
// Pi swallows handler errors and would then run its summarizer; cancel instead.
|
|
1145
|
-
if (!cancelled()) {
|
|
1146
|
-
const message = `Posthorse automatic rollover cancelled: ${error instanceof Error ? error.message : String(error)}`;
|
|
1147
|
-
if (ctx.hasUI) ctx.ui.notify(message, "error");
|
|
1148
|
-
else console.error(message);
|
|
1149
|
-
}
|
|
1150
|
-
return { cancel: true };
|
|
1166
|
+
// firstKeptEntryId must name an entry; an invisible sentinel retains no prior conversation.
|
|
1167
|
+
pi.appendEntry("posthorse-boundary", {});
|
|
1168
|
+
const firstKeptEntryId = ctx.sessionManager.getLeafId();
|
|
1169
|
+
if (!firstKeptEntryId) throw new Error("Pi did not persist the context boundary.");
|
|
1170
|
+
return { compaction: { summary: handoff, firstKeptEntryId, tokensBefore: event.preparation.tokensBefore, details: { posthorse: 1, reason: event.reason } } };
|
|
1171
|
+
} catch (error) {
|
|
1172
|
+
// Pi swallows handler errors and would then run its summarizer; cancel instead.
|
|
1173
|
+
if (!cancelled()) {
|
|
1174
|
+
const message = `Posthorse automatic rollover cancelled: ${error instanceof Error ? error.message : String(error)}`;
|
|
1175
|
+
if (ctx.hasUI) ctx.ui.notify(message, "error");
|
|
1176
|
+
else console.error(message);
|
|
1151
1177
|
}
|
|
1152
|
-
|
|
1153
|
-
|
|
1178
|
+
return { cancel: true };
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1154
1181
|
|
|
1155
1182
|
pi.registerTool({
|
|
1156
1183
|
name: "new_context",
|
|
1157
1184
|
label: "New Context",
|
|
1158
1185
|
...toolCards("new_context"),
|
|
1159
1186
|
description:
|
|
1160
|
-
"
|
|
1161
|
-
promptSnippet: "request a fresh context window with an optional
|
|
1162
|
-
promptGuidelines: [
|
|
1163
|
-
"Before calling new_context, pass concise continuation state in handoff or save durable goal/progress/decisions/next-steps with notes",
|
|
1164
|
-
],
|
|
1187
|
+
"Start a fresh context window once the current foreground tools succeed; background work continues across the reset. Earlier conversation stays recoverable through history. Put continuation state in handoff, or save fuller state in notes first.",
|
|
1188
|
+
promptSnippet: "request a fresh context window with an optional handoff",
|
|
1165
1189
|
parameters: Type.Object({
|
|
1166
1190
|
handoff: Type.Optional(
|
|
1167
1191
|
Type.String({
|
|
1168
|
-
description: "
|
|
1192
|
+
description: "What the fresh window needs to continue, as readable prose",
|
|
1169
1193
|
maxLength: MAX_HANDOFF_CHARS,
|
|
1170
1194
|
}),
|
|
1171
1195
|
),
|
|
@@ -1183,8 +1207,8 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
|
|
|
1183
1207
|
[],
|
|
1184
1208
|
{ kind: "new-context" },
|
|
1185
1209
|
);
|
|
1186
|
-
|
|
1187
|
-
return
|
|
1210
|
+
resetRequests.set(_id, trimmed || EMPTY_HANDOFF);
|
|
1211
|
+
return result;
|
|
1188
1212
|
},
|
|
1189
1213
|
});
|
|
1190
1214
|
|
|
@@ -1193,8 +1217,8 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
|
|
|
1193
1217
|
label: "Context Remaining",
|
|
1194
1218
|
...toolCards("get_context_remaining"),
|
|
1195
1219
|
description:
|
|
1196
|
-
"
|
|
1197
|
-
promptSnippet: "
|
|
1220
|
+
"Estimate tokens left before Pi's automatic rollover and the configured context limit. Check before reporting remaining space or changing your work plan because of context limits. A checkpoint reminder already arrives before rollover; no routine checks are needed.",
|
|
1221
|
+
promptSnippet: "verify remaining context before reporting a token count or changing plans because of context limits",
|
|
1198
1222
|
parameters: Type.Object({}),
|
|
1199
1223
|
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
1200
1224
|
const host = policy(ctx);
|
|
@@ -1207,8 +1231,8 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
|
|
|
1207
1231
|
rollover: !budget?.enabled ? "disabled" : budget.supported ? "enabled" : "unsupported",
|
|
1208
1232
|
rolloverAt: budget?.rolloverAt,
|
|
1209
1233
|
};
|
|
1210
|
-
const configured = `≈${n(Math.max(0, usage.contextWindow - usage.tokens))} tokens until the configured context limit (${n(usage.tokens)}/${n(usage.contextWindow)} used, ${Math.round(usage.percent ?? 0)}%). Best available native estimate
|
|
1211
|
-
if (!budget?.enabled) return textResult(`Automatic rollover is disabled
|
|
1234
|
+
const configured = `≈${n(Math.max(0, usage.contextWindow - usage.tokens))} tokens until the configured context limit (${n(usage.tokens)}/${n(usage.contextWindow)} used, ${Math.round(usage.percent ?? 0)}%). Best available native estimate. ${SNAPSHOT_NOTE}`;
|
|
1235
|
+
if (!budget?.enabled) return textResult(`Automatic rollover is disabled in the available settings (Pi compaction.enabled=false). ${configured}`, [], display);
|
|
1212
1236
|
if (!budget.supported) return textResult(`${unsupportedMessage(budget)} ${configured}`, [], display);
|
|
1213
1237
|
return textResult(
|
|
1214
1238
|
`≈${n(Math.max(0, budget.rolloverAt - usage.tokens))} tokens until automatic rollover (line at ${n(budget.rolloverAt)}); ${configured}`,
|
|
@@ -1222,18 +1246,14 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
|
|
|
1222
1246
|
label: "Notes",
|
|
1223
1247
|
...toolCards("notes"),
|
|
1224
1248
|
description:
|
|
1225
|
-
"
|
|
1249
|
+
"Durable notes shared across Git worktrees and context resets. Ops: list (newest first, size/date; optional folder), read, search (case-insensitive text), write (replace; empty clears), append (one newline-terminated record). list/read/search are paged; continue with the returned offset. list shows the absolute notes directory; read shows the absolute file path. Prefer available file-editing tools for changed sections of one concise current-state note; write for creation or substantial restructuring. Link fuller evidence instead of copying it.",
|
|
1226
1250
|
promptSnippet: "save and recall durable state that survives context resets",
|
|
1227
|
-
promptGuidelines: [
|
|
1228
|
-
"Use notes for durable state too large for a new_context handoff",
|
|
1229
|
-
"Reload relevant notes after a context rollover",
|
|
1230
|
-
],
|
|
1231
1251
|
parameters: Type.Object({
|
|
1232
1252
|
op: Type.Union(
|
|
1233
1253
|
[Type.Literal("list"), Type.Literal("read"), Type.Literal("write"), Type.Literal("append"), Type.Literal("search")],
|
|
1234
1254
|
{ description: "Operation to perform" },
|
|
1235
1255
|
),
|
|
1236
|
-
path: Type.Optional(Type.String({ description: "Note path relative to .pi/notes/
|
|
1256
|
+
path: Type.Optional(Type.String({ description: "Note or folder path relative to .pi/notes/" })),
|
|
1237
1257
|
content: Type.Optional(Type.String({ description: "Full file content (write) or text to add (append)" })),
|
|
1238
1258
|
query: Type.Optional(Type.String({ description: "Substring to find in notes (search)" })),
|
|
1239
1259
|
offset: Type.Optional(Type.Integer({ description: "Character offset for list/read/search (default 0)", minimum: 0 })),
|
|
@@ -1247,7 +1267,7 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
|
|
|
1247
1267
|
}
|
|
1248
1268
|
return join(dir, relative);
|
|
1249
1269
|
};
|
|
1250
|
-
const walk = (directory: string, output:
|
|
1270
|
+
const walk = (directory: string, output: NoteFile[], ancestors = new Set<string>()) => {
|
|
1251
1271
|
const root = realpathSync(directory);
|
|
1252
1272
|
if (ancestors.has(root)) return;
|
|
1253
1273
|
ancestors.add(root);
|
|
@@ -1256,30 +1276,51 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
|
|
|
1256
1276
|
const info = statSync(path, { throwIfNoEntry: false });
|
|
1257
1277
|
if (!info) continue;
|
|
1258
1278
|
if (info.isDirectory()) walk(path, output, ancestors);
|
|
1259
|
-
else output.push(path);
|
|
1279
|
+
else output.push({ path, size: info.size, mtimeMs: info.mtimeMs });
|
|
1260
1280
|
}
|
|
1261
1281
|
ancestors.delete(root);
|
|
1262
1282
|
};
|
|
1283
|
+
const notesUnder = (target: string): NoteFile[] => {
|
|
1284
|
+
const info = statSync(target, { throwIfNoEntry: false });
|
|
1285
|
+
if (!info) return [];
|
|
1286
|
+
const files: NoteFile[] = [];
|
|
1287
|
+
if (info.isDirectory()) walk(target, files);
|
|
1288
|
+
else files.push({ path: target, size: info.size, mtimeMs: info.mtimeMs });
|
|
1289
|
+
return files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
1290
|
+
};
|
|
1291
|
+
const list = (folder?: string) => notesPage(
|
|
1292
|
+
policy(ctx),
|
|
1293
|
+
notesUnder(folder ? safeJoin(folder) : dir).map((file) =>
|
|
1294
|
+
`${file.path.slice(dir.length + 1)} ${formatSize(file.size)} ${new Date(file.mtimeMs).toISOString().slice(0, 16)}Z`),
|
|
1295
|
+
params.offset ?? 0,
|
|
1296
|
+
"notes-list",
|
|
1297
|
+
folder ? `(no notes in ${folder})` : "(no notes yet)",
|
|
1298
|
+
`Notes directory: ${dir}\n`,
|
|
1299
|
+
);
|
|
1263
1300
|
|
|
1264
1301
|
switch (params.op) {
|
|
1265
|
-
case "list":
|
|
1266
|
-
|
|
1267
|
-
if (existsSync(dir)) walk(dir, files);
|
|
1268
|
-
return notesPage(policy(ctx), files.map((file) => file.slice(dir.length + 1)), params.offset ?? 0, "notes-list", "(no notes yet)");
|
|
1269
|
-
}
|
|
1302
|
+
case "list":
|
|
1303
|
+
return list(params.path);
|
|
1270
1304
|
case "read": {
|
|
1271
1305
|
const relative = requireValue(params.path, "path", params.op);
|
|
1272
1306
|
const path = safeJoin(relative);
|
|
1273
|
-
|
|
1307
|
+
const info = statSync(path, { throwIfNoEntry: false });
|
|
1308
|
+
if (!info) throw new Error(`No note at ${relative}. Use op "list" to see available notes.`);
|
|
1309
|
+
if (info.isDirectory()) return list(relative);
|
|
1310
|
+
if (await isBinaryFile(path)) throw new Error(`${relative} is a binary file (${formatSize(info.size)}); notes read returns text only.`);
|
|
1274
1311
|
const text = readFileSync(path, "utf8");
|
|
1275
1312
|
const offset = params.offset ?? 0;
|
|
1276
1313
|
if (offset && offset >= text.length) {
|
|
1277
1314
|
throw new Error(`Offset ${offset} is past the end of ${relative} (${text.length} chars).`);
|
|
1278
1315
|
}
|
|
1279
|
-
const
|
|
1316
|
+
const header = `File: ${path}\n`;
|
|
1317
|
+
const host = policy(ctx);
|
|
1318
|
+
const chars = pageSize(host, offset, 0) - header.length;
|
|
1319
|
+
if (chars <= 0) pageError(host, `Too little context remains to include the note path. Call new_context first, then retry with offset ${offset}.`);
|
|
1320
|
+
const end = Math.min(text.length, offset + chars);
|
|
1280
1321
|
const more =
|
|
1281
1322
|
end < text.length ? `\n[chars ${offset}-${end} of ${text.length}; continue with offset ${end}]` : "";
|
|
1282
|
-
return pageResult(`${text.slice(offset, end)}${more}`, [], { kind: "note-read", offset, end, total: text.length });
|
|
1323
|
+
return pageResult(`${header}${text.slice(offset, end)}${more}`, [], { kind: "note-read", headerLength: header.length, offset, end, total: text.length });
|
|
1283
1324
|
}
|
|
1284
1325
|
case "write": {
|
|
1285
1326
|
const relative = requireValue(params.path, "path", params.op);
|
|
@@ -1307,15 +1348,16 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
|
|
|
1307
1348
|
}
|
|
1308
1349
|
case "search": {
|
|
1309
1350
|
const query = requireValue(params.query, "query", params.op).toLowerCase();
|
|
1310
|
-
const files: string[] = [];
|
|
1311
|
-
if (existsSync(dir)) walk(dir, files);
|
|
1312
1351
|
const hits: string[] = [];
|
|
1313
|
-
for (const file of
|
|
1314
|
-
|
|
1352
|
+
for (const { path: file } of notesUnder(dir)) {
|
|
1353
|
+
if (await isBinaryFile(file)) continue;
|
|
1354
|
+
let number = 0;
|
|
1355
|
+
for await (const line of lfLines(file, signal)) {
|
|
1356
|
+
number++;
|
|
1315
1357
|
const trimmed = line.trim();
|
|
1316
1358
|
const match = trimmed.toLowerCase().indexOf(query);
|
|
1317
1359
|
if (match !== -1) {
|
|
1318
|
-
hits.push(`${file.slice(dir.length + 1)}:${
|
|
1360
|
+
hits.push(`${file.slice(dir.length + 1)}:${number}: ${excerptAround(trimmed, match, 50, 200)}`);
|
|
1319
1361
|
}
|
|
1320
1362
|
}
|
|
1321
1363
|
}
|
|
@@ -1330,28 +1372,45 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
|
|
|
1330
1372
|
label: "History",
|
|
1331
1373
|
...toolCards("history"),
|
|
1332
1374
|
description:
|
|
1333
|
-
"Search or read
|
|
1375
|
+
"Search or read earlier session entries, including previous context windows. search skips entries still in your active context and lists original content before recovery echoes, newest first; all=true also searches this project's other sessions and subagent runs. read takes an entry id exactly as search printed it and pages text and images.",
|
|
1334
1376
|
promptSnippet: "recover earlier conversation that left the active context window",
|
|
1335
|
-
promptGuidelines: ["Use history search first, then history read with the
|
|
1377
|
+
promptGuidelines: ["Use history search first, then history read with the entry id exactly as printed"],
|
|
1336
1378
|
parameters: Type.Object({
|
|
1337
1379
|
op: Type.Union([Type.Literal("search"), Type.Literal("read")], { description: "Operation to perform" }),
|
|
1338
1380
|
query: Type.Optional(Type.String({ description: "Case-insensitive text to find (search)" })),
|
|
1339
|
-
id: Type.Optional(Type.String({ description: "Entry id
|
|
1340
|
-
all: Type.Optional(Type.Boolean({ description: "
|
|
1381
|
+
id: Type.Optional(Type.String({ description: "Entry id exactly as search printed it (read)" })),
|
|
1382
|
+
all: Type.Optional(Type.Boolean({ description: "Also search this project's other sessions and subagent runs" })),
|
|
1341
1383
|
limit: Type.Optional(Type.Integer({ description: "Maximum search results per page (default 10, max 50)", minimum: 1, maximum: 50 })),
|
|
1342
|
-
cursor: Type.Optional(Type.String({ description: "
|
|
1384
|
+
cursor: Type.Optional(Type.String({ description: "Continuation from the previous search page; keep query and all unchanged", maxLength: 512 })),
|
|
1343
1385
|
offset: Type.Optional(Type.Integer({ description: "Character offset for read (default 0)", minimum: 0 })),
|
|
1344
|
-
imageOffset: Type.Optional(Type.Integer({ description: "First image to return
|
|
1386
|
+
imageOffset: Type.Optional(Type.Integer({ description: "First image to return (read); use the value from the continuation", minimum: 0 })),
|
|
1345
1387
|
}),
|
|
1346
1388
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
1347
1389
|
const manager = ctx.sessionManager;
|
|
1348
1390
|
const cwd = resolve(ctx.cwd);
|
|
1349
1391
|
const currentFile = manager.getSessionFile?.();
|
|
1392
|
+
const dir = manager.getSessionDir();
|
|
1350
1393
|
|
|
1351
1394
|
if (params.op === "search") {
|
|
1352
1395
|
const query = requireValue(params.query, "query", params.op).toLowerCase();
|
|
1353
1396
|
const limit = params.limit ?? 10;
|
|
1354
|
-
const searchKey =
|
|
1397
|
+
const searchKey = shortKey(JSON.stringify([query, params.all === true]));
|
|
1398
|
+
// Projections show replacements and stripped async calls under the original id, so an entry is in
|
|
1399
|
+
// context only when all of its own text is visible there. Posthorse strips stale reminders from input.
|
|
1400
|
+
const seen = (message: MessageLike) => [
|
|
1401
|
+
textOf(message), assistantFailure(message), imageSummary(imagesOf(message.content)), message.summary, message.command, message.output,
|
|
1402
|
+
].filter(Boolean).join("\n").toLowerCase();
|
|
1403
|
+
const visible = new Map((manager.buildSessionProjection().entries as readonly ProjectedEntry[]).flatMap(({ sourceEntry, messages }) =>
|
|
1404
|
+
sourceEntry.id && messages.length ? [[sourceEntry.id, messages.map(seen).join("\n")] as const] : []));
|
|
1405
|
+
const inContext = (entry: EntryLike) => {
|
|
1406
|
+
if (entry.type === "custom_message" && isReminderType(entry.customType)) return false;
|
|
1407
|
+
const own = entry.type === "message" ? entry.message ?? {}
|
|
1408
|
+
: entry.type === "context_edit" ? { content: entry.replacement?.content }
|
|
1409
|
+
: entry.type === "context_window" ? { content: entry.handoff }
|
|
1410
|
+
: { content: entry.content, summary: entry.summary };
|
|
1411
|
+
return visible.get((entry.type === "context_edit" ? entry.targetId : entry.id) ?? "")?.includes(seen(own)) === true;
|
|
1412
|
+
};
|
|
1413
|
+
let skipped = 0;
|
|
1355
1414
|
let cursor: [string, 0 | 1, number, string] | undefined;
|
|
1356
1415
|
if (params.cursor) {
|
|
1357
1416
|
try {
|
|
@@ -1375,13 +1434,21 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
|
|
|
1375
1434
|
};
|
|
1376
1435
|
|
|
1377
1436
|
if (params.all) {
|
|
1378
|
-
|
|
1437
|
+
const needles = prefilterNeedles(query);
|
|
1438
|
+
const candidates = needles && await filesContaining(dir, needles, signal);
|
|
1439
|
+
const current = currentFile && resolve(currentFile);
|
|
1440
|
+
for await (const file of scopedSessionFiles(dir, cwd, currentFile, signal, { candidates })) {
|
|
1379
1441
|
const recent: HistoryHit[][] = [[], []];
|
|
1380
1442
|
let anchorHere = false;
|
|
1381
|
-
const source = relative(
|
|
1443
|
+
const source = relative(dir, file);
|
|
1444
|
+
const inCurrent = resolve(file) === current;
|
|
1382
1445
|
for await (const item of sessionWindowEntries(file, signal)) {
|
|
1383
1446
|
const hit = historyHit(item, query, source);
|
|
1384
1447
|
if (!hit) continue;
|
|
1448
|
+
if (inCurrent && inContext(item.entry)) {
|
|
1449
|
+
skipped++;
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1385
1452
|
const seeking = cursor && !found && hit.priority === cursor[1];
|
|
1386
1453
|
if (seeking && anchorHere) continue;
|
|
1387
1454
|
if (seeking && hit.id === cursor?.[0]) anchorHere = true;
|
|
@@ -1397,20 +1464,30 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
|
|
|
1397
1464
|
} else {
|
|
1398
1465
|
const current = [...windowEntries(manager.getBranch() as EntryLike[])];
|
|
1399
1466
|
for (const item of current.reverse()) {
|
|
1400
|
-
|
|
1467
|
+
const hit = historyHit(item, query);
|
|
1468
|
+
if (!hit) continue;
|
|
1469
|
+
if (inContext(item.entry)) {
|
|
1470
|
+
skipped++;
|
|
1471
|
+
continue;
|
|
1472
|
+
}
|
|
1473
|
+
addHit(hit);
|
|
1401
1474
|
if (hits[0].length > limit) break;
|
|
1402
1475
|
}
|
|
1403
1476
|
}
|
|
1404
|
-
const
|
|
1405
|
-
|
|
1477
|
+
const host = policy(ctx);
|
|
1478
|
+
const chars = pageSize(host, 0, 0, params.cursor);
|
|
1479
|
+
if (!found) pageError(host, "History cursor entry no longer matches; restart the search without it.", params.cursor);
|
|
1406
1480
|
const results = hits.flat();
|
|
1407
1481
|
const cursorFor = (hit: HistoryHit, offset: number) => Buffer.from(JSON.stringify([hit.id, hit.priority, offset, searchKey])).toString("base64url");
|
|
1408
1482
|
const footer = (next: string) => `\n[More results; continue with cursor "${next}" and the same query/scope.]`;
|
|
1409
1483
|
const reserve = Math.max(0, ...results.map((hit) => footer(cursorFor(hit, hit.text.length)).length));
|
|
1410
|
-
if (reserve >= chars) pageError(
|
|
1484
|
+
if (reserve >= chars) pageError(host, "History entry id is too large for pagination.", params.cursor);
|
|
1485
|
+
const skippedNote = skipped && !params.cursor
|
|
1486
|
+
? `\n[Skipped ${skipped} match${skipped === 1 ? "" : "es"} already in your active context.]`
|
|
1487
|
+
: "";
|
|
1411
1488
|
const parts: string[] = [];
|
|
1412
1489
|
const spans: Array<{ headerLength: number; length: number }> = [];
|
|
1413
|
-
let available = chars - reserve;
|
|
1490
|
+
let available = chars - reserve - skippedNote.length;
|
|
1414
1491
|
let next: string | undefined;
|
|
1415
1492
|
for (const hit of results) {
|
|
1416
1493
|
const offset = cursor?.[0] === hit.id ? cursor[2] : 0;
|
|
@@ -1427,8 +1504,8 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
|
|
|
1427
1504
|
if (available <= 0 || parts.length >= limit) break;
|
|
1428
1505
|
}
|
|
1429
1506
|
const body = parts.length ? parts.join("\n") : `No history matches "${excerpt(params.query!, 200)}".`;
|
|
1430
|
-
const
|
|
1431
|
-
return pageResult(body +
|
|
1507
|
+
const tail = `${skippedNote}${next ? footer(next) : ""}`;
|
|
1508
|
+
return pageResult(body + tail, [], { kind: "history-search", entries: spans, footerLength: tail.length, more: next !== undefined, skipped });
|
|
1432
1509
|
}
|
|
1433
1510
|
|
|
1434
1511
|
const id = requireValue(params.id, "id", params.op);
|
|
@@ -1436,6 +1513,8 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
|
|
|
1436
1513
|
const entryId = separator < 0 ? id : id.slice(0, separator);
|
|
1437
1514
|
const fileKey = separator < 0 ? undefined : id.slice(separator + 1);
|
|
1438
1515
|
const formatEntry = (item: WindowedEntry, source = "") => {
|
|
1516
|
+
// Session-file reads print the qualified id, so continuations reopen the same file.
|
|
1517
|
+
const shownId = source ? `${item.entry.id}@${shortKey(source)}` : id;
|
|
1439
1518
|
const offset = params.offset ?? 0;
|
|
1440
1519
|
const imageOffset = params.imageOffset ?? (offset === 0 ? 0 : item.images.length);
|
|
1441
1520
|
if (imageOffset > item.images.length) {
|
|
@@ -1454,8 +1533,8 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
|
|
|
1454
1533
|
offset + chars - (images.length - firstImage) * ESTIMATED_IMAGE_CHARS,
|
|
1455
1534
|
);
|
|
1456
1535
|
const more = end < item.text.length || imageEnd < item.images.length
|
|
1457
|
-
? `\nMore remains; call history read with id "${
|
|
1458
|
-
const header = `${
|
|
1536
|
+
? `\nMore remains; call history read with id "${shownId}" and offset ${end}${item.images.length ? ` and imageOffset ${imageEnd}` : ""}.` : "";
|
|
1537
|
+
const header = `${item.entry.timestamp ?? ""}${sourceTag(source)} [window ${item.windowId}] [${shownId}] [chars ${offset}-${end} of ${item.text.length}] `;
|
|
1459
1538
|
return pageResult(
|
|
1460
1539
|
`${header}${item.text.slice(offset, end)}${more}`,
|
|
1461
1540
|
images,
|
|
@@ -1468,13 +1547,17 @@ export const createPosthorse = (getPolicy: (ctx: ExtensionContext) => Compaction
|
|
|
1468
1547
|
if (item.entry.id === entryId) return formatEntry(item);
|
|
1469
1548
|
}
|
|
1470
1549
|
}
|
|
1471
|
-
|
|
1472
|
-
|
|
1550
|
+
// Bare ids from other sessions reach here through older notes and handoffs.
|
|
1551
|
+
const candidates = fileKey === undefined && /^[\w-]+$/.test(entryId)
|
|
1552
|
+
? await filesContaining(dir, [`"id":"${entryId}"`], signal)
|
|
1553
|
+
: undefined;
|
|
1554
|
+
for await (const file of scopedSessionFiles(dir, cwd, currentFile, signal, { fileKey, candidates })) {
|
|
1555
|
+
const fileSource = relative(dir, file);
|
|
1473
1556
|
for await (const item of sessionWindowEntries(file, signal)) {
|
|
1474
1557
|
if (item.entry.id === entryId) return formatEntry(item, fileSource);
|
|
1475
1558
|
}
|
|
1476
1559
|
}
|
|
1477
|
-
throw new Error(`No history entry
|
|
1560
|
+
throw new Error(`No history entry "${id}" in this session or this project's other sessions. Pass an id exactly as history search prints it, such as 1a2f07de, or 1a2f07de@Ab3dE5fG7h from another session; search first when you only have text or a timestamp.`);
|
|
1478
1561
|
},
|
|
1479
1562
|
});
|
|
1480
1563
|
};
|