pi-posthorse 0.10.2 → 0.10.4
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 +24 -21
- package/src/context-tools.ts +2 -2
- package/src/contracts.ts +39 -32
- package/src/files.ts +0 -5
- package/src/guidance.ts +41 -0
- package/src/history-entries.ts +16 -16
- package/src/history-pages.ts +1 -1
- package/src/history-search.ts +6 -6
- package/src/history.ts +5 -2
- 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 +118 -137
- package/src/paging.ts +2 -2
- package/src/recovery-note-touches.ts +389 -0
- package/src/recovery-notes.ts +54 -503
- package/src/recovery-records.ts +90 -0
- package/src/recovery-results.ts +7 -6
- package/src/recovery.ts +24 -108
- package/src/reminders.ts +3 -3
- package/src/retention.ts +23 -17
- package/src/rollover.ts +66 -88
- package/src/session-files.ts +29 -20
- package/src/session-lines.ts +1 -2
- package/src/ui/cards.ts +3 -2
- package/src/ui/display.ts +2 -2
- package/src/ui/text.ts +0 -1
package/src/recovery-notes.ts
CHANGED
|
@@ -1,455 +1,21 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
type EntryLike,
|
|
5
|
-
type MessageLike,
|
|
6
|
-
type ProjectedEntry,
|
|
7
|
-
type ToolCallLike,
|
|
8
|
-
} from "./contracts.ts";
|
|
9
|
-
import { contextEdits } from "./history-entries.ts";
|
|
10
|
-
import { textOf, toolCalls } from "./message-text.ts";
|
|
1
|
+
import type { EntryLike, ProjectedEntry } from "./contracts.ts";
|
|
2
|
+
import { sessionNoteTouches, scopeRecoveryTouches, type NotesRecovery } from "./note-evidence.ts";
|
|
3
|
+
import type { NoteTarget, NoteTouch } from "./recovery-note-touches.ts";
|
|
11
4
|
|
|
12
5
|
const MAX_WRITTEN_NOTES = 4;
|
|
13
6
|
const MAX_READ_NOTES = 3;
|
|
14
7
|
|
|
15
|
-
type ScannedNoteEntry = {
|
|
16
|
-
readonly entry: EntryLike;
|
|
17
|
-
readonly entryId: string;
|
|
18
|
-
readonly inRetained: boolean;
|
|
19
|
-
};
|
|
20
8
|
export type RetainedSpan = { readonly firstId: string; readonly lastId: string };
|
|
21
|
-
type NoteOpKind = "full-write" | "partial-write" | "read" | "clear";
|
|
22
|
-
type NoteTarget = { readonly notePath: string; readonly absPath?: string };
|
|
23
|
-
type NoteTouch = NoteTarget & {
|
|
24
|
-
readonly kind: NoteOpKind;
|
|
25
|
-
readonly entryId: string;
|
|
26
|
-
readonly inRetained: boolean;
|
|
27
|
-
};
|
|
28
9
|
type TrackedNote = NoteTarget & {
|
|
29
10
|
readonly written: boolean;
|
|
11
|
+
readonly metadataOnly: boolean;
|
|
30
12
|
readonly lastEntryId: string;
|
|
31
13
|
readonly lastInRetained: boolean;
|
|
32
|
-
readonly lastWriteEntryId?: string;
|
|
33
|
-
readonly fullWriteRetainedEntryId?: string;
|
|
34
|
-
readonly partialEditRetainedEntryId?: string;
|
|
14
|
+
readonly lastWriteEntryId?: string | undefined;
|
|
15
|
+
readonly fullWriteRetainedEntryId?: string | undefined;
|
|
16
|
+
readonly partialEditRetainedEntryId?: string | undefined;
|
|
35
17
|
readonly order: number;
|
|
36
18
|
};
|
|
37
|
-
type ToolResultInfo = {
|
|
38
|
-
readonly entryId: string;
|
|
39
|
-
readonly inRetained: boolean;
|
|
40
|
-
readonly isError: boolean;
|
|
41
|
-
readonly toolName: string;
|
|
42
|
-
readonly text: string;
|
|
43
|
-
readonly details: unknown;
|
|
44
|
-
};
|
|
45
|
-
type TouchContext = {
|
|
46
|
-
readonly entryId: string;
|
|
47
|
-
readonly inRetained: boolean;
|
|
48
|
-
readonly knownDirs: ReadonlySet<string>;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
function bareToolName(name: string | undefined): string {
|
|
52
|
-
if (name === undefined || name === "") {
|
|
53
|
-
return "";
|
|
54
|
-
}
|
|
55
|
-
const cut = Math.max(name.lastIndexOf(":"), name.lastIndexOf("/"));
|
|
56
|
-
return cut >= 0 ? name.slice(cut + 1) : name;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function normalizeRelPath(raw: string): string {
|
|
60
|
-
const cleaned = raw
|
|
61
|
-
.trim()
|
|
62
|
-
.replaceAll("\\", "/")
|
|
63
|
-
.replace(/^(?:\.\/)+/u, "")
|
|
64
|
-
.replace(/^\/+/u, "");
|
|
65
|
-
const invalid =
|
|
66
|
-
cleaned === "" ||
|
|
67
|
-
cleaned === "." ||
|
|
68
|
-
cleaned === ".." ||
|
|
69
|
-
cleaned.startsWith("../") ||
|
|
70
|
-
cleaned.includes("/../") ||
|
|
71
|
-
cleaned.endsWith("/");
|
|
72
|
-
return invalid ? "" : cleaned;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function matchMarkerPath(slash: string): string | undefined {
|
|
76
|
-
for (const marker of ["/.pi/notes/", "/posthorse-notes/"]) {
|
|
77
|
-
const idx = slash.lastIndexOf(marker);
|
|
78
|
-
if (idx >= 0) {
|
|
79
|
-
return slash.slice(idx + marker.length);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
if (slash.startsWith(".pi/notes/") || slash.startsWith("./.pi/notes/")) {
|
|
83
|
-
return slash.replace(/^(?:\.\/)?\.pi\/notes\//u, "");
|
|
84
|
-
}
|
|
85
|
-
const pkgMatch = /\/\.pi\/package-notes\/[0-9a-f]{64}\/notes\/(.+)$/u.exec(slash);
|
|
86
|
-
if (pkgMatch !== null) {
|
|
87
|
-
return pkgMatch[1];
|
|
88
|
-
}
|
|
89
|
-
return /\/\.pi\/(?:agent\/)?notes\/[^/]+\/[^/]+\/[^/]+\/(.+)$/u.exec(slash)?.[1];
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function matchKnownDir(slash: string, knownDirs: ReadonlySet<string>): string | undefined {
|
|
93
|
-
for (const dir of knownDirs) {
|
|
94
|
-
if (slash.startsWith(`${dir}/`)) {
|
|
95
|
-
return slash.slice(dir.length + 1);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return undefined;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function resolveNoteTarget(
|
|
102
|
-
rawPath: string | undefined,
|
|
103
|
-
knownDirs: ReadonlySet<string>,
|
|
104
|
-
fromNotesTool: boolean,
|
|
105
|
-
): NoteTarget | undefined {
|
|
106
|
-
if (rawPath === undefined) {
|
|
107
|
-
return undefined;
|
|
108
|
-
}
|
|
109
|
-
const trimmed = rawPath.trim();
|
|
110
|
-
const slash = trimmed.replaceAll("\\", "/");
|
|
111
|
-
const matched = matchKnownDir(slash, knownDirs) ?? matchMarkerPath(slash);
|
|
112
|
-
if (matched !== undefined) {
|
|
113
|
-
const rel = normalizeRelPath(matched);
|
|
114
|
-
return rel === "" ? undefined : { notePath: rel, absPath: trimmed };
|
|
115
|
-
}
|
|
116
|
-
if (!fromNotesTool) {
|
|
117
|
-
return undefined;
|
|
118
|
-
}
|
|
119
|
-
if (slash.startsWith("/") || /^[A-Za-z]:\//u.test(slash)) {
|
|
120
|
-
const base = normalizeRelPath(slash.slice(slash.lastIndexOf("/") + 1));
|
|
121
|
-
return base === "" ? undefined : { notePath: base, absPath: trimmed };
|
|
122
|
-
}
|
|
123
|
-
const rel = normalizeRelPath(slash);
|
|
124
|
-
return rel === "" ? undefined : { notePath: rel };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function extractNotesResultTarget(
|
|
128
|
-
text: string,
|
|
129
|
-
): { readonly kind: NoteOpKind; readonly rawPath: string } | undefined {
|
|
130
|
-
const patterns: readonly (readonly [RegExp, NoteOpKind])[] = [
|
|
131
|
-
[/^Wrote ([^\r\n]+)$/mu, "full-write"],
|
|
132
|
-
[/^Appended to ([^\r\n]+)$/mu, "partial-write"],
|
|
133
|
-
[/^File: ([^\r\n]+)$/mu, "read"],
|
|
134
|
-
];
|
|
135
|
-
for (const [pattern, kind] of patterns) {
|
|
136
|
-
const match = pattern.exec(text);
|
|
137
|
-
if (match !== null) {
|
|
138
|
-
return { kind, rawPath: match[1] };
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
return undefined;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function collectResultMap(
|
|
145
|
-
entries: readonly ScannedNoteEntry[],
|
|
146
|
-
): ReadonlyMap<string, ToolResultInfo> {
|
|
147
|
-
const results = new Map<string, ToolResultInfo>();
|
|
148
|
-
for (const item of entries) {
|
|
149
|
-
const msg = item.entry.message;
|
|
150
|
-
const callId = optionalString(msg?.toolCallId);
|
|
151
|
-
if (
|
|
152
|
-
item.entry.type === "message" &&
|
|
153
|
-
msg?.role === "toolResult" &&
|
|
154
|
-
callId !== undefined &&
|
|
155
|
-
callId !== ""
|
|
156
|
-
) {
|
|
157
|
-
results.set(callId, {
|
|
158
|
-
entryId: item.entryId,
|
|
159
|
-
inRetained: item.inRetained,
|
|
160
|
-
isError: msg.isError === true,
|
|
161
|
-
toolName: bareToolName(msg.toolName),
|
|
162
|
-
text: textOf(msg),
|
|
163
|
-
details: msg.details,
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
return results;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function inferDirFromCall(
|
|
171
|
-
call: ToolCallLike,
|
|
172
|
-
results: ReadonlyMap<string, ToolResultInfo>,
|
|
173
|
-
): string | undefined {
|
|
174
|
-
const callId = optionalString(call.id);
|
|
175
|
-
const res = callId === undefined ? undefined : results.get(callId);
|
|
176
|
-
if (res === undefined || res.isError) {
|
|
177
|
-
return undefined;
|
|
178
|
-
}
|
|
179
|
-
const parsed = extractNotesResultTarget(res.text);
|
|
180
|
-
const rawCallPath = isRecord(call.arguments) ? optionalString(call.arguments.path) : undefined;
|
|
181
|
-
const callPath = rawCallPath === undefined ? "" : normalizeRelPath(rawCallPath);
|
|
182
|
-
if (parsed === undefined || callPath === "") {
|
|
183
|
-
return undefined;
|
|
184
|
-
}
|
|
185
|
-
const slash = parsed.rawPath.trim().replaceAll("\\", "/");
|
|
186
|
-
const suffix = `/${callPath}`;
|
|
187
|
-
return slash.endsWith(suffix) && slash.length > suffix.length
|
|
188
|
-
? slash.slice(0, slash.length - suffix.length)
|
|
189
|
-
: undefined;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function assistantCallsOf(entry: EntryLike): readonly ToolCallLike[] {
|
|
193
|
-
return entry.type === "message" && entry.message?.role === "assistant"
|
|
194
|
-
? toolCalls(entry.message.content)
|
|
195
|
-
: [];
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function learnNotesDirs(
|
|
199
|
-
entries: readonly ScannedNoteEntry[],
|
|
200
|
-
results: ReadonlyMap<string, ToolResultInfo>,
|
|
201
|
-
): ReadonlySet<string> {
|
|
202
|
-
const dirs = new Set<string>();
|
|
203
|
-
for (const info of results.values()) {
|
|
204
|
-
const match =
|
|
205
|
-
!info.isError && info.toolName === "notes"
|
|
206
|
-
? /^Notes directory: ([^\r\n]+)$/mu.exec(info.text)
|
|
207
|
-
: null;
|
|
208
|
-
if (match !== null) {
|
|
209
|
-
dirs.add(match[1].trim().replaceAll("\\", "/").replace(/\/+$/u, ""));
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
for (const item of entries) {
|
|
213
|
-
for (const call of assistantCallsOf(item.entry)) {
|
|
214
|
-
const inferred = inferDirFromCall(call, results);
|
|
215
|
-
if (inferred !== undefined) {
|
|
216
|
-
dirs.add(inferred);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
return dirs;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function pathsFromFileList(value: unknown): string[] {
|
|
224
|
-
if (!Array.isArray(value)) {
|
|
225
|
-
return [];
|
|
226
|
-
}
|
|
227
|
-
return value.flatMap((item: unknown) => {
|
|
228
|
-
if (typeof item === "string") {
|
|
229
|
-
return [item];
|
|
230
|
-
}
|
|
231
|
-
const itemPath = isRecord(item) ? optionalString(item.path) : undefined;
|
|
232
|
-
const skipped =
|
|
233
|
-
isRecord(item) && (item.status === "failed" || item.status === "unattempted");
|
|
234
|
-
return itemPath !== undefined && !skipped ? [itemPath] : [];
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function patchPaths(input: unknown): string[] {
|
|
239
|
-
return typeof input === "string"
|
|
240
|
-
? Array.from(
|
|
241
|
-
input.matchAll(/^\*\*\* (?:Update File|Add File|Move to): ([^\r\n]+)$/gmu),
|
|
242
|
-
(m) => m[1],
|
|
243
|
-
)
|
|
244
|
-
: [];
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function detailFilePaths(details: unknown): string[] {
|
|
248
|
-
return isRecord(details)
|
|
249
|
-
? [...pathsFromFileList(details.modifiedFiles), ...pathsFromFileList(details.files)]
|
|
250
|
-
: [];
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function extractFileToolSpec(
|
|
254
|
-
tool: string,
|
|
255
|
-
args: Readonly<Record<string, unknown>>,
|
|
256
|
-
details: unknown,
|
|
257
|
-
): { readonly kind: NoteOpKind; readonly rawPaths: readonly string[] } | undefined {
|
|
258
|
-
if (args.preview === true) {
|
|
259
|
-
return undefined;
|
|
260
|
-
}
|
|
261
|
-
const directPath = optionalString(args.path);
|
|
262
|
-
const directKinds: Readonly<Record<string, NoteOpKind | undefined>> = {
|
|
263
|
-
write: args.content === "" ? "clear" : "full-write",
|
|
264
|
-
edit: "partial-write",
|
|
265
|
-
read: "read",
|
|
266
|
-
};
|
|
267
|
-
const directKind = directKinds[tool];
|
|
268
|
-
if (directKind !== undefined && directPath !== undefined) {
|
|
269
|
-
return { kind: directKind, rawPaths: [directPath] };
|
|
270
|
-
}
|
|
271
|
-
if (tool === "write_files" || tool === "replace_text") {
|
|
272
|
-
const kind: NoteOpKind = tool === "write_files" ? "full-write" : "partial-write";
|
|
273
|
-
return { kind, rawPaths: [...pathsFromFileList(args.files), ...detailFilePaths(details)] };
|
|
274
|
-
}
|
|
275
|
-
return tool === "apply_patch"
|
|
276
|
-
? {
|
|
277
|
-
kind: "partial-write",
|
|
278
|
-
rawPaths: [...patchPaths(args.input), ...detailFilePaths(details)],
|
|
279
|
-
}
|
|
280
|
-
: undefined;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function fileToolTouches(
|
|
284
|
-
tool: string,
|
|
285
|
-
args: Readonly<Record<string, unknown>>,
|
|
286
|
-
details: unknown,
|
|
287
|
-
context: TouchContext,
|
|
288
|
-
): NoteTouch[] {
|
|
289
|
-
const spec = extractFileToolSpec(tool, args, details);
|
|
290
|
-
if (spec === undefined) {
|
|
291
|
-
return [];
|
|
292
|
-
}
|
|
293
|
-
return spec.rawPaths.flatMap((raw) => {
|
|
294
|
-
const target = resolveNoteTarget(raw, context.knownDirs, false);
|
|
295
|
-
return target === undefined
|
|
296
|
-
? []
|
|
297
|
-
: [
|
|
298
|
-
{
|
|
299
|
-
...target,
|
|
300
|
-
kind: spec.kind,
|
|
301
|
-
entryId: context.entryId,
|
|
302
|
-
inRetained: context.inRetained,
|
|
303
|
-
},
|
|
304
|
-
];
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function notesCallKind(args: Readonly<Record<string, unknown>>): NoteOpKind | undefined {
|
|
309
|
-
if (args.op === "write") {
|
|
310
|
-
return args.content === "" ? "clear" : "full-write";
|
|
311
|
-
}
|
|
312
|
-
if (args.op === "append") {
|
|
313
|
-
return "partial-write";
|
|
314
|
-
}
|
|
315
|
-
return args.op === "read" ? "read" : undefined;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function mergeNoteTargets(
|
|
319
|
-
callTarget: NoteTarget | undefined,
|
|
320
|
-
resTarget: NoteTarget | undefined,
|
|
321
|
-
): NoteTarget | undefined {
|
|
322
|
-
const notePath = callTarget?.notePath ?? resTarget?.notePath;
|
|
323
|
-
return notePath === undefined
|
|
324
|
-
? undefined
|
|
325
|
-
: { notePath, absPath: resTarget?.absPath ?? callTarget?.absPath };
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
function notesToolTouches(
|
|
329
|
-
args: Readonly<Record<string, unknown>>,
|
|
330
|
-
resultText: string | undefined,
|
|
331
|
-
context: TouchContext,
|
|
332
|
-
): NoteTouch[] {
|
|
333
|
-
const parsed = resultText === undefined ? undefined : extractNotesResultTarget(resultText);
|
|
334
|
-
const kind = notesCallKind(args) ?? parsed?.kind;
|
|
335
|
-
const callTarget = resolveNoteTarget(optionalString(args.path), context.knownDirs, true);
|
|
336
|
-
const resTarget = resolveNoteTarget(parsed?.rawPath, context.knownDirs, true);
|
|
337
|
-
const merged = mergeNoteTargets(callTarget, resTarget);
|
|
338
|
-
return kind === undefined || merged === undefined
|
|
339
|
-
? []
|
|
340
|
-
: [{ ...merged, kind, entryId: context.entryId, inRetained: context.inRetained }];
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function extractNestedCalls(message: MessageLike): readonly unknown[] {
|
|
344
|
-
const nested = isRecord(message.nestedCalls) ? message.nestedCalls.calls : undefined;
|
|
345
|
-
if (Array.isArray(nested)) {
|
|
346
|
-
return nested;
|
|
347
|
-
}
|
|
348
|
-
const details = isRecord(message.details) ? message.details : undefined;
|
|
349
|
-
if (details !== undefined && Array.isArray(details.calls)) {
|
|
350
|
-
return details.calls;
|
|
351
|
-
}
|
|
352
|
-
const detailNested =
|
|
353
|
-
details !== undefined && isRecord(details.nestedCalls)
|
|
354
|
-
? details.nestedCalls.calls
|
|
355
|
-
: undefined;
|
|
356
|
-
return Array.isArray(detailNested) ? detailNested : [];
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function nestedArgs(raw: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> {
|
|
360
|
-
if (isRecord(raw.arguments)) {
|
|
361
|
-
return raw.arguments;
|
|
362
|
-
}
|
|
363
|
-
if (isRecord(raw.args)) {
|
|
364
|
-
return raw.args;
|
|
365
|
-
}
|
|
366
|
-
return isRecord(raw.input) ? raw.input : {};
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function touchesFromNested(message: MessageLike, context: TouchContext): NoteTouch[] {
|
|
370
|
-
return extractNestedCalls(message).flatMap((raw) => {
|
|
371
|
-
if (
|
|
372
|
-
!isRecord(raw) ||
|
|
373
|
-
raw.isError === true ||
|
|
374
|
-
raw.status === "error" ||
|
|
375
|
-
(typeof raw.error === "string" && raw.error !== "")
|
|
376
|
-
) {
|
|
377
|
-
return [];
|
|
378
|
-
}
|
|
379
|
-
const name = bareToolName(optionalString(raw.name) ?? optionalString(raw.toolName));
|
|
380
|
-
const args = nestedArgs(raw);
|
|
381
|
-
return name === "notes"
|
|
382
|
-
? notesToolTouches(args, optionalString(raw.result), context)
|
|
383
|
-
: fileToolTouches(name, args, raw.result, context);
|
|
384
|
-
});
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
function touchesFromAssistantCall(
|
|
388
|
-
call: ToolCallLike,
|
|
389
|
-
results: ReadonlyMap<string, ToolResultInfo>,
|
|
390
|
-
context: TouchContext,
|
|
391
|
-
): { readonly consumedCallId?: string; readonly touches: readonly NoteTouch[] } {
|
|
392
|
-
const callId = optionalString(call.id);
|
|
393
|
-
const res = callId === undefined ? undefined : results.get(callId);
|
|
394
|
-
const consumedCallId = callId !== undefined && res !== undefined ? callId : undefined;
|
|
395
|
-
if (res?.isError === true) {
|
|
396
|
-
return { consumedCallId, touches: [] };
|
|
397
|
-
}
|
|
398
|
-
const tool = bareToolName(call.name);
|
|
399
|
-
const args = isRecord(call.arguments) ? call.arguments : {};
|
|
400
|
-
const touches =
|
|
401
|
-
tool === "notes"
|
|
402
|
-
? notesToolTouches(args, res?.text, context)
|
|
403
|
-
: fileToolTouches(tool, args, res?.details, context);
|
|
404
|
-
return { consumedCallId, touches };
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function touchesFromToolResult(
|
|
408
|
-
msg: MessageLike,
|
|
409
|
-
handledResultIds: ReadonlySet<string>,
|
|
410
|
-
context: TouchContext,
|
|
411
|
-
): NoteTouch[] {
|
|
412
|
-
if (msg.role !== "toolResult" || msg.isError === true) {
|
|
413
|
-
return [];
|
|
414
|
-
}
|
|
415
|
-
const callId = optionalString(msg.toolCallId) ?? "";
|
|
416
|
-
const direct =
|
|
417
|
-
!handledResultIds.has(callId) && bareToolName(msg.toolName) === "notes"
|
|
418
|
-
? notesToolTouches({}, textOf(msg), context)
|
|
419
|
-
: [];
|
|
420
|
-
return [...direct, ...touchesFromNested(msg, context)];
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
function collectTouches(entries: readonly ScannedNoteEntry[]): {
|
|
424
|
-
readonly touches: readonly NoteTouch[];
|
|
425
|
-
readonly defaultDir?: string;
|
|
426
|
-
} {
|
|
427
|
-
const results = collectResultMap(entries);
|
|
428
|
-
const knownDirs = learnNotesDirs(entries, results);
|
|
429
|
-
const handledResultIds = new Set<string>();
|
|
430
|
-
const touches: NoteTouch[] = [];
|
|
431
|
-
for (const item of entries) {
|
|
432
|
-
const msg = item.entry.message;
|
|
433
|
-
const context: TouchContext = {
|
|
434
|
-
entryId: item.entryId,
|
|
435
|
-
inRetained: item.inRetained,
|
|
436
|
-
knownDirs,
|
|
437
|
-
};
|
|
438
|
-
for (const call of assistantCallsOf(item.entry)) {
|
|
439
|
-
const out = touchesFromAssistantCall(call, results, context);
|
|
440
|
-
if (out.consumedCallId !== undefined) {
|
|
441
|
-
handledResultIds.add(out.consumedCallId);
|
|
442
|
-
}
|
|
443
|
-
touches.push(...out.touches);
|
|
444
|
-
}
|
|
445
|
-
if (item.entry.type === "message" && msg !== undefined) {
|
|
446
|
-
touches.push(...touchesFromToolResult(msg, handledResultIds, context));
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
const defaultDir = knownDirs.size === 1 ? [...knownDirs][0] : undefined;
|
|
450
|
-
return { touches, defaultDir };
|
|
451
|
-
}
|
|
452
|
-
|
|
453
19
|
function applyReadTouch(
|
|
454
20
|
previous: TrackedNote | undefined,
|
|
455
21
|
touch: NoteTouch,
|
|
@@ -457,14 +23,14 @@ function applyReadTouch(
|
|
|
457
23
|
): TrackedNote {
|
|
458
24
|
const wasWritten = previous?.written === true;
|
|
459
25
|
return {
|
|
26
|
+
...previous,
|
|
460
27
|
notePath: touch.notePath,
|
|
461
28
|
absPath: touch.absPath ?? previous?.absPath,
|
|
462
29
|
written: wasWritten,
|
|
30
|
+
metadataOnly: previous?.metadataOnly ?? false,
|
|
31
|
+
referenceScope: touch.referenceScope ?? previous?.referenceScope,
|
|
463
32
|
lastEntryId: touch.entryId,
|
|
464
33
|
lastInRetained: touch.inRetained,
|
|
465
|
-
lastWriteEntryId: previous?.lastWriteEntryId,
|
|
466
|
-
fullWriteRetainedEntryId: previous?.fullWriteRetainedEntryId,
|
|
467
|
-
partialEditRetainedEntryId: previous?.partialEditRetainedEntryId,
|
|
468
34
|
order: wasWritten ? previous.order : order,
|
|
469
35
|
};
|
|
470
36
|
}
|
|
@@ -474,17 +40,19 @@ function applyWriteTouch(
|
|
|
474
40
|
touch: NoteTouch,
|
|
475
41
|
order: number,
|
|
476
42
|
): TrackedNote {
|
|
43
|
+
const previousFull =
|
|
44
|
+
touch.kind === "observed-write" ? undefined : previous?.fullWriteRetainedEntryId;
|
|
477
45
|
return {
|
|
478
46
|
notePath: touch.notePath,
|
|
479
47
|
absPath: touch.absPath ?? previous?.absPath,
|
|
480
48
|
written: true,
|
|
49
|
+
metadataOnly: touch.kind === "observed-write",
|
|
50
|
+
referenceScope: touch.referenceScope,
|
|
481
51
|
lastEntryId: touch.entryId,
|
|
482
52
|
lastInRetained: touch.inRetained,
|
|
483
53
|
lastWriteEntryId: touch.entryId,
|
|
484
54
|
fullWriteRetainedEntryId:
|
|
485
|
-
touch.kind === "full-write" && touch.inRetained
|
|
486
|
-
? touch.entryId
|
|
487
|
-
: previous?.fullWriteRetainedEntryId,
|
|
55
|
+
touch.kind === "full-write" && touch.inRetained ? touch.entryId : previousFull,
|
|
488
56
|
partialEditRetainedEntryId:
|
|
489
57
|
touch.kind === "partial-write" && touch.inRetained
|
|
490
58
|
? touch.entryId
|
|
@@ -494,6 +62,9 @@ function applyWriteTouch(
|
|
|
494
62
|
}
|
|
495
63
|
|
|
496
64
|
function formatNoteLabel(note: TrackedNote): string {
|
|
65
|
+
if (note.referenceScope !== undefined) {
|
|
66
|
+
return `${note.notePath} (read-only reference; notes read ${JSON.stringify({ scope: note.referenceScope, path: note.notePath })})`;
|
|
67
|
+
}
|
|
497
68
|
return note.absPath !== undefined && note.absPath !== note.notePath
|
|
498
69
|
? `${note.notePath} (${note.absPath})`
|
|
499
70
|
: note.notePath;
|
|
@@ -502,6 +73,9 @@ function formatNoteLabel(note: TrackedNote): string {
|
|
|
502
73
|
function formatWrittenNoteLine(note: TrackedNote, span: RetainedSpan | undefined): string {
|
|
503
74
|
const label = formatNoteLabel(note);
|
|
504
75
|
const writeId = note.lastWriteEntryId ?? note.lastEntryId;
|
|
76
|
+
if (note.metadataOnly) {
|
|
77
|
+
return `- Active note (written): ${label} [verified effect at entry ${writeId}; full content is not retained — read this note now]`;
|
|
78
|
+
}
|
|
505
79
|
if (span === undefined) {
|
|
506
80
|
return `- Active note (written): ${label} [compacted at entry ${writeId} — read this note now]`;
|
|
507
81
|
}
|
|
@@ -542,56 +116,10 @@ function formatNotesAction(
|
|
|
542
116
|
return `Read ${primaryWritten.notePath} (notes read or read on its path) before continuing to restore the saved goal, progress, decisions, and next steps, then keep that note current as work progresses.`;
|
|
543
117
|
}
|
|
544
118
|
|
|
545
|
-
function
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
retained: ReadonlySet<string>,
|
|
550
|
-
): ScannedNoteEntry[] {
|
|
551
|
-
const edits = contextEdits(allEntries);
|
|
552
|
-
const omittedIds = new Set(
|
|
553
|
-
projected
|
|
554
|
-
.filter(({ messages }) => messages.length === 0)
|
|
555
|
-
.map(({ sourceEntry }) => sourceEntry.id),
|
|
556
|
-
);
|
|
557
|
-
return chainEntries.flatMap((entry) => {
|
|
558
|
-
const rawId = entry.id ?? "";
|
|
559
|
-
const edit = edits.get(rawId);
|
|
560
|
-
if (omittedIds.has(rawId) || edit?.replacement === null) {
|
|
561
|
-
return [];
|
|
562
|
-
}
|
|
563
|
-
const effective =
|
|
564
|
-
edit?.replacement !== undefined && entry.type === "message"
|
|
565
|
-
? {
|
|
566
|
-
...entry,
|
|
567
|
-
id: edit.id,
|
|
568
|
-
message: { ...entry.message, content: edit.replacement.content },
|
|
569
|
-
}
|
|
570
|
-
: entry;
|
|
571
|
-
const resolvedId = (effective.id ?? rawId).slice(0, 120);
|
|
572
|
-
return [
|
|
573
|
-
{
|
|
574
|
-
entry: effective,
|
|
575
|
-
entryId: resolvedId === "" ? "unknown" : resolvedId,
|
|
576
|
-
inRetained: retained.has(rawId),
|
|
577
|
-
},
|
|
578
|
-
];
|
|
579
|
-
});
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
export function renderSessionNotes(
|
|
583
|
-
chainEntries: readonly EntryLike[],
|
|
584
|
-
allEntries: readonly EntryLike[],
|
|
585
|
-
projected: readonly ProjectedEntry[],
|
|
586
|
-
options: {
|
|
587
|
-
readonly retained: ReadonlySet<string>;
|
|
588
|
-
readonly span: RetainedSpan | undefined;
|
|
589
|
-
readonly maxChars: number;
|
|
590
|
-
},
|
|
591
|
-
): string | undefined {
|
|
592
|
-
const { retained, span, maxChars } = options;
|
|
593
|
-
const entries = projectNoteEntries(chainEntries, allEntries, projected, retained);
|
|
594
|
-
const { touches, defaultDir } = collectTouches(entries);
|
|
119
|
+
function trackedNotes(
|
|
120
|
+
touches: readonly NoteTouch[],
|
|
121
|
+
defaultDir: string | undefined,
|
|
122
|
+
): TrackedNote[] {
|
|
595
123
|
const tracked = new Map<string, TrackedNote>();
|
|
596
124
|
let order = 0;
|
|
597
125
|
for (const rawTouch of touches) {
|
|
@@ -600,18 +128,41 @@ export function renderSessionNotes(
|
|
|
600
128
|
rawTouch.absPath === undefined && defaultDir !== undefined
|
|
601
129
|
? { ...rawTouch, absPath: `${defaultDir}/${rawTouch.notePath}` }
|
|
602
130
|
: rawTouch;
|
|
131
|
+
const key = `${touch.referenceScope ?? touch.absPath ?? ""}\0${touch.notePath}`;
|
|
603
132
|
if (touch.kind === "clear") {
|
|
604
|
-
tracked.delete(
|
|
133
|
+
tracked.delete(key);
|
|
605
134
|
} else if (touch.kind === "read") {
|
|
606
|
-
tracked.set(
|
|
135
|
+
tracked.set(key, applyReadTouch(tracked.get(key), touch, order));
|
|
607
136
|
} else {
|
|
608
|
-
tracked.set(
|
|
137
|
+
tracked.set(key, applyWriteTouch(tracked.get(key), touch, order));
|
|
609
138
|
}
|
|
610
139
|
}
|
|
611
|
-
|
|
612
|
-
|
|
140
|
+
return [...tracked.values()].sort((a, b) => b.order - a.order);
|
|
141
|
+
}
|
|
142
|
+
export function renderSessionNotes(
|
|
143
|
+
chainEntries: readonly EntryLike[],
|
|
144
|
+
allEntries: readonly EntryLike[],
|
|
145
|
+
projected: readonly ProjectedEntry[],
|
|
146
|
+
options: {
|
|
147
|
+
readonly retained: ReadonlySet<string>;
|
|
148
|
+
readonly span: RetainedSpan | undefined;
|
|
149
|
+
readonly maxChars: number;
|
|
150
|
+
readonly notes?: NotesRecovery | undefined;
|
|
151
|
+
},
|
|
152
|
+
): string | undefined {
|
|
153
|
+
const { retained, span, maxChars } = options;
|
|
154
|
+
const collected = sessionNoteTouches({
|
|
155
|
+
branch: chainEntries,
|
|
156
|
+
allEntries: options.notes?.entries ?? allEntries,
|
|
157
|
+
projected,
|
|
158
|
+
retained,
|
|
159
|
+
});
|
|
160
|
+
const touches = scopeRecoveryTouches(collected.touches, options.notes);
|
|
161
|
+
const { defaultDir } = collected;
|
|
162
|
+
const all = trackedNotes(touches, defaultDir);
|
|
163
|
+
if (all.length === 0) {
|
|
164
|
+
return;
|
|
613
165
|
}
|
|
614
|
-
const all = [...tracked.values()].sort((a, b) => b.order - a.order);
|
|
615
166
|
const written = all.filter((note) => note.written).slice(0, MAX_WRITTEN_NOTES);
|
|
616
167
|
const readOnly = all.filter((note) => !note.written).slice(0, MAX_READ_NOTES);
|
|
617
168
|
const lines = [
|