pi-posthorse 0.10.3 → 0.10.5
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 +7 -2
- package/index.ts +13 -3
- package/package.json +6 -6
- package/src/contracts.ts +3 -0
- package/src/note-discovery.ts +291 -0
- package/src/note-evidence.ts +179 -0
- package/src/note-guards.ts +203 -0
- package/src/note-inventory.ts +111 -0
- package/src/note-provenance.ts +299 -0
- package/src/note-scopes.ts +293 -0
- package/src/note-snapshots.ts +185 -0
- package/src/note-store.ts +278 -0
- package/src/note-tool-effects.ts +190 -0
- package/src/notes.ts +117 -133
- package/src/recovery-note-touches.ts +59 -106
- package/src/recovery-notes.ts +50 -69
- package/src/recovery.ts +5 -0
- package/src/retention.ts +3 -0
- package/src/rollover.ts +22 -5
- package/src/session-files.ts +7 -2
- package/src/ui/cards.ts +9 -1
- package/src/ui/text.ts +2 -3
- package/ui.ts +3 -1
package/README.md
CHANGED
|
@@ -48,7 +48,12 @@ These tools are available to the model:
|
|
|
48
48
|
|
|
49
49
|
Expand a tool card with Ctrl+O. In fullscreen mode, click a tool card to expand it. A `new_context` card shows a request. Pi's `Compacted from … tokens` card confirms the rollover.
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
Forks get private copies of the notes used on their selected branch. The parent and fork can
|
|
52
|
+
update the same note name without overwriting each other. Project-wide lists and searches still
|
|
53
|
+
find both, with scope selectors for read-only comparisons.
|
|
54
|
+
|
|
55
|
+
See [tool examples and paging details](https://github.com/fitchmultz/pi-posthorse/blob/main/docs/reference.md#tools)
|
|
56
|
+
and [fork notes](https://github.com/fitchmultz/pi-posthorse/blob/main/docs/reference.md#forks-references-and-discovery).
|
|
52
57
|
|
|
53
58
|
## Settings and limits
|
|
54
59
|
|
|
@@ -80,7 +85,7 @@ Removal leaves notes and Pi session history in place. See the [installation refe
|
|
|
80
85
|
|
|
81
86
|
## Data and privacy
|
|
82
87
|
|
|
83
|
-
Posthorse makes no network requests. Notes are plaintext files under `.pi/notes
|
|
88
|
+
Posthorse makes no network requests. Notes are plaintext files under `.pi/notes/`. Linked Git worktrees share that store; forks keep private note files inside its `.forks/` directory. Notes for Pi-managed Git packages live outside their checkouts because `pi update` deletes untracked files there: global packages (including their descendants) use Pi's agent-directory `notes/`, and other project-local packages use the containing project's `.pi/package-notes/`, with project-local stores keyed by a full SHA-256 digest of the host and package path. Access existing checkout-local notes with this version before updating the package so they can migrate. Add `.pi/notes/` and `.pi/package-notes/` to the containing project's `.gitignore` if the notes must stay private.
|
|
84
89
|
|
|
85
90
|
History can include user messages, assistant thinking, tool arguments, errors, and images. Content you retrieve enters the context of your selected model and provider.
|
|
86
91
|
|
package/index.ts
CHANGED
|
@@ -15,6 +15,8 @@ import type { CompactionPolicy, PolicyReader } from "./src/contracts.ts";
|
|
|
15
15
|
import { registerHistory } from "./src/history.ts";
|
|
16
16
|
import { safeJsonStringify } from "./src/message-text.ts";
|
|
17
17
|
import { registerNotes } from "./src/notes.ts";
|
|
18
|
+
import { createNotesStore } from "./src/note-store.ts";
|
|
19
|
+
import { registerNoteGuards } from "./src/note-guards.ts";
|
|
18
20
|
import { PageReservations } from "./src/paging.ts";
|
|
19
21
|
import { registerRollover } from "./src/rollover.ts";
|
|
20
22
|
|
|
@@ -47,10 +49,18 @@ export function createPosthorse(getPolicy?: PolicyReader): (pi: ExtensionAPI) =>
|
|
|
47
49
|
const toolTokens = () => activeToolTokens(pi);
|
|
48
50
|
const pages = new PageReservations(toolTokens);
|
|
49
51
|
registerPosthorseMessages(pi);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
const notes = createNotesStore(pi);
|
|
53
|
+
registerRollover(pi, policy, toolTokens, {
|
|
54
|
+
resetPages: () => {
|
|
55
|
+
pages.reset();
|
|
56
|
+
},
|
|
57
|
+
notes: (ctx) => notes.recovery(ctx),
|
|
52
58
|
});
|
|
53
|
-
|
|
59
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
60
|
+
await notes.get(ctx);
|
|
61
|
+
});
|
|
62
|
+
registerNoteGuards(pi, notes);
|
|
63
|
+
registerNotes(pi, policy, pages, notes);
|
|
54
64
|
registerHistory(pi, policy, pages);
|
|
55
65
|
};
|
|
56
66
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-posthorse",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Posthorse: fresh context, same journey. No-summary context rollover for Pi, with sparse budget reminders, handoffs, notes, and history.",
|
|
6
6
|
"keywords": [
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
"lint:fix:oxlint": "oxlint --fix ."
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
|
-
"@earendil-works/pi-coding-agent": ">=1.
|
|
55
|
-
"@earendil-works/pi-tui": ">=1.
|
|
54
|
+
"@earendil-works/pi-coding-agent": ">=1.5.0",
|
|
55
|
+
"@earendil-works/pi-tui": ">=1.5.0",
|
|
56
56
|
"typebox": "*"
|
|
57
57
|
},
|
|
58
58
|
"peerDependenciesMeta": {
|
|
@@ -67,9 +67,9 @@
|
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
|
-
"@earendil-works/pi-ai": "1.
|
|
71
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
72
|
-
"@earendil-works/pi-tui": "1.
|
|
70
|
+
"@earendil-works/pi-ai": "1.5.0",
|
|
71
|
+
"@earendil-works/pi-coding-agent": "1.5.0",
|
|
72
|
+
"@earendil-works/pi-tui": "1.5.0",
|
|
73
73
|
"@types/node": "24.19.1",
|
|
74
74
|
"oxc-parser": "0.156.0",
|
|
75
75
|
"oxfmt": "0.75.0",
|
package/src/contracts.ts
CHANGED
|
@@ -63,6 +63,9 @@ export type EntryLike = {
|
|
|
63
63
|
readonly content?: unknown;
|
|
64
64
|
readonly details?: unknown;
|
|
65
65
|
readonly display?: boolean | undefined;
|
|
66
|
+
readonly data?: unknown;
|
|
67
|
+
readonly cwd?: string | undefined;
|
|
68
|
+
readonly parentSession?: string | undefined;
|
|
66
69
|
readonly handoff?: string | undefined;
|
|
67
70
|
readonly firstKeptEntryId?: string | null | undefined;
|
|
68
71
|
readonly targetId?: string | undefined;
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { isRecord } from "./contracts.ts";
|
|
4
|
+
import { sessionNoteTouches } from "./note-evidence.ts";
|
|
5
|
+
import { notesUnder, type NoteFile } from "./note-inventory.ts";
|
|
6
|
+
import { fileDigest, fileIdentity, readManifest, sameIdentity } from "./note-snapshots.ts";
|
|
7
|
+
import {
|
|
8
|
+
sameScope,
|
|
9
|
+
scopeDirectory,
|
|
10
|
+
type ForkBinding,
|
|
11
|
+
type NoteAddress,
|
|
12
|
+
type NoteIdentity,
|
|
13
|
+
type NoteScope,
|
|
14
|
+
type NoteSeed,
|
|
15
|
+
} from "./note-scopes.ts";
|
|
16
|
+
import type { SessionNotes } from "./note-store.ts";
|
|
17
|
+
|
|
18
|
+
export type DiscoveredNote = NoteFile & {
|
|
19
|
+
readonly address: NoteAddress;
|
|
20
|
+
readonly lineage: NoteAddress;
|
|
21
|
+
readonly seed: NoteSeed | undefined;
|
|
22
|
+
readonly copies: readonly NoteAddress[];
|
|
23
|
+
};
|
|
24
|
+
type ScopeInventory = { readonly scope: NoteScope; readonly manifest: ForkBinding | undefined };
|
|
25
|
+
type DiscoveryOptions = {
|
|
26
|
+
readonly path?: string | undefined;
|
|
27
|
+
readonly scope?: string | undefined;
|
|
28
|
+
readonly signal: AbortSignal | undefined;
|
|
29
|
+
};
|
|
30
|
+
type ComparisonOptions = DiscoveryOptions & { readonly notice: (message: string) => void };
|
|
31
|
+
type DigestCache = {
|
|
32
|
+
readonly identity: NoteIdentity;
|
|
33
|
+
readonly digest: string;
|
|
34
|
+
readonly revision: number;
|
|
35
|
+
};
|
|
36
|
+
async function projectScopes(
|
|
37
|
+
session: SessionNotes,
|
|
38
|
+
notice: (message: string) => void,
|
|
39
|
+
): Promise<ScopeInventory[]> {
|
|
40
|
+
const scopes: ScopeInventory[] = [
|
|
41
|
+
{ scope: { root: session.binding.root, scope: "" }, manifest: undefined },
|
|
42
|
+
];
|
|
43
|
+
const directory = join(session.binding.root, ".forks");
|
|
44
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
|
|
45
|
+
if (isRecord(error) && error.code === "ENOENT") {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
throw error;
|
|
49
|
+
});
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
const path = join(directory, entry.name);
|
|
52
|
+
if (entry.name.startsWith(".staging-")) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const manifest = entry.isDirectory() ? await readManifest(join(path, "notes")) : undefined;
|
|
56
|
+
if (manifest === undefined || manifest.root !== session.binding.root) {
|
|
57
|
+
notice(`Not indexed: unmanaged or incomplete notes data at ${path}.`);
|
|
58
|
+
} else {
|
|
59
|
+
scopes.push({ scope: manifest, manifest });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!scopes.some(({ scope }) => sameScope(scope, session.binding))) {
|
|
63
|
+
scopes.push({
|
|
64
|
+
scope: session.binding,
|
|
65
|
+
manifest: session.binding.kind === "fork" ? session.binding : undefined,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return scopes;
|
|
69
|
+
}
|
|
70
|
+
async function scopedFiles(
|
|
71
|
+
inventory: ScopeInventory,
|
|
72
|
+
options: DiscoveryOptions,
|
|
73
|
+
notice: (message: string) => void,
|
|
74
|
+
): Promise<DiscoveredNote[]> {
|
|
75
|
+
const files = await notesUnder(inventory.scope, options.path, {
|
|
76
|
+
signal: options.signal,
|
|
77
|
+
notice,
|
|
78
|
+
});
|
|
79
|
+
const seeds = new Map(inventory.manifest?.seeds.map((seed) => [seed.path, seed]));
|
|
80
|
+
return files.map((file) => {
|
|
81
|
+
const path = relative(scopeDirectory(inventory.scope), file.path);
|
|
82
|
+
const address = { root: inventory.scope.root, scope: inventory.scope.scope, path };
|
|
83
|
+
const seed = seeds.get(path);
|
|
84
|
+
return Object.assign({}, file, {
|
|
85
|
+
address,
|
|
86
|
+
seed,
|
|
87
|
+
lineage: seed?.lineage ?? address,
|
|
88
|
+
copies: [],
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function noteKey(address: NoteAddress): string {
|
|
93
|
+
return JSON.stringify([address.root, address.scope, address.path]);
|
|
94
|
+
}
|
|
95
|
+
export function noteReadTarget(note: DiscoveredNote): string {
|
|
96
|
+
return JSON.stringify({ scope: scopeDirectory(note.address), path: note.address.path });
|
|
97
|
+
}
|
|
98
|
+
export function noteLabel(note: DiscoveredNote, session: SessionNotes, labeled: boolean): string {
|
|
99
|
+
if (!labeled) {
|
|
100
|
+
return note.address.path;
|
|
101
|
+
}
|
|
102
|
+
const mode = sameScope(note.address, session.binding) ? "current" : "read-only";
|
|
103
|
+
const copies =
|
|
104
|
+
note.copies.length === 0
|
|
105
|
+
? ""
|
|
106
|
+
: `; identical copies: ${note.copies.map((copy) => JSON.stringify({ scope: scopeDirectory(copy), path: copy.path })).join(", ")}`;
|
|
107
|
+
return `${note.address.path} [${mode}; read ${noteReadTarget(note)}${copies}]`;
|
|
108
|
+
}
|
|
109
|
+
async function readReference(
|
|
110
|
+
session: SessionNotes,
|
|
111
|
+
path: string,
|
|
112
|
+
signal: AbortSignal | undefined,
|
|
113
|
+
): Promise<DiscoveredNote | undefined> {
|
|
114
|
+
const target = await session.read(path, { signal });
|
|
115
|
+
if (target.writable) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const info = await stat(target.path).catch((error: unknown) => {
|
|
119
|
+
if (isRecord(error) && error.code === "ENOENT") {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
123
|
+
});
|
|
124
|
+
if (info?.isFile() !== true) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
...fileIdentity(info),
|
|
129
|
+
path: target.path,
|
|
130
|
+
address: target.address,
|
|
131
|
+
lineage: target.address,
|
|
132
|
+
seed: undefined,
|
|
133
|
+
copies: [],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
export class NoteDiscovery {
|
|
137
|
+
readonly #digests = new Map<string, DigestCache>();
|
|
138
|
+
#knownDigest(note: DiscoveredNote, revision: number): string | undefined {
|
|
139
|
+
const cached = this.#digests.get(note.path);
|
|
140
|
+
if (
|
|
141
|
+
cached !== undefined &&
|
|
142
|
+
cached.revision === revision &&
|
|
143
|
+
sameIdentity(cached.identity, note)
|
|
144
|
+
) {
|
|
145
|
+
return cached.digest;
|
|
146
|
+
}
|
|
147
|
+
if (revision === 0 && note.seed !== undefined && sameIdentity(note.seed.identity, note)) {
|
|
148
|
+
return note.seed.digest;
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
async #digest(
|
|
153
|
+
note: DiscoveredNote,
|
|
154
|
+
session: SessionNotes,
|
|
155
|
+
options: ComparisonOptions,
|
|
156
|
+
): Promise<string | undefined> {
|
|
157
|
+
const { signal } = options;
|
|
158
|
+
const revision = session.revision(note.path);
|
|
159
|
+
const known = this.#knownDigest(note, revision);
|
|
160
|
+
if (known !== undefined) {
|
|
161
|
+
return known;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const digest = await fileDigest(note.path, signal);
|
|
165
|
+
if (!sameIdentity(note, await stat(note.path))) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
this.#digests.set(note.path, { identity: note, digest, revision });
|
|
169
|
+
return digest;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
signal?.throwIfAborted();
|
|
172
|
+
options.notice(
|
|
173
|
+
`Duplicate comparison unavailable for ${noteReadTarget(note)}: ${error instanceof Error ? error.message : String(error)}. Copies remain separate.`,
|
|
174
|
+
);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async #collapse(
|
|
179
|
+
files: readonly DiscoveredNote[],
|
|
180
|
+
session: SessionNotes,
|
|
181
|
+
options: ComparisonOptions,
|
|
182
|
+
): Promise<DiscoveredNote[]> {
|
|
183
|
+
const groups = new Map<string, DiscoveredNote[]>();
|
|
184
|
+
for (const file of files) {
|
|
185
|
+
const key = `${noteKey(file.lineage)}\0${file.size}`;
|
|
186
|
+
const group = groups.get(key) ?? [];
|
|
187
|
+
group.push(file);
|
|
188
|
+
groups.set(key, group);
|
|
189
|
+
}
|
|
190
|
+
const selected: DiscoveredNote[] = [];
|
|
191
|
+
for (const group of groups.values()) {
|
|
192
|
+
if (group.length === 1) {
|
|
193
|
+
selected.push(...group);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const matches = new Map<string, DiscoveredNote>();
|
|
197
|
+
group.sort(
|
|
198
|
+
(a, b) =>
|
|
199
|
+
Number(sameScope(b.address, session.binding)) -
|
|
200
|
+
Number(sameScope(a.address, session.binding)),
|
|
201
|
+
);
|
|
202
|
+
for (const file of group) {
|
|
203
|
+
// Only same-lineage, same-size candidates need content comparison.
|
|
204
|
+
const digest = await this.#digest(file, session, options);
|
|
205
|
+
const previous = digest === undefined ? undefined : matches.get(digest);
|
|
206
|
+
if (previous === undefined || digest === undefined) {
|
|
207
|
+
matches.set(digest ?? noteKey(file.address), file);
|
|
208
|
+
} else {
|
|
209
|
+
matches.set(digest, {
|
|
210
|
+
...previous,
|
|
211
|
+
copies: [...previous.copies, file.address],
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
selected.push(...matches.values());
|
|
216
|
+
}
|
|
217
|
+
return selected;
|
|
218
|
+
}
|
|
219
|
+
async #references(session: SessionNotes, options: DiscoveryOptions): Promise<DiscoveredNote[]> {
|
|
220
|
+
if (session.binding.kind !== "fork" || options.scope !== undefined) {
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
const recovery = session.recovery;
|
|
224
|
+
const { touches } = sessionNoteTouches({
|
|
225
|
+
branch: session.branch,
|
|
226
|
+
allEntries: recovery.entries,
|
|
227
|
+
projected: session.projection,
|
|
228
|
+
retained: new Set(),
|
|
229
|
+
directories: new Set(session.scopes.map(scopeDirectory)),
|
|
230
|
+
});
|
|
231
|
+
const paths = new Set(touches.map((touch) => touch.notePath));
|
|
232
|
+
const files: DiscoveredNote[] = [];
|
|
233
|
+
for (const path of paths) {
|
|
234
|
+
if (
|
|
235
|
+
session.owns(path) ||
|
|
236
|
+
(options.path !== undefined &&
|
|
237
|
+
path !== options.path &&
|
|
238
|
+
!path.startsWith(`${options.path}/`))
|
|
239
|
+
) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const file = await readReference(session, path, options.signal);
|
|
243
|
+
if (file !== undefined) {
|
|
244
|
+
files.push(file);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return files;
|
|
248
|
+
}
|
|
249
|
+
async list(
|
|
250
|
+
session: SessionNotes,
|
|
251
|
+
options: DiscoveryOptions,
|
|
252
|
+
): Promise<{
|
|
253
|
+
readonly files: readonly DiscoveredNote[];
|
|
254
|
+
readonly notices: readonly string[];
|
|
255
|
+
readonly labeled: boolean;
|
|
256
|
+
}> {
|
|
257
|
+
const notices: string[] = [];
|
|
258
|
+
const notice = (text: string) => {
|
|
259
|
+
notices.push(text);
|
|
260
|
+
};
|
|
261
|
+
const scopes =
|
|
262
|
+
options.scope === undefined
|
|
263
|
+
? await projectScopes(session, notice)
|
|
264
|
+
: [
|
|
265
|
+
{
|
|
266
|
+
scope: await session.selectScope(options.scope),
|
|
267
|
+
manifest: await readManifest(options.scope),
|
|
268
|
+
},
|
|
269
|
+
];
|
|
270
|
+
const files: DiscoveredNote[] = [];
|
|
271
|
+
for (const scope of scopes) {
|
|
272
|
+
// Preserve native listing order while walking each scope independently.
|
|
273
|
+
files.push(...(await scopedFiles(scope, options, notice)));
|
|
274
|
+
}
|
|
275
|
+
for (const reference of await this.#references(session, options)) {
|
|
276
|
+
if (!files.some((file) => noteKey(file.address) === noteKey(reference.address))) {
|
|
277
|
+
files.push(reference);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const selected =
|
|
281
|
+
options.scope === undefined
|
|
282
|
+
? await this.#collapse(files, session, { ...options, notice })
|
|
283
|
+
: files;
|
|
284
|
+
return {
|
|
285
|
+
files: selected.sort((a, b) => b.mtimeMs - a.mtimeMs),
|
|
286
|
+
notices,
|
|
287
|
+
labeled:
|
|
288
|
+
scopes.length > 1 || scopes.some(({ scope }) => !sameScope(scope, session.binding)),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { isRecord, optionalString, type EntryLike, type ProjectedEntry } from "./contracts.ts";
|
|
3
|
+
import { contextEdits } from "./history-entries.ts";
|
|
4
|
+
import { collectTouches, type NoteTouch, type ScannedNoteEntry } from "./recovery-note-touches.ts";
|
|
5
|
+
import {
|
|
6
|
+
addressOf,
|
|
7
|
+
bindingsOf,
|
|
8
|
+
notePath,
|
|
9
|
+
ownedPaths,
|
|
10
|
+
sameOwner,
|
|
11
|
+
sameScope,
|
|
12
|
+
scopeDirectory,
|
|
13
|
+
touchRecords,
|
|
14
|
+
type NoteBinding,
|
|
15
|
+
} from "./note-scopes.ts";
|
|
16
|
+
|
|
17
|
+
export type NotesRecovery = {
|
|
18
|
+
readonly binding: NoteBinding;
|
|
19
|
+
readonly entries: readonly EntryLike[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type NoteEvidence = {
|
|
23
|
+
readonly branch: readonly EntryLike[];
|
|
24
|
+
readonly allEntries: readonly EntryLike[];
|
|
25
|
+
readonly projected: readonly ProjectedEntry[];
|
|
26
|
+
readonly retained: ReadonlySet<string>;
|
|
27
|
+
readonly directories?: ReadonlySet<string>;
|
|
28
|
+
};
|
|
29
|
+
/** Compaction removes context, not provenance. Only active-branch edits suppress old receipts. */
|
|
30
|
+
function projectedNotes(evidence: NoteEvidence): ScannedNoteEntry[] {
|
|
31
|
+
const edits = contextEdits(evidence.branch);
|
|
32
|
+
const omitted = new Set(
|
|
33
|
+
evidence.projected
|
|
34
|
+
.filter(({ messages }) => messages.length === 0)
|
|
35
|
+
.map(({ sourceEntry }) => sourceEntry.id),
|
|
36
|
+
);
|
|
37
|
+
return evidence.branch.flatMap((entry) => {
|
|
38
|
+
const id = entry.id ?? "";
|
|
39
|
+
const edit = edits.get(id);
|
|
40
|
+
if (omitted.has(id) || edit?.replacement === null) {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
const effective =
|
|
44
|
+
edit?.replacement !== undefined && entry.type === "message"
|
|
45
|
+
? { ...entry, message: { ...entry.message, content: edit.replacement.content } }
|
|
46
|
+
: entry;
|
|
47
|
+
return [
|
|
48
|
+
{ entry: effective, entryId: edit?.id ?? id, inRetained: evidence.retained.has(id) },
|
|
49
|
+
];
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function receiptKey(entry: EntryLike): string | undefined {
|
|
53
|
+
if (entry.type === "message" && entry.message?.role === "toolResult") {
|
|
54
|
+
return entry.message.toolCallId;
|
|
55
|
+
}
|
|
56
|
+
return entry.type === "custom_message" && isRecord(entry.details)
|
|
57
|
+
? optionalString(entry.details.runId)
|
|
58
|
+
: undefined;
|
|
59
|
+
}
|
|
60
|
+
function effectReceipts(branch: readonly EntryLike[]): ReadonlyMap<string, EntryLike> {
|
|
61
|
+
const receipts = new Map<string, EntryLike>();
|
|
62
|
+
for (const entry of branch) {
|
|
63
|
+
const key = receiptKey(entry);
|
|
64
|
+
if (key !== undefined) {
|
|
65
|
+
// A delivered completion wins over the launch, including when an edit omits it.
|
|
66
|
+
receipts.set(key, entry);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return receipts;
|
|
70
|
+
}
|
|
71
|
+
function recordedTouches(
|
|
72
|
+
evidence: NoteEvidence,
|
|
73
|
+
visible: readonly ScannedNoteEntry[],
|
|
74
|
+
): NoteTouch[] {
|
|
75
|
+
const receipts = effectReceipts(evidence.branch);
|
|
76
|
+
const shown = new Map(visible.map((item) => [item.entry.id, item]));
|
|
77
|
+
const seen = new Set<string>();
|
|
78
|
+
return touchRecords(evidence.allEntries).flatMap((record) => {
|
|
79
|
+
const receipt = receipts.get(record.rootToolCallId);
|
|
80
|
+
const item = shown.get(receipt?.id);
|
|
81
|
+
const key = `${record.owner.id}\0${record.owner.timestamp}\0${record.toolCallId}`;
|
|
82
|
+
if (item === undefined || seen.has(key)) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
seen.add(key);
|
|
86
|
+
return record.files.map((file): NoteTouch => {
|
|
87
|
+
let kind: NoteTouch["kind"] = "observed-write";
|
|
88
|
+
if (file.kind === "read") {
|
|
89
|
+
kind = "read";
|
|
90
|
+
} else if (file.kind === "delete" || file.kind === "clear") {
|
|
91
|
+
kind = "clear";
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
notePath: file.path,
|
|
95
|
+
absPath: join(scopeDirectory(file), file.path),
|
|
96
|
+
kind,
|
|
97
|
+
entryId: item.entryId,
|
|
98
|
+
receiptId: item.entry.id ?? item.entryId,
|
|
99
|
+
inRetained: false,
|
|
100
|
+
owner: record.owner,
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function touchKey(touch: NoteTouch): string {
|
|
106
|
+
return JSON.stringify([touch.receiptId ?? touch.entryId, touch.absPath ?? touch.notePath]);
|
|
107
|
+
}
|
|
108
|
+
export function sessionNoteTouches(evidence: NoteEvidence): {
|
|
109
|
+
readonly touches: readonly NoteTouch[];
|
|
110
|
+
readonly defaultDir: string | undefined;
|
|
111
|
+
readonly visibleIds: ReadonlySet<string>;
|
|
112
|
+
} {
|
|
113
|
+
const visible = projectedNotes(evidence);
|
|
114
|
+
const directories = new Set([
|
|
115
|
+
...bindingsOf(evidence.allEntries).map(scopeDirectory),
|
|
116
|
+
...(evidence.directories ?? []),
|
|
117
|
+
]);
|
|
118
|
+
const legacy = collectTouches(visible, directories);
|
|
119
|
+
const recorded = recordedTouches(evidence, visible);
|
|
120
|
+
const recordedByReceipt = new Map(recorded.map((touch) => [touchKey(touch), touch]));
|
|
121
|
+
const covered = new Set<string>();
|
|
122
|
+
const identified = legacy.touches.map((touch) => {
|
|
123
|
+
const key = touchKey(touch);
|
|
124
|
+
covered.add(key);
|
|
125
|
+
return { ...touch, owner: recordedByReceipt.get(key)?.owner };
|
|
126
|
+
});
|
|
127
|
+
const touches = [...identified, ...recorded.filter((touch) => !covered.has(touchKey(touch)))];
|
|
128
|
+
const order = new Map(visible.map((entry, index) => [entry.entry.id ?? entry.entryId, index]));
|
|
129
|
+
touches.sort(
|
|
130
|
+
(a, b) =>
|
|
131
|
+
(order.get(a.receiptId ?? a.entryId) ?? 0) - (order.get(b.receiptId ?? b.entryId) ?? 0),
|
|
132
|
+
);
|
|
133
|
+
return {
|
|
134
|
+
touches,
|
|
135
|
+
defaultDir: legacy.defaultDir,
|
|
136
|
+
visibleIds: new Set(visible.map((item) => item.entry.id ?? "")),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Inherited history describes source bytes, not necessarily the bytes snapshotted at fork time. */
|
|
141
|
+
export function scopeRecoveryTouches(
|
|
142
|
+
touches: readonly NoteTouch[],
|
|
143
|
+
recovery: NotesRecovery | undefined,
|
|
144
|
+
): readonly NoteTouch[] {
|
|
145
|
+
if (recovery === undefined || recovery.binding.kind === "project") {
|
|
146
|
+
return touches;
|
|
147
|
+
}
|
|
148
|
+
const owned = ownedPaths(recovery.binding, recovery.entries);
|
|
149
|
+
const scopes = [
|
|
150
|
+
recovery.binding,
|
|
151
|
+
...bindingsOf(recovery.entries),
|
|
152
|
+
...(recovery.binding.source === null ? [] : [recovery.binding.source]),
|
|
153
|
+
];
|
|
154
|
+
return touches.map((touch): NoteTouch => {
|
|
155
|
+
const address = touch.absPath === undefined ? undefined : addressOf(touch.absPath, scopes);
|
|
156
|
+
if (address !== undefined && sameScope(address, recovery.binding)) {
|
|
157
|
+
return touch;
|
|
158
|
+
}
|
|
159
|
+
const currentOwner =
|
|
160
|
+
touch.owner !== undefined && sameOwner(touch.owner, recovery.binding.owner);
|
|
161
|
+
if (owned.has(touch.notePath) && !currentOwner) {
|
|
162
|
+
return {
|
|
163
|
+
...touch,
|
|
164
|
+
absPath: notePath(recovery.binding, touch.notePath),
|
|
165
|
+
kind:
|
|
166
|
+
touch.kind === "read" || touch.kind === "clear" ? touch.kind : "observed-write",
|
|
167
|
+
inRetained: false,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return address === undefined
|
|
171
|
+
? touch
|
|
172
|
+
: {
|
|
173
|
+
...touch,
|
|
174
|
+
kind: "read",
|
|
175
|
+
referenceScope: scopeDirectory(address),
|
|
176
|
+
inRetained: false,
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
}
|