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
|
@@ -0,0 +1,389 @@
|
|
|
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
|
+
import { bareToolName, noteOperationKind, verifiedNoteOperations } from "./note-tool-effects.ts";
|
|
10
|
+
import { matchNoteDirectory, type NoteOwner } from "./note-scopes.ts";
|
|
11
|
+
|
|
12
|
+
export type ScannedNoteEntry = {
|
|
13
|
+
readonly entry: EntryLike;
|
|
14
|
+
readonly entryId: string;
|
|
15
|
+
readonly inRetained: boolean;
|
|
16
|
+
};
|
|
17
|
+
type NoteOpKind = "full-write" | "partial-write" | "read" | "clear" | "observed-write";
|
|
18
|
+
export type NoteTarget = {
|
|
19
|
+
readonly notePath: string;
|
|
20
|
+
readonly absPath?: string | undefined;
|
|
21
|
+
readonly referenceScope?: string | undefined;
|
|
22
|
+
};
|
|
23
|
+
export type NoteTouch = NoteTarget & {
|
|
24
|
+
readonly kind: NoteOpKind;
|
|
25
|
+
readonly entryId: string;
|
|
26
|
+
readonly inRetained: boolean;
|
|
27
|
+
readonly receiptId?: string | undefined;
|
|
28
|
+
readonly owner?: NoteOwner | undefined;
|
|
29
|
+
};
|
|
30
|
+
type ToolResultInfo = {
|
|
31
|
+
readonly entryId: string;
|
|
32
|
+
readonly sourceId: string;
|
|
33
|
+
readonly inRetained: boolean;
|
|
34
|
+
readonly isError: boolean;
|
|
35
|
+
readonly toolName: string;
|
|
36
|
+
readonly text: string;
|
|
37
|
+
readonly details: unknown;
|
|
38
|
+
};
|
|
39
|
+
type TouchContext = {
|
|
40
|
+
readonly entryId: string;
|
|
41
|
+
readonly sourceId: string;
|
|
42
|
+
readonly inRetained: boolean;
|
|
43
|
+
readonly knownDirs: ReadonlySet<string>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function normalizeRelPath(raw: string): string {
|
|
47
|
+
const cleaned = raw
|
|
48
|
+
.trim()
|
|
49
|
+
.replaceAll("\\", "/")
|
|
50
|
+
.replace(/^(?:\.\/)+/u, "")
|
|
51
|
+
.replace(/^\/+/u, "");
|
|
52
|
+
const invalid =
|
|
53
|
+
cleaned === "" ||
|
|
54
|
+
cleaned === "." ||
|
|
55
|
+
cleaned === ".." ||
|
|
56
|
+
cleaned.startsWith("../") ||
|
|
57
|
+
cleaned.includes("/../") ||
|
|
58
|
+
cleaned.endsWith("/");
|
|
59
|
+
return invalid ? "" : cleaned;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function matchMarkerPath(slash: string): string | undefined {
|
|
63
|
+
for (const marker of ["/.pi/notes/", "/posthorse-notes/"]) {
|
|
64
|
+
const idx = slash.lastIndexOf(marker);
|
|
65
|
+
if (idx >= 0) {
|
|
66
|
+
return slash.slice(idx + marker.length);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (slash.startsWith(".pi/notes/") || slash.startsWith("./.pi/notes/")) {
|
|
70
|
+
return slash.replace(/^(?:\.\/)?\.pi\/notes\//u, "");
|
|
71
|
+
}
|
|
72
|
+
const pkgMatch = /\/\.pi\/package-notes\/[0-9a-f]{64}\/notes\/(.+)$/u.exec(slash);
|
|
73
|
+
if (pkgMatch !== null) {
|
|
74
|
+
return pkgMatch[1];
|
|
75
|
+
}
|
|
76
|
+
return /\/\.pi\/(?:agent\/)?notes\/[^/]+\/[^/]+\/[^/]+\/(.+)$/u.exec(slash)?.[1];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function unscopedNoteTarget(trimmed: string, slash: string): NoteTarget | undefined {
|
|
80
|
+
if (slash.startsWith("/") || /^[A-Za-z]:\//u.test(slash)) {
|
|
81
|
+
const base = normalizeRelPath(slash.slice(slash.lastIndexOf("/") + 1));
|
|
82
|
+
return base === "" ? undefined : { notePath: base, absPath: trimmed };
|
|
83
|
+
}
|
|
84
|
+
const rel = normalizeRelPath(slash);
|
|
85
|
+
return rel === "" ? undefined : { notePath: rel };
|
|
86
|
+
}
|
|
87
|
+
function resolveNoteTarget(
|
|
88
|
+
rawPath: string | undefined,
|
|
89
|
+
knownDirs: ReadonlySet<string>,
|
|
90
|
+
fromNotesTool: boolean,
|
|
91
|
+
): NoteTarget | undefined {
|
|
92
|
+
if (rawPath === undefined) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const trimmed = rawPath.trim();
|
|
96
|
+
const slash = trimmed.replaceAll("\\", "/");
|
|
97
|
+
const matched = matchNoteDirectory(slash, [...knownDirs])?.path ?? matchMarkerPath(slash);
|
|
98
|
+
if (matched !== undefined) {
|
|
99
|
+
const rel = matched.startsWith(".forks/") ? "" : normalizeRelPath(matched);
|
|
100
|
+
return rel === "" ? undefined : { notePath: rel, absPath: trimmed };
|
|
101
|
+
}
|
|
102
|
+
return fromNotesTool ? unscopedNoteTarget(trimmed, slash) : undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function extractNotesResultTarget(
|
|
106
|
+
text: string,
|
|
107
|
+
): { readonly kind: NoteOpKind; readonly rawPath: string } | undefined {
|
|
108
|
+
const patterns: readonly (readonly [RegExp, NoteOpKind])[] = [
|
|
109
|
+
[/^Wrote ([^\r\n]+)$/mu, "full-write"],
|
|
110
|
+
[/^Appended to ([^\r\n]+)$/mu, "partial-write"],
|
|
111
|
+
[/^File: ([^\r\n]+)$/mu, "read"],
|
|
112
|
+
];
|
|
113
|
+
for (const [pattern, kind] of patterns) {
|
|
114
|
+
const rawPath = pattern.exec(text)?.[1];
|
|
115
|
+
if (rawPath !== undefined) {
|
|
116
|
+
return { kind, rawPath };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function collectResultMap(
|
|
123
|
+
entries: readonly ScannedNoteEntry[],
|
|
124
|
+
): ReadonlyMap<string, ToolResultInfo> {
|
|
125
|
+
const results = new Map<string, ToolResultInfo>();
|
|
126
|
+
for (const item of entries) {
|
|
127
|
+
const msg = item.entry.message;
|
|
128
|
+
const callId = optionalString(msg?.toolCallId);
|
|
129
|
+
if (
|
|
130
|
+
item.entry.type === "message" &&
|
|
131
|
+
msg?.role === "toolResult" &&
|
|
132
|
+
callId !== undefined &&
|
|
133
|
+
callId !== ""
|
|
134
|
+
) {
|
|
135
|
+
results.set(callId, {
|
|
136
|
+
entryId: item.entryId,
|
|
137
|
+
sourceId: item.entry.id ?? item.entryId,
|
|
138
|
+
inRetained: item.inRetained,
|
|
139
|
+
isError: msg.isError === true,
|
|
140
|
+
toolName: bareToolName(msg.toolName),
|
|
141
|
+
text: textOf(msg),
|
|
142
|
+
details: msg.details,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return results;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function inferDirFromCall(
|
|
150
|
+
call: ToolCallLike,
|
|
151
|
+
results: ReadonlyMap<string, ToolResultInfo>,
|
|
152
|
+
): string | undefined {
|
|
153
|
+
const callId = optionalString(call.id);
|
|
154
|
+
const res = callId === undefined ? undefined : results.get(callId);
|
|
155
|
+
if (res === undefined || res.isError) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
const parsed = extractNotesResultTarget(res.text);
|
|
159
|
+
const rawCallPath = isRecord(call.arguments) ? optionalString(call.arguments.path) : undefined;
|
|
160
|
+
const callPath = rawCallPath === undefined ? "" : normalizeRelPath(rawCallPath);
|
|
161
|
+
if (parsed === undefined || callPath === "") {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
const slash = parsed.rawPath.trim().replaceAll("\\", "/");
|
|
165
|
+
const suffix = `/${callPath}`;
|
|
166
|
+
return slash.endsWith(suffix) && slash.length > suffix.length
|
|
167
|
+
? slash.slice(0, slash.length - suffix.length)
|
|
168
|
+
: undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function assistantCallsOf(entry: EntryLike): readonly ToolCallLike[] {
|
|
172
|
+
return entry.type === "message" && entry.message?.role === "assistant"
|
|
173
|
+
? toolCalls(entry.message.content)
|
|
174
|
+
: [];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function learnNotesDirs(
|
|
178
|
+
entries: readonly ScannedNoteEntry[],
|
|
179
|
+
results: ReadonlyMap<string, ToolResultInfo>,
|
|
180
|
+
): ReadonlySet<string> {
|
|
181
|
+
const dirs = new Set<string>();
|
|
182
|
+
for (const info of results.values()) {
|
|
183
|
+
const match =
|
|
184
|
+
!info.isError && info.toolName === "notes"
|
|
185
|
+
? /^Notes directory: ([^\r\n]+)$/mu.exec(info.text)?.[1]
|
|
186
|
+
: undefined;
|
|
187
|
+
if (match !== undefined) {
|
|
188
|
+
dirs.add(match.trim().replaceAll("\\", "/").replace(/\/+$/u, ""));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
for (const item of entries) {
|
|
192
|
+
for (const call of assistantCallsOf(item.entry)) {
|
|
193
|
+
const inferred = inferDirFromCall(call, results);
|
|
194
|
+
if (inferred !== undefined) {
|
|
195
|
+
dirs.add(inferred);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return dirs;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function fileToolTouches(
|
|
203
|
+
tool: string,
|
|
204
|
+
args: Readonly<Record<string, unknown>>,
|
|
205
|
+
result: { readonly details: unknown; readonly isError: boolean },
|
|
206
|
+
context: TouchContext,
|
|
207
|
+
): NoteTouch[] {
|
|
208
|
+
return verifiedNoteOperations(tool, args, result).flatMap((operation) => {
|
|
209
|
+
const target = resolveNoteTarget(operation.path, context.knownDirs, false);
|
|
210
|
+
return target === undefined
|
|
211
|
+
? []
|
|
212
|
+
: [
|
|
213
|
+
{
|
|
214
|
+
...target,
|
|
215
|
+
kind:
|
|
216
|
+
tool === "apply_patch" && operation.action !== "delete"
|
|
217
|
+
? "partial-write"
|
|
218
|
+
: noteOperationKind(operation.action),
|
|
219
|
+
entryId: context.entryId,
|
|
220
|
+
inRetained: context.inRetained,
|
|
221
|
+
},
|
|
222
|
+
];
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function notesCallKind(args: Readonly<Record<string, unknown>>): NoteOpKind | undefined {
|
|
227
|
+
if (args.op === "write") {
|
|
228
|
+
return args.content === "" ? "clear" : "full-write";
|
|
229
|
+
}
|
|
230
|
+
if (args.op === "append") {
|
|
231
|
+
return "partial-write";
|
|
232
|
+
}
|
|
233
|
+
return args.op === "read" ? "read" : undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function mergeNoteTargets(
|
|
237
|
+
callTarget: NoteTarget | undefined,
|
|
238
|
+
resTarget: NoteTarget | undefined,
|
|
239
|
+
): NoteTarget | undefined {
|
|
240
|
+
const notePath = callTarget?.notePath ?? resTarget?.notePath;
|
|
241
|
+
return notePath === undefined
|
|
242
|
+
? undefined
|
|
243
|
+
: { notePath, absPath: resTarget?.absPath ?? callTarget?.absPath };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function notesToolTouches(
|
|
247
|
+
args: Readonly<Record<string, unknown>>,
|
|
248
|
+
resultText: string | undefined,
|
|
249
|
+
context: TouchContext,
|
|
250
|
+
): NoteTouch[] {
|
|
251
|
+
const parsed = resultText === undefined ? undefined : extractNotesResultTarget(resultText);
|
|
252
|
+
const kind = notesCallKind(args) ?? parsed?.kind;
|
|
253
|
+
const path = optionalString(args.path);
|
|
254
|
+
const scopedPath =
|
|
255
|
+
typeof args.scope === "string" && path !== undefined ? `${args.scope}/${path}` : path;
|
|
256
|
+
const callTarget = resolveNoteTarget(scopedPath, context.knownDirs, true);
|
|
257
|
+
const resTarget = resolveNoteTarget(parsed?.rawPath, context.knownDirs, true);
|
|
258
|
+
const merged = mergeNoteTargets(callTarget, resTarget);
|
|
259
|
+
return kind === undefined || merged === undefined
|
|
260
|
+
? []
|
|
261
|
+
: [{ ...merged, kind, entryId: context.entryId, inRetained: context.inRetained }];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function extractNestedCalls(message: MessageLike): readonly unknown[] {
|
|
265
|
+
const nested = isRecord(message.nestedCalls) ? message.nestedCalls.calls : undefined;
|
|
266
|
+
if (Array.isArray(nested)) {
|
|
267
|
+
return nested;
|
|
268
|
+
}
|
|
269
|
+
const details = isRecord(message.details) ? message.details : undefined;
|
|
270
|
+
if (details !== undefined && Array.isArray(details.calls)) {
|
|
271
|
+
return details.calls;
|
|
272
|
+
}
|
|
273
|
+
const detailNested =
|
|
274
|
+
details !== undefined && isRecord(details.nestedCalls)
|
|
275
|
+
? details.nestedCalls.calls
|
|
276
|
+
: undefined;
|
|
277
|
+
return Array.isArray(detailNested) ? detailNested : [];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function nestedArgs(raw: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> {
|
|
281
|
+
if (isRecord(raw.arguments)) {
|
|
282
|
+
return raw.arguments;
|
|
283
|
+
}
|
|
284
|
+
if (isRecord(raw.args)) {
|
|
285
|
+
return raw.args;
|
|
286
|
+
}
|
|
287
|
+
return isRecord(raw.input) ? raw.input : {};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function touchesFromNested(message: MessageLike, context: TouchContext): NoteTouch[] {
|
|
291
|
+
return extractNestedCalls(message).flatMap((raw) => {
|
|
292
|
+
if (
|
|
293
|
+
!isRecord(raw) ||
|
|
294
|
+
raw.isError === true ||
|
|
295
|
+
raw.status === "error" ||
|
|
296
|
+
raw.status === "unfinished" ||
|
|
297
|
+
(typeof raw.error === "string" && raw.error !== "")
|
|
298
|
+
) {
|
|
299
|
+
return [];
|
|
300
|
+
}
|
|
301
|
+
const name = bareToolName(optionalString(raw.name) ?? optionalString(raw.toolName));
|
|
302
|
+
const args = nestedArgs(raw);
|
|
303
|
+
const touches =
|
|
304
|
+
name === "notes"
|
|
305
|
+
? notesToolTouches(args, optionalString(raw.result), context)
|
|
306
|
+
: fileToolTouches(name, args, { details: raw.result, isError: false }, context);
|
|
307
|
+
return touches.map((touch) =>
|
|
308
|
+
Object.assign({}, touch, {
|
|
309
|
+
kind:
|
|
310
|
+
touch.kind === "read" || touch.kind === "clear" ? touch.kind : "observed-write",
|
|
311
|
+
inRetained: false,
|
|
312
|
+
receiptId: context.sourceId,
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function touchesFromAssistantCall(
|
|
319
|
+
call: ToolCallLike,
|
|
320
|
+
results: ReadonlyMap<string, ToolResultInfo>,
|
|
321
|
+
context: TouchContext,
|
|
322
|
+
): { readonly consumedCallId?: string | undefined; readonly touches: readonly NoteTouch[] } {
|
|
323
|
+
const callId = optionalString(call.id);
|
|
324
|
+
const res = callId === undefined ? undefined : results.get(callId);
|
|
325
|
+
const consumedCallId = callId !== undefined && res !== undefined ? callId : undefined;
|
|
326
|
+
if (res === undefined || (res.isError && bareToolName(call.name) === "notes")) {
|
|
327
|
+
return { consumedCallId, touches: [] };
|
|
328
|
+
}
|
|
329
|
+
const tool = bareToolName(call.name);
|
|
330
|
+
const args = isRecord(call.arguments) ? call.arguments : {};
|
|
331
|
+
const touches =
|
|
332
|
+
tool === "notes"
|
|
333
|
+
? notesToolTouches(args, res.text, context)
|
|
334
|
+
: fileToolTouches(tool, args, res, context);
|
|
335
|
+
return {
|
|
336
|
+
consumedCallId,
|
|
337
|
+
touches: touches.map((touch) => Object.assign({}, touch, { receiptId: res.sourceId })),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function touchesFromToolResult(
|
|
342
|
+
msg: MessageLike,
|
|
343
|
+
handledResultIds: ReadonlySet<string>,
|
|
344
|
+
context: TouchContext,
|
|
345
|
+
): NoteTouch[] {
|
|
346
|
+
if (msg.role !== "toolResult" || msg.isError === true) {
|
|
347
|
+
return [];
|
|
348
|
+
}
|
|
349
|
+
const callId = optionalString(msg.toolCallId) ?? "";
|
|
350
|
+
const direct =
|
|
351
|
+
!handledResultIds.has(callId) && bareToolName(msg.toolName) === "notes"
|
|
352
|
+
? notesToolTouches({}, textOf(msg), context)
|
|
353
|
+
: [];
|
|
354
|
+
return [...direct, ...touchesFromNested(msg, context)];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function collectTouches(
|
|
358
|
+
entries: readonly ScannedNoteEntry[],
|
|
359
|
+
directories: ReadonlySet<string> = new Set(),
|
|
360
|
+
): {
|
|
361
|
+
readonly touches: readonly NoteTouch[];
|
|
362
|
+
readonly defaultDir?: string | undefined;
|
|
363
|
+
} {
|
|
364
|
+
const results = collectResultMap(entries);
|
|
365
|
+
const knownDirs = new Set([...directories, ...learnNotesDirs(entries, results)]);
|
|
366
|
+
const handledResultIds = new Set<string>();
|
|
367
|
+
const touches: NoteTouch[] = [];
|
|
368
|
+
for (const item of entries) {
|
|
369
|
+
const msg = item.entry.message;
|
|
370
|
+
const context: TouchContext = {
|
|
371
|
+
entryId: item.entryId,
|
|
372
|
+
sourceId: item.entry.id ?? item.entryId,
|
|
373
|
+
inRetained: item.inRetained,
|
|
374
|
+
knownDirs,
|
|
375
|
+
};
|
|
376
|
+
for (const call of assistantCallsOf(item.entry)) {
|
|
377
|
+
const out = touchesFromAssistantCall(call, results, context);
|
|
378
|
+
if (out.consumedCallId !== undefined) {
|
|
379
|
+
handledResultIds.add(out.consumedCallId);
|
|
380
|
+
}
|
|
381
|
+
touches.push(...out.touches);
|
|
382
|
+
}
|
|
383
|
+
if (item.entry.type === "message" && msg !== undefined) {
|
|
384
|
+
touches.push(...touchesFromToolResult(msg, handledResultIds, context));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const defaultDir = knownDirs.size === 1 ? [...knownDirs][0] : undefined;
|
|
388
|
+
return { touches, defaultDir };
|
|
389
|
+
}
|