pi-posthorse 0.10.25 → 0.10.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/boundary.ts +8 -2
- package/src/contracts.ts +45 -1
- package/src/files.ts +6 -0
- package/src/guidance.ts +45 -8
- package/src/note-discovery.ts +5 -1
- package/src/note-inventory.ts +8 -2
- package/src/note-store.ts +6 -3
- package/src/notes.ts +8 -4
- package/src/recovery-note-touches.ts +20 -11
- package/src/recovery.ts +6 -3
- package/src/reminders.ts +30 -7
- package/src/retained-tail.ts +85 -1
- package/src/rollover.ts +40 -11
- package/src/session-lines.ts +1 -0
- package/ui.ts +3 -2
package/package.json
CHANGED
package/src/boundary.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
AUTO_HANDOFF_PREFIX,
|
|
3
|
+
POSTHORSE_COMPACTION,
|
|
4
|
+
isRecord,
|
|
5
|
+
type EntryLike,
|
|
6
|
+
type ProjectedEntry,
|
|
7
|
+
} from "./contracts.ts";
|
|
2
8
|
|
|
3
9
|
export type BoundaryKind = "fork" | "native" | "automatic" | "explicit" | "posthorse";
|
|
4
10
|
/** Everything the app derives from one persisted context boundary, decided in one place. */
|
|
@@ -50,7 +56,7 @@ export function classifyBoundary(entry?: EntryLike): Boundary | undefined {
|
|
|
50
56
|
}
|
|
51
57
|
const details = isRecord(entry.details) ? entry.details : undefined;
|
|
52
58
|
const freshGuidance = !providerSummaryBlock(entry);
|
|
53
|
-
if (details?.posthorse !==
|
|
59
|
+
if (details?.posthorse !== POSTHORSE_COMPACTION) {
|
|
54
60
|
return { kind: "native", window: false, chainsAncestry: false, freshGuidance };
|
|
55
61
|
}
|
|
56
62
|
const kind = compactionKind(details);
|
package/src/contracts.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { PosthorseDisplay } from "
|
|
2
|
+
import type { PosthorseDisplay } from "./ui/display.ts";
|
|
3
3
|
|
|
4
4
|
export { isRecord } from "./records.ts";
|
|
5
5
|
|
|
@@ -40,6 +40,8 @@ export type ToolCallLike = {
|
|
|
40
40
|
};
|
|
41
41
|
export type MessageLike = {
|
|
42
42
|
readonly role?: string | undefined;
|
|
43
|
+
readonly timestamp?: number | undefined;
|
|
44
|
+
readonly sections?: unknown;
|
|
43
45
|
readonly stopReason?: string | undefined;
|
|
44
46
|
readonly errorMessage?: string | undefined;
|
|
45
47
|
readonly content?: unknown;
|
|
@@ -112,6 +114,48 @@ export const REMINDER_TYPE = "posthorse-reminder";
|
|
|
112
114
|
export const AUTO_HANDOFF_PREFIX = "Automatic context rollover recovery record.";
|
|
113
115
|
export const EMPTY_HANDOFF = "Fresh context. Restore relevant notes and history before continuing.";
|
|
114
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Journal wording is a storage format: recovery reads sessions back, across releases, by
|
|
119
|
+
* matching this exact text. Producers and parsers share one vocabulary, so a wording change
|
|
120
|
+
* is a migration and must keep reading the previous release's journals.
|
|
121
|
+
*/
|
|
122
|
+
export const BOUNDARY_TYPE = "posthorse-boundary";
|
|
123
|
+
export const RESET_DEFERRED_TYPE = "posthorse-reset-deferred";
|
|
124
|
+
export const RETAINED_TAIL_TYPE = "posthorse-retained-tail";
|
|
125
|
+
export const RETAINED_MARKER_PREFIX = "Context rolled over after entry ";
|
|
126
|
+
/** Display marker the legacy fork's context-window extension projects into messages. */
|
|
127
|
+
export const LEGACY_WINDOW_TYPE = "context-window";
|
|
128
|
+
/** The details marker every compaction Posthorse drafts carries; readers reject other values. */
|
|
129
|
+
export const POSTHORSE_COMPACTION = 1;
|
|
130
|
+
|
|
131
|
+
export function posthorseDetails(reason: string): {
|
|
132
|
+
readonly posthorse: typeof POSTHORSE_COMPACTION;
|
|
133
|
+
readonly reason: string;
|
|
134
|
+
} {
|
|
135
|
+
return { posthorse: POSTHORSE_COMPACTION, reason };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Receipt lines the notes tool returns; recovery matches them to record note touches. */
|
|
139
|
+
export const NOTE_RECEIPT = {
|
|
140
|
+
wrote: "Wrote ",
|
|
141
|
+
appended: "Appended to ",
|
|
142
|
+
file: "File: ",
|
|
143
|
+
directory: "Notes directory: ",
|
|
144
|
+
} as const;
|
|
145
|
+
|
|
146
|
+
export function noteWriteReceipt(path: string): string {
|
|
147
|
+
return `${NOTE_RECEIPT.wrote}${path}`;
|
|
148
|
+
}
|
|
149
|
+
export function noteAppendReceipt(path: string): string {
|
|
150
|
+
return `${NOTE_RECEIPT.appended}${path}`;
|
|
151
|
+
}
|
|
152
|
+
export function noteReadHeader(path: string): string {
|
|
153
|
+
return `${NOTE_RECEIPT.file}${path}\n`;
|
|
154
|
+
}
|
|
155
|
+
export function notesDirectoryHeader(directory: string): string {
|
|
156
|
+
return `${NOTE_RECEIPT.directory}${directory}\n`;
|
|
157
|
+
}
|
|
158
|
+
|
|
115
159
|
export function unknownArray(value: unknown): readonly unknown[] | undefined {
|
|
116
160
|
return Array.isArray(value) ? value : undefined;
|
|
117
161
|
}
|
package/src/files.ts
CHANGED
|
@@ -86,6 +86,12 @@ async function existingNote(target: string) {
|
|
|
86
86
|
}
|
|
87
87
|
return previous;
|
|
88
88
|
}
|
|
89
|
+
/** Reserved by publishFile; inventories hide these names without deleting their contents. */
|
|
90
|
+
export function isPublicationTemporaryName(name: string): boolean {
|
|
91
|
+
return /^\.posthorse-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.tmp$/.test(
|
|
92
|
+
name,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
89
95
|
/** Same-directory replacement; pre-rename failures preserve the old note and its metadata. */
|
|
90
96
|
export async function publishFile(
|
|
91
97
|
path: string,
|
package/src/guidance.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionAPI
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type, type Static } from "typebox";
|
|
3
3
|
import { Check } from "typebox/value";
|
|
4
4
|
import {
|
|
@@ -9,8 +9,14 @@ import {
|
|
|
9
9
|
} from "./budget.ts";
|
|
10
10
|
import type { EntryLike, PolicyContext, SnapshotReader } from "./contracts.ts";
|
|
11
11
|
import { startsFreshGuidanceEpoch } from "./boundary.ts";
|
|
12
|
-
import { createEpochCache, type EpochValue } from "./epoch-cache.ts";
|
|
13
|
-
import {
|
|
12
|
+
import { createEpochCache, type EpochValue, type SessionContext } from "./epoch-cache.ts";
|
|
13
|
+
import {
|
|
14
|
+
sessionEntry,
|
|
15
|
+
toolFacts,
|
|
16
|
+
type SessionHost,
|
|
17
|
+
type SessionIdentity,
|
|
18
|
+
type ToolRegistry,
|
|
19
|
+
} from "./host.ts";
|
|
14
20
|
|
|
15
21
|
export const WINDOW_GUIDANCE_TYPE = "posthorse-window-guidance";
|
|
16
22
|
const guidanceRecordSchema = Type.Object({
|
|
@@ -33,12 +39,13 @@ type GuidanceEpoch = {
|
|
|
33
39
|
readonly tools: GuidanceTools | undefined;
|
|
34
40
|
};
|
|
35
41
|
export type WindowGuidance = {
|
|
36
|
-
|
|
37
|
-
readonly
|
|
42
|
+
/** Only the session manager is read; the same context the epoch cache accepts. */
|
|
43
|
+
readonly resolve: (ctx: SessionContext, host: PolicyContext) => string;
|
|
44
|
+
readonly deliver: (ctx: SessionContext, host: PolicyContext) => string;
|
|
38
45
|
readonly reset: () => void;
|
|
39
46
|
};
|
|
40
47
|
|
|
41
|
-
export function activeGuidanceTools(pi:
|
|
48
|
+
export function activeGuidanceTools(pi: ToolRegistry): GuidanceTools {
|
|
42
49
|
const { activeUnnamespaced } = toolFacts(pi);
|
|
43
50
|
return {
|
|
44
51
|
newContext: activeUnnamespaced.has("new_context"),
|
|
@@ -122,7 +129,10 @@ function scanGuidanceEpoch(
|
|
|
122
129
|
...withMemoryEpoch(collected, memoryByEpoch?.get(anchorKey)),
|
|
123
130
|
};
|
|
124
131
|
}
|
|
125
|
-
|
|
132
|
+
/** The journal and tool-registry members guidance borrows; the host's full API satisfies these. */
|
|
133
|
+
export type GuidanceRegistry = ToolRegistry & Pick<ExtensionAPI, "appendEntry">;
|
|
134
|
+
|
|
135
|
+
export function createWindowGuidance(pi: GuidanceRegistry): WindowGuidance {
|
|
126
136
|
let memoryByManager = new WeakMap<object, Map<string, MemoryEpoch>>();
|
|
127
137
|
const epochs = createEpochCache<GuidanceEpoch>((ctx, identity, previous) =>
|
|
128
138
|
scanGuidanceEpoch(
|
|
@@ -132,6 +142,21 @@ export function createWindowGuidance(pi: ExtensionAPI): WindowGuidance {
|
|
|
132
142
|
memoryByManager.get(ctx.sessionManager),
|
|
133
143
|
),
|
|
134
144
|
);
|
|
145
|
+
/**
|
|
146
|
+
* A fresh anchor retires every earlier one on this branch: nothing reads an old epoch again
|
|
147
|
+
* without /tree, which resets all memory. Keep only the current anchor's delivered text.
|
|
148
|
+
*/
|
|
149
|
+
const pruneRetiredEpochs = (manager: object, anchorKey: string): void => {
|
|
150
|
+
const byEpoch = memoryByManager.get(manager);
|
|
151
|
+
if (byEpoch === undefined) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const key of byEpoch.keys()) {
|
|
155
|
+
if (key !== anchorKey) {
|
|
156
|
+
byEpoch.delete(key);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
135
160
|
return {
|
|
136
161
|
resolve(ctx, host) {
|
|
137
162
|
const epoch = epochs.read(ctx);
|
|
@@ -140,8 +165,19 @@ export function createWindowGuidance(pi: ExtensionAPI): WindowGuidance {
|
|
|
140
165
|
epoch.byBudget.get(key) ?? buildGuidance(host, epoch.tools ?? ALL_GUIDANCE_TOOLS)
|
|
141
166
|
);
|
|
142
167
|
},
|
|
168
|
+
/**
|
|
169
|
+
* Delivering guidance from inside the request hook must persist before the request: a
|
|
170
|
+
* restart after a rollover with a provider summary block has to reuse these exact bytes —
|
|
171
|
+
* the leading system message is sent unchanged, and in-memory delivery state does not
|
|
172
|
+
* survive. On the fork, the provider-summary request runs this hook mid-compaction. That
|
|
173
|
+
* is safe twice over: the request that reached the rollover threshold already delivered
|
|
174
|
+
* this budget's text, so the summary request reuses it and writes nothing; and a
|
|
175
|
+
* compaction with a summary block keeps the previous guidance anchor, so even a budget
|
|
176
|
+
* change mid-compaction appends inside the epoch the next window still reads.
|
|
177
|
+
*/
|
|
143
178
|
deliver(ctx, host) {
|
|
144
179
|
const epoch = epochs.read(ctx);
|
|
180
|
+
pruneRetiredEpochs(ctx.sessionManager, epoch.anchorKey);
|
|
145
181
|
const key = budgetGuidanceKey(host);
|
|
146
182
|
const existing = epoch.byBudget.get(key);
|
|
147
183
|
if (existing !== undefined) {
|
|
@@ -177,7 +213,7 @@ export function createWindowGuidance(pi: ExtensionAPI): WindowGuidance {
|
|
|
177
213
|
};
|
|
178
214
|
}
|
|
179
215
|
export function registerGuidance(
|
|
180
|
-
pi: ExtensionAPI,
|
|
216
|
+
pi: Pick<ExtensionAPI, "on">,
|
|
181
217
|
policy: SnapshotReader,
|
|
182
218
|
windowGuidance: WindowGuidance,
|
|
183
219
|
): void {
|
|
@@ -185,6 +221,7 @@ export function registerGuidance(
|
|
|
185
221
|
pi.on("before_agent_start", (event) => {
|
|
186
222
|
promptOptions = event.systemPromptOptions;
|
|
187
223
|
});
|
|
224
|
+
|
|
188
225
|
pi.on("agent_settled", () => {
|
|
189
226
|
// Prompt options belong to one run, never to later idle context evaluation.
|
|
190
227
|
promptOptions = undefined;
|
package/src/note-discovery.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readdir, stat } from "node:fs/promises";
|
|
2
|
-
import { join, relative } from "node:path";
|
|
2
|
+
import { basename, join, relative } from "node:path";
|
|
3
3
|
import { isRecord } from "./contracts.ts";
|
|
4
|
+
import { isPublicationTemporaryName } from "./files.ts";
|
|
4
5
|
import { sessionNoteTouches } from "./note-evidence.ts";
|
|
5
6
|
import { notesUnder, type NoteFile } from "./note-inventory.ts";
|
|
6
7
|
import {
|
|
@@ -118,6 +119,9 @@ async function readReference(
|
|
|
118
119
|
path: string,
|
|
119
120
|
signal: AbortSignal | undefined,
|
|
120
121
|
): Promise<DiscoveredNote | undefined> {
|
|
122
|
+
if (isPublicationTemporaryName(basename(path))) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
121
125
|
const target = await session.access(path, { signal });
|
|
122
126
|
if (target.kind === "own") {
|
|
123
127
|
return;
|
package/src/note-inventory.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readdir, realpath, stat } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
3
|
import { isRecord, presentText } from "./contracts.ts";
|
|
4
|
+
import { isPublicationTemporaryName } from "./files.ts";
|
|
4
5
|
import {
|
|
5
6
|
canonicalPath,
|
|
6
7
|
inside,
|
|
@@ -26,7 +27,9 @@ async function statIfPresent(path: string) {
|
|
|
26
27
|
}
|
|
27
28
|
/** Preserve depth-first listing order and recheck later entries after visiting each folder. */
|
|
28
29
|
async function* folderEntries(directory: string, signal: AbortSignal | undefined) {
|
|
29
|
-
const entries = await readdir(directory, { withFileTypes: true })
|
|
30
|
+
const entries = (await readdir(directory, { withFileTypes: true })).filter(
|
|
31
|
+
(entry) => !isPublicationTemporaryName(entry.name),
|
|
32
|
+
);
|
|
30
33
|
let start = 0;
|
|
31
34
|
while (start < entries.length) {
|
|
32
35
|
signal?.throwIfAborted();
|
|
@@ -99,6 +102,9 @@ export async function notesUnder(
|
|
|
99
102
|
requested === undefined || requested === "."
|
|
100
103
|
? scopeDirectory(scope)
|
|
101
104
|
: notePath(scope, requested);
|
|
105
|
+
if (isPublicationTemporaryName(basename(target))) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
102
108
|
const info = await statIfPresent(target);
|
|
103
109
|
options.signal?.throwIfAborted();
|
|
104
110
|
if (info === undefined) {
|
package/src/note-store.ts
CHANGED
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
import type { NotesRecovery } from "./note-evidence.ts";
|
|
33
33
|
|
|
34
34
|
type NotesManager = ExtensionContext["sessionManager"];
|
|
35
|
+
/** The journal member the notes store borrows; the host's full API satisfies this. */
|
|
36
|
+
type NotesApi = Pick<ExtensionAPI, "appendEntry">;
|
|
35
37
|
export type NoteCall = { readonly id: string; readonly rootId: string };
|
|
36
38
|
type Adoption = "owned" | "adopted";
|
|
37
39
|
/**
|
|
@@ -42,7 +44,7 @@ export type NoteAccess =
|
|
|
42
44
|
| { readonly kind: "own"; readonly address: NoteAddress; readonly path: string }
|
|
43
45
|
| { readonly kind: "inherited"; readonly address: NoteAddress; readonly path: string };
|
|
44
46
|
export class SessionNotes {
|
|
45
|
-
readonly #pi:
|
|
47
|
+
readonly #pi: NotesApi;
|
|
46
48
|
readonly binding: NoteBinding;
|
|
47
49
|
readonly #manager: NotesManager;
|
|
48
50
|
readonly #source: NoteSource;
|
|
@@ -50,7 +52,7 @@ export class SessionNotes {
|
|
|
50
52
|
readonly scopes: readonly NoteScope[];
|
|
51
53
|
readonly #calls = new Map<string, string>();
|
|
52
54
|
readonly #revisions = new Map<string, number>();
|
|
53
|
-
constructor(pi:
|
|
55
|
+
constructor(pi: NotesApi, binding: NoteBinding, manager: NotesManager, source: NoteSource) {
|
|
54
56
|
this.#pi = pi;
|
|
55
57
|
this.binding = binding;
|
|
56
58
|
this.#manager = manager;
|
|
@@ -251,8 +253,9 @@ export type NotesStore = {
|
|
|
251
253
|
type SessionState =
|
|
252
254
|
| { readonly kind: "initializing"; readonly value: Promise<SessionNotes> }
|
|
253
255
|
| { readonly kind: "ready"; readonly value: SessionNotes };
|
|
254
|
-
export function createNotesStore(pi:
|
|
256
|
+
export function createNotesStore(pi: NotesApi): NotesStore {
|
|
255
257
|
const sessions = new Map<string, SessionState>();
|
|
258
|
+
|
|
256
259
|
const get = (ctx: ExtensionContext): Promise<SessionNotes> => {
|
|
257
260
|
const key = sessionKey(ctx);
|
|
258
261
|
const existing = sessions.get(key);
|
package/src/notes.ts
CHANGED
|
@@ -11,6 +11,10 @@ import { Type } from "typebox";
|
|
|
11
11
|
import { toolCards } from "../ui.ts";
|
|
12
12
|
import {
|
|
13
13
|
MAX_PAGE_CHARS,
|
|
14
|
+
noteAppendReceipt,
|
|
15
|
+
noteReadHeader,
|
|
16
|
+
noteWriteReceipt,
|
|
17
|
+
notesDirectoryHeader,
|
|
14
18
|
presentText,
|
|
15
19
|
requireValue,
|
|
16
20
|
textResult,
|
|
@@ -84,7 +88,7 @@ async function listNotes(
|
|
|
84
88
|
offset: params.offset ?? 0,
|
|
85
89
|
kind: "notes-list",
|
|
86
90
|
empty: folder === undefined ? "(no notes yet)" : `(no notes in ${folder})`,
|
|
87
|
-
header:
|
|
91
|
+
header: notesDirectoryHeader(dir),
|
|
88
92
|
});
|
|
89
93
|
}
|
|
90
94
|
async function readNote(
|
|
@@ -124,7 +128,7 @@ async function readNote(
|
|
|
124
128
|
}
|
|
125
129
|
const header =
|
|
126
130
|
target.kind === "own"
|
|
127
|
-
?
|
|
131
|
+
? noteReadHeader(path)
|
|
128
132
|
: `Read-only live reference: notes read ${JSON.stringify({ scope: scopeDirectory(target.address), path: target.address.path })}\n`;
|
|
129
133
|
const end = Math.min(
|
|
130
134
|
text.length,
|
|
@@ -170,7 +174,7 @@ async function writeNote(
|
|
|
170
174
|
access.call,
|
|
171
175
|
);
|
|
172
176
|
});
|
|
173
|
-
return textResult(
|
|
177
|
+
return textResult(noteWriteReceipt(path), [], { kind: "note-write" });
|
|
174
178
|
}
|
|
175
179
|
async function appendNote(
|
|
176
180
|
session: SessionNotes,
|
|
@@ -203,7 +207,7 @@ async function appendNote(
|
|
|
203
207
|
access.call,
|
|
204
208
|
);
|
|
205
209
|
});
|
|
206
|
-
return textResult(
|
|
210
|
+
return textResult(noteAppendReceipt(path), [], { kind: "note-append" });
|
|
207
211
|
}
|
|
208
212
|
/** One note's matching lines as `path:line: excerpt` rows. */
|
|
209
213
|
function matchingLines(name: string, text: string, query: string): string[] {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
NOTE_RECEIPT,
|
|
2
3
|
isRecord,
|
|
3
4
|
optionalString,
|
|
4
5
|
presentText,
|
|
@@ -15,7 +16,7 @@ export type ScannedNoteEntry = {
|
|
|
15
16
|
readonly entryId: string;
|
|
16
17
|
readonly inRetained: boolean;
|
|
17
18
|
};
|
|
18
|
-
type ContentTouchKind = "full-write" | "partial-write" | "read" | "clear";
|
|
19
|
+
export type ContentTouchKind = "full-write" | "partial-write" | "read" | "clear";
|
|
19
20
|
export type NoteTarget = {
|
|
20
21
|
readonly notePath: string;
|
|
21
22
|
readonly absPath?: string | undefined;
|
|
@@ -81,6 +82,21 @@ function matchMarkerPath(slash: string): string | undefined {
|
|
|
81
82
|
function absoluteSlashPath(slash: string): boolean {
|
|
82
83
|
return slash.startsWith("/") || /^[A-Za-z]:\//u.test(slash);
|
|
83
84
|
}
|
|
85
|
+
|
|
86
|
+
/** Receipt lines are a storage format: parse exactly what the notes tool emits. */
|
|
87
|
+
function receiptPattern(prefix: string): RegExp {
|
|
88
|
+
return new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}([^\\r\\n]+)$`, "mu");
|
|
89
|
+
}
|
|
90
|
+
const receiptPatterns: readonly (readonly [RegExp, ContentTouchKind])[] = [
|
|
91
|
+
[receiptPattern(NOTE_RECEIPT.wrote), "full-write"],
|
|
92
|
+
[receiptPattern(NOTE_RECEIPT.appended), "partial-write"],
|
|
93
|
+
[receiptPattern(NOTE_RECEIPT.file), "read"],
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
export function notesDirectoryOf(text: string): string | undefined {
|
|
97
|
+
return receiptPattern(NOTE_RECEIPT.directory).exec(text)?.[1];
|
|
98
|
+
}
|
|
99
|
+
|
|
84
100
|
function unscopedNoteTarget(trimmed: string, slash: string): NoteTarget | undefined {
|
|
85
101
|
if (absoluteSlashPath(slash)) {
|
|
86
102
|
const base = normalizeRelPath(slash.slice(slash.lastIndexOf("/") + 1));
|
|
@@ -114,15 +130,10 @@ function resolveNoteTarget(
|
|
|
114
130
|
return fromNotesTool ? unscopedNoteTarget(trimmed, slash) : undefined;
|
|
115
131
|
}
|
|
116
132
|
|
|
117
|
-
function extractNotesResultTarget(
|
|
133
|
+
export function extractNotesResultTarget(
|
|
118
134
|
text: string,
|
|
119
135
|
): { readonly kind: ContentTouchKind; readonly rawPath: string } | undefined {
|
|
120
|
-
|
|
121
|
-
[/^Wrote ([^\r\n]+)$/mu, "full-write"],
|
|
122
|
-
[/^Appended to ([^\r\n]+)$/mu, "partial-write"],
|
|
123
|
-
[/^File: ([^\r\n]+)$/mu, "read"],
|
|
124
|
-
];
|
|
125
|
-
for (const [pattern, kind] of patterns) {
|
|
136
|
+
for (const [pattern, kind] of receiptPatterns) {
|
|
126
137
|
const rawPath = pattern.exec(text)?.[1];
|
|
127
138
|
if (rawPath !== undefined) {
|
|
128
139
|
return { kind, rawPath };
|
|
@@ -193,9 +204,7 @@ function learnNotesDirs(
|
|
|
193
204
|
const dirs = new Set<string>();
|
|
194
205
|
for (const info of results.values()) {
|
|
195
206
|
const match =
|
|
196
|
-
!info.isError && info.toolName === "notes"
|
|
197
|
-
? /^Notes directory: ([^\r\n]+)$/mu.exec(info.text)?.[1]
|
|
198
|
-
: undefined;
|
|
207
|
+
!info.isError && info.toolName === "notes" ? notesDirectoryOf(info.text) : undefined;
|
|
199
208
|
if (match !== undefined) {
|
|
200
209
|
dirs.add(match.trim().replaceAll("\\", "/").replace(/\/+$/u, ""));
|
|
201
210
|
}
|
package/src/recovery.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
AUTO_HANDOFF_PREFIX,
|
|
3
|
+
RETAINED_MARKER_PREFIX,
|
|
4
|
+
type EntryLike,
|
|
5
|
+
type ProjectedEntry,
|
|
6
|
+
} from "./contracts.ts";
|
|
2
7
|
import { contextStart, windowHandoff } from "./boundary.ts";
|
|
3
8
|
import { contextEdits } from "./history-entries.ts";
|
|
4
9
|
import { boundedBlock, excerpt } from "./message-text.ts";
|
|
@@ -23,8 +28,6 @@ const MAX_RECOVERY_RESULT_CHARS = 1_500;
|
|
|
23
28
|
const HANDOFF_OVERHEAD_RESERVE = 1_000;
|
|
24
29
|
const MAX_NOTES_SECTION_CHARS = 1_200;
|
|
25
30
|
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.`;
|
|
26
|
-
const RETAINED_MARKER_PREFIX = "Context rolled over after entry ";
|
|
27
|
-
|
|
28
31
|
export function hasRetainedRolloverMarker(summary: string): boolean {
|
|
29
32
|
return (
|
|
30
33
|
summary.startsWith(AUTO_HANDOFF_PREFIX) && summary.includes(`\n${RETAINED_MARKER_PREFIX}`)
|
package/src/reminders.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { ExtensionContext, TurnEndEvent } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { budgetFor, type Budget } from "./budget.ts";
|
|
3
3
|
import {
|
|
4
|
+
LEGACY_WINDOW_TYPE,
|
|
4
5
|
isRecord,
|
|
5
6
|
isReminderType,
|
|
7
|
+
type MessageLike,
|
|
6
8
|
type EntryLike,
|
|
7
9
|
type PolicyContext,
|
|
8
10
|
type ProjectedEntry,
|
|
@@ -58,7 +60,7 @@ function contextMarker(messages: readonly ContextMessage[]): {
|
|
|
58
60
|
readonly present: boolean;
|
|
59
61
|
readonly windowId: unknown;
|
|
60
62
|
} {
|
|
61
|
-
const marker = messages.find((message) => message.customType ===
|
|
63
|
+
const marker = messages.find((message) => message.customType === LEGACY_WINDOW_TYPE);
|
|
62
64
|
if (marker === undefined) {
|
|
63
65
|
return { present: false, windowId: undefined };
|
|
64
66
|
}
|
|
@@ -235,12 +237,12 @@ export function createReminderLookup(): ReminderLookup {
|
|
|
235
237
|
return { reset: cache.reset, read: cache.read };
|
|
236
238
|
}
|
|
237
239
|
|
|
238
|
-
function
|
|
240
|
+
function retainedProjectedMessages(
|
|
239
241
|
projected: readonly ProjectedEntry[],
|
|
240
242
|
retainedIds: ReadonlySet<string>,
|
|
241
243
|
stale: StaleReminderPredicate,
|
|
242
|
-
):
|
|
243
|
-
|
|
244
|
+
): MessageLike[] {
|
|
245
|
+
const retained: MessageLike[] = [];
|
|
244
246
|
for (const item of projected) {
|
|
245
247
|
if (!retainedIds.has(item.sourceEntry.id ?? "")) {
|
|
246
248
|
continue;
|
|
@@ -249,11 +251,11 @@ function countRetainedFromProjected(
|
|
|
249
251
|
if (
|
|
250
252
|
!stale({ role: msg.role ?? "", customType: msg.customType, details: msg.details })
|
|
251
253
|
) {
|
|
252
|
-
|
|
254
|
+
retained.push(msg);
|
|
253
255
|
}
|
|
254
256
|
}
|
|
255
257
|
}
|
|
256
|
-
return
|
|
258
|
+
return retained;
|
|
257
259
|
}
|
|
258
260
|
|
|
259
261
|
function entryProducesRetainedMessage(
|
|
@@ -284,7 +286,7 @@ export function countRetainedMessages(
|
|
|
284
286
|
}
|
|
285
287
|
const projection = sessionProjection(ctx.sessionManager);
|
|
286
288
|
if (projection !== undefined) {
|
|
287
|
-
return
|
|
289
|
+
return retainedProjectedMessages(projection, retainedIds, stale).length;
|
|
288
290
|
}
|
|
289
291
|
const edits = contextEdits(branch);
|
|
290
292
|
return branch.filter(
|
|
@@ -292,3 +294,24 @@ export function countRetainedMessages(
|
|
|
292
294
|
retainedIds.has(entry.id ?? "") && entryProducesRetainedMessage(entry, edits, stale),
|
|
293
295
|
).length;
|
|
294
296
|
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The retained tail's messages, in journal order, as the session projection carries them.
|
|
300
|
+
* Stale reminders leave the list exactly as the provider filter removes them. Undefined when
|
|
301
|
+
* the journal names no tail or the host has no session projection, so callers cannot certify
|
|
302
|
+
* the tail by identity and must count positions instead.
|
|
303
|
+
*/
|
|
304
|
+
export function retainedTailMessages(
|
|
305
|
+
ctx: ExtensionContext,
|
|
306
|
+
stale: StaleReminderPredicate,
|
|
307
|
+
): readonly MessageLike[] | undefined {
|
|
308
|
+
const branch = sessionBranch(ctx.sessionManager) ?? [];
|
|
309
|
+
const retainedIds = journalRetainedIds(branch);
|
|
310
|
+
if (retainedIds === undefined) {
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
const projection = sessionProjection(ctx.sessionManager);
|
|
314
|
+
return projection === undefined
|
|
315
|
+
? undefined
|
|
316
|
+
: retainedProjectedMessages(projection, retainedIds, stale);
|
|
317
|
+
}
|
package/src/retained-tail.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EntryLike } from "./contracts.ts";
|
|
1
|
+
import type { EntryLike, MessageLike } from "./contracts.ts";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* What a rollover retains: where Pi keeps the tail, which entries it holds, and how it must start.
|
|
@@ -54,3 +54,87 @@ export function retainedSpan(
|
|
|
54
54
|
const lastId = list.at(-1)?.id?.slice(0, 120);
|
|
55
55
|
return firstId === undefined || lastId === undefined ? undefined : { firstId, lastId };
|
|
56
56
|
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Where the retained tail ends in a provider message list: the position after the last message
|
|
60
|
+
* that matches the journal's next retained message, in order. Opaque system presentations of
|
|
61
|
+
* custom messages must preserve their journal timestamp and order. Other interleaved messages
|
|
62
|
+
* are skipped. Returns undefined when the whole tail cannot be matched.
|
|
63
|
+
*/
|
|
64
|
+
export function retainedTailEnd(
|
|
65
|
+
messages: readonly MessageLike[],
|
|
66
|
+
summaryIndex: number,
|
|
67
|
+
retained: readonly MessageLike[],
|
|
68
|
+
): number | undefined {
|
|
69
|
+
if (retained.length === 0) {
|
|
70
|
+
return summaryIndex + 1;
|
|
71
|
+
}
|
|
72
|
+
let expected = 0;
|
|
73
|
+
let end: number | undefined;
|
|
74
|
+
for (
|
|
75
|
+
let index = summaryIndex + 1;
|
|
76
|
+
index < messages.length && expected < retained.length;
|
|
77
|
+
index++
|
|
78
|
+
) {
|
|
79
|
+
const message = messages.at(index);
|
|
80
|
+
const journal = retained.at(expected);
|
|
81
|
+
if (message === undefined || journal === undefined) {
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
if (sameRetainedMessage(journal, message) || isOpaquePresentation(journal, message)) {
|
|
85
|
+
expected += 1;
|
|
86
|
+
end = index + 1;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return expected === retained.length ? end : undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Presenters such as pi-change-working-dir replace a custom message's content and details with
|
|
94
|
+
* system sections but preserve its timestamp. Timestamp/order correlation is their integration
|
|
95
|
+
* contract, not a globally unique message ID. A system message without that timestamp is not
|
|
96
|
+
* evidence that the expected custom message survived.
|
|
97
|
+
*/
|
|
98
|
+
function isOpaquePresentation(journal: MessageLike, event: MessageLike): boolean {
|
|
99
|
+
return (
|
|
100
|
+
event.role === "system" &&
|
|
101
|
+
event.content === "" &&
|
|
102
|
+
event.sections !== undefined &&
|
|
103
|
+
journal.role === "custom" &&
|
|
104
|
+
journal.timestamp !== undefined &&
|
|
105
|
+
event.timestamp === journal.timestamp
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Identity of a retained message as the provider context carries it: same payload and receipt
|
|
111
|
+
* linkage, and a role an earlier handler may only present as `system`. Journal and
|
|
112
|
+
* provider messages are clones of each other, so identity is structural, never object equality.
|
|
113
|
+
*/
|
|
114
|
+
export function sameRetainedMessage(journal: MessageLike, event: MessageLike): boolean {
|
|
115
|
+
return (
|
|
116
|
+
(event.role === "system" || journal.role === event.role) &&
|
|
117
|
+
messageIdentity(journal) === messageIdentity(event)
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function messageIdentity(message: MessageLike): string {
|
|
122
|
+
// Array serialization normalizes absent optional fields to null on both clones.
|
|
123
|
+
return JSON.stringify([
|
|
124
|
+
message.timestamp,
|
|
125
|
+
message.customType,
|
|
126
|
+
message.content,
|
|
127
|
+
message.details,
|
|
128
|
+
message.toolCallId,
|
|
129
|
+
message.toolName,
|
|
130
|
+
message.namespace,
|
|
131
|
+
message.isError,
|
|
132
|
+
message.summary,
|
|
133
|
+
message.command,
|
|
134
|
+
message.output,
|
|
135
|
+
message.stopReason,
|
|
136
|
+
message.errorMessage,
|
|
137
|
+
message.nestedCalls,
|
|
138
|
+
message.excludeFromContext,
|
|
139
|
+
]);
|
|
140
|
+
}
|
package/src/rollover.ts
CHANGED
|
@@ -9,7 +9,11 @@ import type {
|
|
|
9
9
|
import { activeBoundaryIndex } from "./boundary.ts";
|
|
10
10
|
import { budgetFor, freshPayloadChars } from "./budget.ts";
|
|
11
11
|
import {
|
|
12
|
+
BOUNDARY_TYPE,
|
|
12
13
|
REMINDER_TYPE,
|
|
14
|
+
RESET_DEFERRED_TYPE,
|
|
15
|
+
RETAINED_TAIL_TYPE,
|
|
16
|
+
posthorseDetails,
|
|
13
17
|
type PolicyContext,
|
|
14
18
|
type ProjectedEntry,
|
|
15
19
|
type SnapshotReader,
|
|
@@ -36,10 +40,12 @@ import {
|
|
|
36
40
|
countRetainedMessages,
|
|
37
41
|
createReminderLookup,
|
|
38
42
|
reminderDecision,
|
|
43
|
+
retainedTailMessages,
|
|
39
44
|
staleReminderPredicate,
|
|
40
45
|
type ReminderLookup,
|
|
41
46
|
type StaleReminderPredicate,
|
|
42
47
|
} from "./reminders.ts";
|
|
48
|
+
import { retainedTailEnd } from "./retained-tail.ts";
|
|
43
49
|
|
|
44
50
|
function explicitReset(
|
|
45
51
|
event: TurnEndEvent,
|
|
@@ -53,7 +59,7 @@ function explicitReset(
|
|
|
53
59
|
...event.entries,
|
|
54
60
|
{
|
|
55
61
|
type: "custom_message",
|
|
56
|
-
customType:
|
|
62
|
+
customType: RESET_DEFERRED_TYPE,
|
|
57
63
|
display: true,
|
|
58
64
|
content:
|
|
59
65
|
"Posthorse reset deferred: queued messages leave too little room for the requested handoff. The current context is unchanged; process those messages, then request a shorter handoff.",
|
|
@@ -68,7 +74,7 @@ function explicitReset(
|
|
|
68
74
|
type: "compaction",
|
|
69
75
|
summary,
|
|
70
76
|
firstKeptEntryId: null,
|
|
71
|
-
details:
|
|
77
|
+
details: posthorseDetails("explicit"),
|
|
72
78
|
},
|
|
73
79
|
],
|
|
74
80
|
continue: true,
|
|
@@ -83,11 +89,11 @@ function chronologicalRetainedTail(
|
|
|
83
89
|
const first = messages[summaryIndex];
|
|
84
90
|
if (first?.role === "compactionSummary" && hasRetainedRolloverMarker(first.summary)) {
|
|
85
91
|
// Pi projects no system messages into the retained tail, but an earlier handler can
|
|
86
|
-
// present a retained message as one (pi-change-working-dir's directory snapshots)
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
const end = summaryIndex
|
|
90
|
-
if (count > 0 && end
|
|
92
|
+
// present a retained message as one (pi-change-working-dir's directory snapshots) or
|
|
93
|
+
// interleave a new message. Match the projected journal tail before reordering it;
|
|
94
|
+
// count positions only on a host without a projection.
|
|
95
|
+
const { count, end } = retainedTailSliceEnd(messages, summaryIndex, ctx, stale);
|
|
96
|
+
if (count > 0 && end !== undefined) {
|
|
91
97
|
const prefix = messages.slice(0, summaryIndex);
|
|
92
98
|
const retainedSlice = messages.slice(summaryIndex + 1, end);
|
|
93
99
|
const afterSlice = messages.slice(end);
|
|
@@ -98,7 +104,7 @@ function chronologicalRetainedTail(
|
|
|
98
104
|
...prefix,
|
|
99
105
|
{
|
|
100
106
|
role: "custom",
|
|
101
|
-
customType:
|
|
107
|
+
customType: RETAINED_TAIL_TYPE,
|
|
102
108
|
content: "[Retained pre-rollover tail]",
|
|
103
109
|
display: false,
|
|
104
110
|
timestamp: first.timestamp,
|
|
@@ -111,6 +117,27 @@ function chronologicalRetainedTail(
|
|
|
111
117
|
}
|
|
112
118
|
return messages;
|
|
113
119
|
}
|
|
120
|
+
|
|
121
|
+
/** An unmatched projection must not silently become a positional match. */
|
|
122
|
+
function retainedTailSliceEnd(
|
|
123
|
+
messages: ContextWithSystemEvent["messages"],
|
|
124
|
+
summaryIndex: number,
|
|
125
|
+
ctx: ExtensionContext,
|
|
126
|
+
stale: StaleReminderPredicate,
|
|
127
|
+
): { readonly count: number; readonly end: number | undefined } {
|
|
128
|
+
const retained = retainedTailMessages(ctx, stale);
|
|
129
|
+
const count = retained?.length ?? countRetainedMessages(ctx, stale);
|
|
130
|
+
if (count === 0) {
|
|
131
|
+
return { count, end: undefined };
|
|
132
|
+
}
|
|
133
|
+
let end: number | undefined;
|
|
134
|
+
if (retained !== undefined) {
|
|
135
|
+
end = retainedTailEnd(messages, summaryIndex, retained);
|
|
136
|
+
} else if (summaryIndex + 1 + count <= messages.length) {
|
|
137
|
+
end = summaryIndex + 1 + count;
|
|
138
|
+
}
|
|
139
|
+
return { count, end };
|
|
140
|
+
}
|
|
114
141
|
function registerReminderFilter(
|
|
115
142
|
pi: ExtensionAPI,
|
|
116
143
|
policy: SnapshotReader,
|
|
@@ -149,7 +176,9 @@ function automaticDraft(
|
|
|
149
176
|
let firstKeptEntryId = recovery.firstKeptEntryId;
|
|
150
177
|
if (firstKeptEntryId === undefined) {
|
|
151
178
|
// Public compaction requires an entry id even when no prior conversation safely fits.
|
|
152
|
-
|
|
179
|
+
// A compaction cancelled after this append leaves an inert marker: no reader matches
|
|
180
|
+
// BOUNDARY_TYPE; the compaction entry itself is the boundary recovery classifies.
|
|
181
|
+
pi.appendEntry(BOUNDARY_TYPE, {});
|
|
153
182
|
firstKeptEntryId = ctx.sessionManager.getLeafId() ?? undefined;
|
|
154
183
|
}
|
|
155
184
|
if (firstKeptEntryId === undefined || firstKeptEntryId === "") {
|
|
@@ -160,7 +189,7 @@ function automaticDraft(
|
|
|
160
189
|
summary: recovery.handoff,
|
|
161
190
|
firstKeptEntryId,
|
|
162
191
|
tokensBefore: recovery.tokensBefore,
|
|
163
|
-
details:
|
|
192
|
+
details: posthorseDetails(recovery.reason),
|
|
164
193
|
...providerSummaryField(recovery.providerSummary),
|
|
165
194
|
},
|
|
166
195
|
};
|
|
@@ -263,7 +292,7 @@ function thresholdFallback(
|
|
|
263
292
|
type: "compaction",
|
|
264
293
|
summary: recovery.handoff,
|
|
265
294
|
firstKeptEntryId: recovery.firstKeptEntryId ?? null,
|
|
266
|
-
details:
|
|
295
|
+
details: posthorseDetails("threshold"),
|
|
267
296
|
...providerSummaryField(recovery.providerSummary),
|
|
268
297
|
},
|
|
269
298
|
],
|
package/src/session-lines.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type LineMatcher = {
|
|
|
11
11
|
export type SessionLine = {
|
|
12
12
|
readonly head: string;
|
|
13
13
|
readonly matches: boolean;
|
|
14
|
+
/** Read while the owning iterator is open; advancing alone does not close its handle. */
|
|
14
15
|
readonly text: () => Promise<string>;
|
|
15
16
|
};
|
|
16
17
|
type LineRange = {
|
package/ui.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type Text,
|
|
8
8
|
} from "@earendil-works/pi-tui";
|
|
9
9
|
import { clean, hint, leadingRows, textBlock, textOf } from "./src/ui/text.ts";
|
|
10
|
+
import { LEGACY_WINDOW_TYPE, REMINDER_TYPE } from "./src/contracts.ts";
|
|
10
11
|
|
|
11
12
|
export type { PosthorseDisplay } from "./src/ui/display.ts";
|
|
12
13
|
export { toolCards } from "./src/ui/cards.ts";
|
|
@@ -26,7 +27,7 @@ export function registerPosthorseMessages(pi: ExtensionAPI): void {
|
|
|
26
27
|
states.set(message, state);
|
|
27
28
|
}
|
|
28
29
|
const view = state;
|
|
29
|
-
const window = message.customType ===
|
|
30
|
+
const window = message.customType === LEGACY_WINDOW_TYPE;
|
|
30
31
|
const raw = textOf(message.content);
|
|
31
32
|
const handoffLabel = "\n\nHandoff from the previous window:\n";
|
|
32
33
|
const handoffAt = raw.indexOf(handoffLabel);
|
|
@@ -79,7 +80,7 @@ export function registerPosthorseMessages(pi: ExtensionAPI): void {
|
|
|
79
80
|
return { handled: true };
|
|
80
81
|
});
|
|
81
82
|
};
|
|
82
|
-
for (const type of [
|
|
83
|
+
for (const type of [LEGACY_WINDOW_TYPE, REMINDER_TYPE]) {
|
|
83
84
|
pi.registerMessageRenderer(type, renderer);
|
|
84
85
|
}
|
|
85
86
|
}
|