pi-posthorse 0.10.1 → 0.10.3

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.
@@ -0,0 +1,436 @@
1
+ import {
2
+ isRecord,
3
+ optionalString,
4
+ type EntryLike,
5
+ type MessageLike,
6
+ type ToolCallLike,
7
+ } from "./contracts.ts";
8
+ import { textOf, toolCalls } from "./message-text.ts";
9
+
10
+ export type ScannedNoteEntry = {
11
+ readonly entry: EntryLike;
12
+ readonly entryId: string;
13
+ readonly inRetained: boolean;
14
+ };
15
+ type NoteOpKind = "full-write" | "partial-write" | "read" | "clear";
16
+ export type NoteTarget = { readonly notePath: string; readonly absPath?: string | undefined };
17
+ export type NoteTouch = NoteTarget & {
18
+ readonly kind: NoteOpKind;
19
+ readonly entryId: string;
20
+ readonly inRetained: boolean;
21
+ };
22
+ type ToolResultInfo = {
23
+ readonly entryId: string;
24
+ readonly inRetained: boolean;
25
+ readonly isError: boolean;
26
+ readonly toolName: string;
27
+ readonly text: string;
28
+ readonly details: unknown;
29
+ };
30
+ type TouchContext = {
31
+ readonly entryId: string;
32
+ readonly inRetained: boolean;
33
+ readonly knownDirs: ReadonlySet<string>;
34
+ };
35
+
36
+ function bareToolName(name: string | undefined): string {
37
+ if (name === undefined || name === "") {
38
+ return "";
39
+ }
40
+ const cut = Math.max(name.lastIndexOf(":"), name.lastIndexOf("/"));
41
+ return cut >= 0 ? name.slice(cut + 1) : name;
42
+ }
43
+
44
+ function normalizeRelPath(raw: string): string {
45
+ const cleaned = raw
46
+ .trim()
47
+ .replaceAll("\\", "/")
48
+ .replace(/^(?:\.\/)+/u, "")
49
+ .replace(/^\/+/u, "");
50
+ const invalid =
51
+ cleaned === "" ||
52
+ cleaned === "." ||
53
+ cleaned === ".." ||
54
+ cleaned.startsWith("../") ||
55
+ cleaned.includes("/../") ||
56
+ cleaned.endsWith("/");
57
+ return invalid ? "" : cleaned;
58
+ }
59
+
60
+ function matchMarkerPath(slash: string): string | undefined {
61
+ for (const marker of ["/.pi/notes/", "/posthorse-notes/"]) {
62
+ const idx = slash.lastIndexOf(marker);
63
+ if (idx >= 0) {
64
+ return slash.slice(idx + marker.length);
65
+ }
66
+ }
67
+ if (slash.startsWith(".pi/notes/") || slash.startsWith("./.pi/notes/")) {
68
+ return slash.replace(/^(?:\.\/)?\.pi\/notes\//u, "");
69
+ }
70
+ const pkgMatch = /\/\.pi\/package-notes\/[0-9a-f]{64}\/notes\/(.+)$/u.exec(slash);
71
+ if (pkgMatch !== null) {
72
+ return pkgMatch[1];
73
+ }
74
+ return /\/\.pi\/(?:agent\/)?notes\/[^/]+\/[^/]+\/[^/]+\/(.+)$/u.exec(slash)?.[1];
75
+ }
76
+
77
+ function matchKnownDir(slash: string, knownDirs: ReadonlySet<string>): string | undefined {
78
+ for (const dir of knownDirs) {
79
+ if (slash.startsWith(`${dir}/`)) {
80
+ return slash.slice(dir.length + 1);
81
+ }
82
+ }
83
+ return undefined;
84
+ }
85
+
86
+ function resolveNoteTarget(
87
+ rawPath: string | undefined,
88
+ knownDirs: ReadonlySet<string>,
89
+ fromNotesTool: boolean,
90
+ ): NoteTarget | undefined {
91
+ if (rawPath === undefined) {
92
+ return undefined;
93
+ }
94
+ const trimmed = rawPath.trim();
95
+ const slash = trimmed.replaceAll("\\", "/");
96
+ const matched = matchKnownDir(slash, knownDirs) ?? matchMarkerPath(slash);
97
+ if (matched !== undefined) {
98
+ const rel = normalizeRelPath(matched);
99
+ return rel === "" ? undefined : { notePath: rel, absPath: trimmed };
100
+ }
101
+ if (!fromNotesTool) {
102
+ return undefined;
103
+ }
104
+ if (slash.startsWith("/") || /^[A-Za-z]:\//u.test(slash)) {
105
+ const base = normalizeRelPath(slash.slice(slash.lastIndexOf("/") + 1));
106
+ return base === "" ? undefined : { notePath: base, absPath: trimmed };
107
+ }
108
+ const rel = normalizeRelPath(slash);
109
+ return rel === "" ? undefined : { notePath: rel };
110
+ }
111
+
112
+ function extractNotesResultTarget(
113
+ text: string,
114
+ ): { readonly kind: NoteOpKind; readonly rawPath: string } | undefined {
115
+ const patterns: readonly (readonly [RegExp, NoteOpKind])[] = [
116
+ [/^Wrote ([^\r\n]+)$/mu, "full-write"],
117
+ [/^Appended to ([^\r\n]+)$/mu, "partial-write"],
118
+ [/^File: ([^\r\n]+)$/mu, "read"],
119
+ ];
120
+ for (const [pattern, kind] of patterns) {
121
+ const rawPath = pattern.exec(text)?.[1];
122
+ if (rawPath !== undefined) {
123
+ return { kind, rawPath };
124
+ }
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ function collectResultMap(
130
+ entries: readonly ScannedNoteEntry[],
131
+ ): ReadonlyMap<string, ToolResultInfo> {
132
+ const results = new Map<string, ToolResultInfo>();
133
+ for (const item of entries) {
134
+ const msg = item.entry.message;
135
+ const callId = optionalString(msg?.toolCallId);
136
+ if (
137
+ item.entry.type === "message" &&
138
+ msg?.role === "toolResult" &&
139
+ callId !== undefined &&
140
+ callId !== ""
141
+ ) {
142
+ results.set(callId, {
143
+ entryId: item.entryId,
144
+ inRetained: item.inRetained,
145
+ isError: msg.isError === true,
146
+ toolName: bareToolName(msg.toolName),
147
+ text: textOf(msg),
148
+ details: msg.details,
149
+ });
150
+ }
151
+ }
152
+ return results;
153
+ }
154
+
155
+ function inferDirFromCall(
156
+ call: ToolCallLike,
157
+ results: ReadonlyMap<string, ToolResultInfo>,
158
+ ): string | undefined {
159
+ const callId = optionalString(call.id);
160
+ const res = callId === undefined ? undefined : results.get(callId);
161
+ if (res === undefined || res.isError) {
162
+ return undefined;
163
+ }
164
+ const parsed = extractNotesResultTarget(res.text);
165
+ const rawCallPath = isRecord(call.arguments) ? optionalString(call.arguments.path) : undefined;
166
+ const callPath = rawCallPath === undefined ? "" : normalizeRelPath(rawCallPath);
167
+ if (parsed === undefined || callPath === "") {
168
+ return undefined;
169
+ }
170
+ const slash = parsed.rawPath.trim().replaceAll("\\", "/");
171
+ const suffix = `/${callPath}`;
172
+ return slash.endsWith(suffix) && slash.length > suffix.length
173
+ ? slash.slice(0, slash.length - suffix.length)
174
+ : undefined;
175
+ }
176
+
177
+ function assistantCallsOf(entry: EntryLike): readonly ToolCallLike[] {
178
+ return entry.type === "message" && entry.message?.role === "assistant"
179
+ ? toolCalls(entry.message.content)
180
+ : [];
181
+ }
182
+
183
+ function learnNotesDirs(
184
+ entries: readonly ScannedNoteEntry[],
185
+ results: ReadonlyMap<string, ToolResultInfo>,
186
+ ): ReadonlySet<string> {
187
+ const dirs = new Set<string>();
188
+ for (const info of results.values()) {
189
+ const match =
190
+ !info.isError && info.toolName === "notes"
191
+ ? /^Notes directory: ([^\r\n]+)$/mu.exec(info.text)?.[1]
192
+ : undefined;
193
+ if (match !== undefined) {
194
+ dirs.add(match.trim().replaceAll("\\", "/").replace(/\/+$/u, ""));
195
+ }
196
+ }
197
+ for (const item of entries) {
198
+ for (const call of assistantCallsOf(item.entry)) {
199
+ const inferred = inferDirFromCall(call, results);
200
+ if (inferred !== undefined) {
201
+ dirs.add(inferred);
202
+ }
203
+ }
204
+ }
205
+ return dirs;
206
+ }
207
+
208
+ function pathsFromFileList(value: unknown): string[] {
209
+ if (!Array.isArray(value)) {
210
+ return [];
211
+ }
212
+ return value.flatMap((item: unknown) => {
213
+ if (typeof item === "string") {
214
+ return [item];
215
+ }
216
+ const itemPath = isRecord(item) ? optionalString(item.path) : undefined;
217
+ const skipped =
218
+ isRecord(item) && (item.status === "failed" || item.status === "unattempted");
219
+ return itemPath !== undefined && !skipped ? [itemPath] : [];
220
+ });
221
+ }
222
+
223
+ function patchPaths(input: unknown): string[] {
224
+ return typeof input === "string"
225
+ ? Array.from(
226
+ input.matchAll(/^\*\*\* (?:Update File|Add File|Move to): ([^\r\n]+)$/gmu),
227
+ (m) => m[1] ?? "",
228
+ )
229
+ : [];
230
+ }
231
+
232
+ function detailFilePaths(details: unknown): string[] {
233
+ return isRecord(details)
234
+ ? [...pathsFromFileList(details.modifiedFiles), ...pathsFromFileList(details.files)]
235
+ : [];
236
+ }
237
+
238
+ function extractFileToolSpec(
239
+ tool: string,
240
+ args: Readonly<Record<string, unknown>>,
241
+ details: unknown,
242
+ ): { readonly kind: NoteOpKind; readonly rawPaths: readonly string[] } | undefined {
243
+ if (args.preview === true) {
244
+ return undefined;
245
+ }
246
+ const directPath = optionalString(args.path);
247
+ const directKinds: Readonly<Record<string, NoteOpKind | undefined>> = {
248
+ write: args.content === "" ? "clear" : "full-write",
249
+ edit: "partial-write",
250
+ read: "read",
251
+ };
252
+ const directKind = directKinds[tool];
253
+ if (directKind !== undefined && directPath !== undefined) {
254
+ return { kind: directKind, rawPaths: [directPath] };
255
+ }
256
+ if (tool === "write_files" || tool === "replace_text") {
257
+ const kind: NoteOpKind = tool === "write_files" ? "full-write" : "partial-write";
258
+ return { kind, rawPaths: [...pathsFromFileList(args.files), ...detailFilePaths(details)] };
259
+ }
260
+ return tool === "apply_patch"
261
+ ? {
262
+ kind: "partial-write",
263
+ rawPaths: [...patchPaths(args.input), ...detailFilePaths(details)],
264
+ }
265
+ : undefined;
266
+ }
267
+
268
+ function fileToolTouches(
269
+ tool: string,
270
+ args: Readonly<Record<string, unknown>>,
271
+ details: unknown,
272
+ context: TouchContext,
273
+ ): NoteTouch[] {
274
+ const spec = extractFileToolSpec(tool, args, details);
275
+ if (spec === undefined) {
276
+ return [];
277
+ }
278
+ return spec.rawPaths.flatMap((raw) => {
279
+ const target = resolveNoteTarget(raw, context.knownDirs, false);
280
+ return target === undefined
281
+ ? []
282
+ : [
283
+ {
284
+ ...target,
285
+ kind: spec.kind,
286
+ entryId: context.entryId,
287
+ inRetained: context.inRetained,
288
+ },
289
+ ];
290
+ });
291
+ }
292
+
293
+ function notesCallKind(args: Readonly<Record<string, unknown>>): NoteOpKind | undefined {
294
+ if (args.op === "write") {
295
+ return args.content === "" ? "clear" : "full-write";
296
+ }
297
+ if (args.op === "append") {
298
+ return "partial-write";
299
+ }
300
+ return args.op === "read" ? "read" : undefined;
301
+ }
302
+
303
+ function mergeNoteTargets(
304
+ callTarget: NoteTarget | undefined,
305
+ resTarget: NoteTarget | undefined,
306
+ ): NoteTarget | undefined {
307
+ const notePath = callTarget?.notePath ?? resTarget?.notePath;
308
+ return notePath === undefined
309
+ ? undefined
310
+ : { notePath, absPath: resTarget?.absPath ?? callTarget?.absPath };
311
+ }
312
+
313
+ function notesToolTouches(
314
+ args: Readonly<Record<string, unknown>>,
315
+ resultText: string | undefined,
316
+ context: TouchContext,
317
+ ): NoteTouch[] {
318
+ const parsed = resultText === undefined ? undefined : extractNotesResultTarget(resultText);
319
+ const kind = notesCallKind(args) ?? parsed?.kind;
320
+ const callTarget = resolveNoteTarget(optionalString(args.path), context.knownDirs, true);
321
+ const resTarget = resolveNoteTarget(parsed?.rawPath, context.knownDirs, true);
322
+ const merged = mergeNoteTargets(callTarget, resTarget);
323
+ return kind === undefined || merged === undefined
324
+ ? []
325
+ : [{ ...merged, kind, entryId: context.entryId, inRetained: context.inRetained }];
326
+ }
327
+
328
+ function extractNestedCalls(message: MessageLike): readonly unknown[] {
329
+ const nested = isRecord(message.nestedCalls) ? message.nestedCalls.calls : undefined;
330
+ if (Array.isArray(nested)) {
331
+ return nested;
332
+ }
333
+ const details = isRecord(message.details) ? message.details : undefined;
334
+ if (details !== undefined && Array.isArray(details.calls)) {
335
+ return details.calls;
336
+ }
337
+ const detailNested =
338
+ details !== undefined && isRecord(details.nestedCalls)
339
+ ? details.nestedCalls.calls
340
+ : undefined;
341
+ return Array.isArray(detailNested) ? detailNested : [];
342
+ }
343
+
344
+ function nestedArgs(raw: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> {
345
+ if (isRecord(raw.arguments)) {
346
+ return raw.arguments;
347
+ }
348
+ if (isRecord(raw.args)) {
349
+ return raw.args;
350
+ }
351
+ return isRecord(raw.input) ? raw.input : {};
352
+ }
353
+
354
+ function touchesFromNested(message: MessageLike, context: TouchContext): NoteTouch[] {
355
+ return extractNestedCalls(message).flatMap((raw) => {
356
+ if (
357
+ !isRecord(raw) ||
358
+ raw.isError === true ||
359
+ raw.status === "error" ||
360
+ (typeof raw.error === "string" && raw.error !== "")
361
+ ) {
362
+ return [];
363
+ }
364
+ const name = bareToolName(optionalString(raw.name) ?? optionalString(raw.toolName));
365
+ const args = nestedArgs(raw);
366
+ return name === "notes"
367
+ ? notesToolTouches(args, optionalString(raw.result), context)
368
+ : fileToolTouches(name, args, raw.result, context);
369
+ });
370
+ }
371
+
372
+ function touchesFromAssistantCall(
373
+ call: ToolCallLike,
374
+ results: ReadonlyMap<string, ToolResultInfo>,
375
+ context: TouchContext,
376
+ ): { readonly consumedCallId?: string | undefined; readonly touches: readonly NoteTouch[] } {
377
+ const callId = optionalString(call.id);
378
+ const res = callId === undefined ? undefined : results.get(callId);
379
+ const consumedCallId = callId !== undefined && res !== undefined ? callId : undefined;
380
+ if (res?.isError === true) {
381
+ return { consumedCallId, touches: [] };
382
+ }
383
+ const tool = bareToolName(call.name);
384
+ const args = isRecord(call.arguments) ? call.arguments : {};
385
+ const touches =
386
+ tool === "notes"
387
+ ? notesToolTouches(args, res?.text, context)
388
+ : fileToolTouches(tool, args, res?.details, context);
389
+ return { consumedCallId, touches };
390
+ }
391
+
392
+ function touchesFromToolResult(
393
+ msg: MessageLike,
394
+ handledResultIds: ReadonlySet<string>,
395
+ context: TouchContext,
396
+ ): NoteTouch[] {
397
+ if (msg.role !== "toolResult" || msg.isError === true) {
398
+ return [];
399
+ }
400
+ const callId = optionalString(msg.toolCallId) ?? "";
401
+ const direct =
402
+ !handledResultIds.has(callId) && bareToolName(msg.toolName) === "notes"
403
+ ? notesToolTouches({}, textOf(msg), context)
404
+ : [];
405
+ return [...direct, ...touchesFromNested(msg, context)];
406
+ }
407
+
408
+ export function collectTouches(entries: readonly ScannedNoteEntry[]): {
409
+ readonly touches: readonly NoteTouch[];
410
+ readonly defaultDir?: string | undefined;
411
+ } {
412
+ const results = collectResultMap(entries);
413
+ const knownDirs = learnNotesDirs(entries, results);
414
+ const handledResultIds = new Set<string>();
415
+ const touches: NoteTouch[] = [];
416
+ for (const item of entries) {
417
+ const msg = item.entry.message;
418
+ const context: TouchContext = {
419
+ entryId: item.entryId,
420
+ inRetained: item.inRetained,
421
+ knownDirs,
422
+ };
423
+ for (const call of assistantCallsOf(item.entry)) {
424
+ const out = touchesFromAssistantCall(call, results, context);
425
+ if (out.consumedCallId !== undefined) {
426
+ handledResultIds.add(out.consumedCallId);
427
+ }
428
+ touches.push(...out.touches);
429
+ }
430
+ if (item.entry.type === "message" && msg !== undefined) {
431
+ touches.push(...touchesFromToolResult(msg, handledResultIds, context));
432
+ }
433
+ }
434
+ const defaultDir = knownDirs.size === 1 ? [...knownDirs][0] : undefined;
435
+ return { touches, defaultDir };
436
+ }
@@ -0,0 +1,200 @@
1
+ import type { EntryLike, ProjectedEntry } from "./contracts.ts";
2
+ import { contextEdits } from "./history-entries.ts";
3
+ import {
4
+ collectTouches,
5
+ type ScannedNoteEntry,
6
+ type NoteTarget,
7
+ type NoteTouch,
8
+ } from "./recovery-note-touches.ts";
9
+
10
+ const MAX_WRITTEN_NOTES = 4;
11
+ const MAX_READ_NOTES = 3;
12
+
13
+ export type RetainedSpan = { readonly firstId: string; readonly lastId: string };
14
+ type TrackedNote = NoteTarget & {
15
+ readonly written: boolean;
16
+ readonly lastEntryId: string;
17
+ readonly lastInRetained: boolean;
18
+ readonly lastWriteEntryId?: string | undefined;
19
+ readonly fullWriteRetainedEntryId?: string | undefined;
20
+ readonly partialEditRetainedEntryId?: string | undefined;
21
+ readonly order: number;
22
+ };
23
+ function applyReadTouch(
24
+ previous: TrackedNote | undefined,
25
+ touch: NoteTouch,
26
+ order: number,
27
+ ): TrackedNote {
28
+ const wasWritten = previous?.written === true;
29
+ return {
30
+ notePath: touch.notePath,
31
+ absPath: touch.absPath ?? previous?.absPath,
32
+ written: wasWritten,
33
+ lastEntryId: touch.entryId,
34
+ lastInRetained: touch.inRetained,
35
+ lastWriteEntryId: previous?.lastWriteEntryId,
36
+ fullWriteRetainedEntryId: previous?.fullWriteRetainedEntryId,
37
+ partialEditRetainedEntryId: previous?.partialEditRetainedEntryId,
38
+ order: wasWritten ? previous.order : order,
39
+ };
40
+ }
41
+
42
+ function applyWriteTouch(
43
+ previous: TrackedNote | undefined,
44
+ touch: NoteTouch,
45
+ order: number,
46
+ ): TrackedNote {
47
+ return {
48
+ notePath: touch.notePath,
49
+ absPath: touch.absPath ?? previous?.absPath,
50
+ written: true,
51
+ lastEntryId: touch.entryId,
52
+ lastInRetained: touch.inRetained,
53
+ lastWriteEntryId: touch.entryId,
54
+ fullWriteRetainedEntryId:
55
+ touch.kind === "full-write" && touch.inRetained
56
+ ? touch.entryId
57
+ : previous?.fullWriteRetainedEntryId,
58
+ partialEditRetainedEntryId:
59
+ touch.kind === "partial-write" && touch.inRetained
60
+ ? touch.entryId
61
+ : previous?.partialEditRetainedEntryId,
62
+ order,
63
+ };
64
+ }
65
+
66
+ function formatNoteLabel(note: TrackedNote): string {
67
+ return note.absPath !== undefined && note.absPath !== note.notePath
68
+ ? `${note.notePath} (${note.absPath})`
69
+ : note.notePath;
70
+ }
71
+
72
+ function formatWrittenNoteLine(note: TrackedNote, span: RetainedSpan | undefined): string {
73
+ const label = formatNoteLabel(note);
74
+ const writeId = note.lastWriteEntryId ?? note.lastEntryId;
75
+ if (span === undefined) {
76
+ return `- Active note (written): ${label} [compacted at entry ${writeId} — read this note now]`;
77
+ }
78
+ if (note.fullWriteRetainedEntryId !== undefined) {
79
+ const updated =
80
+ note.partialEditRetainedEntryId !== undefined &&
81
+ note.lastWriteEntryId === note.partialEditRetainedEntryId
82
+ ? `, updated at entry ${note.partialEditRetainedEntryId}`
83
+ : "";
84
+ return `- Active note (written): ${label} [latest full write is in the retained tail above at entry ${note.fullWriteRetainedEntryId}${updated}]`;
85
+ }
86
+ if (note.partialEditRetainedEntryId !== undefined) {
87
+ return `- Active note (written): ${label} [only partial edits are in the retained tail above at entry ${note.partialEditRetainedEntryId} — read full note now]`;
88
+ }
89
+ return `- Active note (written): ${label} [compacted before entry ${span.firstId} — read this note now]`;
90
+ }
91
+
92
+ function formatReadNoteLine(note: TrackedNote, span: RetainedSpan | undefined): string {
93
+ const label = formatNoteLabel(note);
94
+ if (span !== undefined && note.lastInRetained) {
95
+ return `- Referenced note (read): ${label} [read in the retained tail above at entry ${note.lastEntryId}]`;
96
+ }
97
+ return span !== undefined
98
+ ? `- Referenced note (read): ${label} [compacted before entry ${span.firstId}]`
99
+ : `- Referenced note (read): ${label} [compacted at entry ${note.lastEntryId}]`;
100
+ }
101
+
102
+ function formatNotesAction(
103
+ primaryWritten: TrackedNote | undefined,
104
+ span: RetainedSpan | undefined,
105
+ ): string {
106
+ if (primaryWritten === undefined) {
107
+ return "Read the referenced note again if its context is still needed, and keep one current-state note for this task.";
108
+ }
109
+ if (span !== undefined && primaryWritten.fullWriteRetainedEntryId !== undefined) {
110
+ return `Your latest full write to ${primaryWritten.notePath} is already visible above at entry ${primaryWritten.fullWriteRetainedEntryId}; do not rewrite it unless new findings after entry ${span.lastId} require it.`;
111
+ }
112
+ 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.`;
113
+ }
114
+
115
+ function projectNoteEntries(
116
+ chainEntries: readonly EntryLike[],
117
+ allEntries: readonly EntryLike[],
118
+ projected: readonly ProjectedEntry[],
119
+ retained: ReadonlySet<string>,
120
+ ): ScannedNoteEntry[] {
121
+ const edits = contextEdits(allEntries);
122
+ const omittedIds = new Set(
123
+ projected
124
+ .filter(({ messages }) => messages.length === 0)
125
+ .map(({ sourceEntry }) => sourceEntry.id),
126
+ );
127
+ return chainEntries.flatMap((entry) => {
128
+ const rawId = entry.id ?? "";
129
+ const edit = edits.get(rawId);
130
+ if (omittedIds.has(rawId) || edit?.replacement === null) {
131
+ return [];
132
+ }
133
+ const effective =
134
+ edit?.replacement !== undefined && entry.type === "message"
135
+ ? {
136
+ ...entry,
137
+ id: edit.id,
138
+ message: { ...entry.message, content: edit.replacement.content },
139
+ }
140
+ : entry;
141
+ const resolvedId = (effective.id ?? rawId).slice(0, 120);
142
+ return [
143
+ {
144
+ entry: effective,
145
+ entryId: resolvedId === "" ? "unknown" : resolvedId,
146
+ inRetained: retained.has(rawId),
147
+ },
148
+ ];
149
+ });
150
+ }
151
+
152
+ export function renderSessionNotes(
153
+ chainEntries: readonly EntryLike[],
154
+ allEntries: readonly EntryLike[],
155
+ projected: readonly ProjectedEntry[],
156
+ options: {
157
+ readonly retained: ReadonlySet<string>;
158
+ readonly span: RetainedSpan | undefined;
159
+ readonly maxChars: number;
160
+ },
161
+ ): string | undefined {
162
+ const { retained, span, maxChars } = options;
163
+ const entries = projectNoteEntries(chainEntries, allEntries, projected, retained);
164
+ const { touches, defaultDir } = collectTouches(entries);
165
+ const tracked = new Map<string, TrackedNote>();
166
+ let order = 0;
167
+ for (const rawTouch of touches) {
168
+ order += 1;
169
+ const touch =
170
+ rawTouch.absPath === undefined && defaultDir !== undefined
171
+ ? { ...rawTouch, absPath: `${defaultDir}/${rawTouch.notePath}` }
172
+ : rawTouch;
173
+ if (touch.kind === "clear") {
174
+ tracked.delete(touch.notePath);
175
+ } else if (touch.kind === "read") {
176
+ tracked.set(touch.notePath, applyReadTouch(tracked.get(touch.notePath), touch, order));
177
+ } else {
178
+ tracked.set(touch.notePath, applyWriteTouch(tracked.get(touch.notePath), touch, order));
179
+ }
180
+ }
181
+ if (tracked.size === 0) {
182
+ return undefined;
183
+ }
184
+ const all = [...tracked.values()].sort((a, b) => b.order - a.order);
185
+ const written = all.filter((note) => note.written).slice(0, MAX_WRITTEN_NOTES);
186
+ const readOnly = all.filter((note) => !note.written).slice(0, MAX_READ_NOTES);
187
+ const lines = [
188
+ ...written.map((note) => formatWrittenNoteLine(note, span)),
189
+ ...readOnly.map((note) => formatReadNoteLine(note, span)),
190
+ ];
191
+ const header = "Task notes in this session:";
192
+ const action = formatNotesAction(written[0], span);
193
+ const selected: string[] = [];
194
+ for (const line of lines) {
195
+ if ([header, ...selected, line, action].join("\n").length <= maxChars) {
196
+ selected.push(line);
197
+ }
198
+ }
199
+ return selected.length === 0 ? undefined : [header, ...selected, action].join("\n");
200
+ }