@pi-unipi/unipi 2.10.2 → 2.11.0
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/CHANGELOG.md +15 -0
- package/package.json +23 -23
- package/packages/ask-user/package.json +2 -2
- package/packages/autocomplete/package.json +1 -1
- package/packages/background-tasks/package.json +2 -2
- package/packages/btw/package.json +2 -2
- package/packages/compactor/package.json +3 -3
- package/packages/core/package.json +1 -1
- package/packages/footer/package.json +2 -2
- package/packages/image/package.json +2 -2
- package/packages/info-screen/package.json +2 -2
- package/packages/input-shortcuts/package.json +2 -2
- package/packages/kanboard/package.json +2 -2
- package/packages/mcp/package.json +2 -2
- package/packages/memory/package.json +3 -3
- package/packages/milestone/package.json +2 -2
- package/packages/notify/package.json +2 -2
- package/packages/ralph/package.json +3 -3
- package/packages/subagents/package.json +2 -2
- package/packages/trajectory/README.md +23 -7
- package/packages/trajectory/index.ts +23 -3
- package/packages/trajectory/package.json +1 -1
- package/packages/trajectory/src/capture.ts +150 -30
- package/packages/trajectory/src/prefix-integrity.ts +213 -0
- package/packages/trajectory/src/project.ts +57 -5
- package/packages/trajectory/src/server.ts +10 -3
- package/packages/trajectory/src/telemetry.ts +79 -18
- package/packages/trajectory/src/tracer.ts +401 -0
- package/packages/trajectory/src/ui.ts +14 -12
- package/packages/unipi/bundled.js +1554 -769
- package/packages/unipi/index.ts +25 -22
- package/packages/updater/package.json +2 -2
- package/packages/utility/package.json +2 -2
- package/packages/web-api/package.json +2 -2
- package/packages/workflow/package.json +2 -2
|
@@ -1,83 +1,203 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { PrefixIntegrityTracker } from "./prefix-integrity.js";
|
|
2
3
|
import { TelemetrySidecar } from "./telemetry.js";
|
|
4
|
+
import type { UnipiTraceRecorder } from "./tracer.js";
|
|
3
5
|
|
|
4
6
|
interface RequestState {
|
|
5
7
|
id: number;
|
|
6
|
-
|
|
8
|
+
turnIndex?: number;
|
|
9
|
+
startedAt?: number;
|
|
7
10
|
firstTokenAt?: number;
|
|
8
11
|
}
|
|
9
12
|
|
|
13
|
+
interface PendingAgent {
|
|
14
|
+
runId: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
10
17
|
/** Register read-only observability hooks. Returned reader follows the active session. */
|
|
11
|
-
export function registerTelemetryCapture(
|
|
18
|
+
export function registerTelemetryCapture(
|
|
19
|
+
pi: ExtensionAPI,
|
|
20
|
+
sidecarRoot?: string,
|
|
21
|
+
traceRecorder?: UnipiTraceRecorder,
|
|
22
|
+
): () => ReturnType<TelemetrySidecar["read"]> {
|
|
12
23
|
let sidecar: TelemetrySidecar | null = null;
|
|
13
24
|
let requestSeq = 0;
|
|
25
|
+
let runSeq = 0;
|
|
14
26
|
let active: RequestState | null = null;
|
|
27
|
+
let lastRequestId: number | null = null;
|
|
28
|
+
let currentTurnIndex: number | undefined;
|
|
29
|
+
let pendingAgent: PendingAgent | null = null;
|
|
30
|
+
let previousTraceCursor = 0;
|
|
31
|
+
const prefixIntegrity = new PrefixIntegrityTracker();
|
|
15
32
|
const toolStarts = new Map<string, number>();
|
|
16
33
|
|
|
17
34
|
const bind = (ctx: ExtensionContext) => {
|
|
18
|
-
|
|
35
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
36
|
+
sidecar = new TelemetrySidecar(sessionId, sidecarRoot);
|
|
37
|
+
traceRecorder?.bind(sessionId);
|
|
38
|
+
const existing = sidecar.read();
|
|
39
|
+
requestSeq = Math.max(0, ...existing.flatMap(event => typeof event.requestId === "number" ? [event.requestId] : []));
|
|
40
|
+
runSeq = Math.max(0, ...existing.flatMap(event => typeof event.runId === "number" ? [event.runId] : []));
|
|
19
41
|
active = null;
|
|
42
|
+
lastRequestId = null;
|
|
43
|
+
currentTurnIndex = undefined;
|
|
44
|
+
pendingAgent = null;
|
|
45
|
+
previousTraceCursor = traceRecorder?.cursor() ?? 0;
|
|
46
|
+
prefixIntegrity.reset();
|
|
20
47
|
toolStarts.clear();
|
|
21
48
|
};
|
|
22
|
-
const append = (
|
|
23
|
-
|
|
49
|
+
const append = (
|
|
50
|
+
type: Parameters<TelemetrySidecar["append"]>[0]["type"],
|
|
51
|
+
data?: unknown,
|
|
52
|
+
extra: Record<string, unknown> = {},
|
|
53
|
+
at = Date.now(),
|
|
54
|
+
) => {
|
|
55
|
+
sidecar?.append({ type, at, ...extra, ...(data === undefined ? {} : { data }) });
|
|
24
56
|
};
|
|
25
57
|
|
|
58
|
+
const ensureRequest = (startedAt?: number) => {
|
|
59
|
+
if (!active) {
|
|
60
|
+
active = { id: ++requestSeq, turnIndex: currentTurnIndex, startedAt };
|
|
61
|
+
} else if (startedAt !== undefined) {
|
|
62
|
+
active.startedAt = startedAt;
|
|
63
|
+
}
|
|
64
|
+
lastRequestId = active.id;
|
|
65
|
+
return active;
|
|
66
|
+
};
|
|
67
|
+
const requestExtra = (request = active) => ({
|
|
68
|
+
...(request?.id !== undefined ? { requestId: request.id } : lastRequestId === null ? {} : { requestId: lastRequestId }),
|
|
69
|
+
...(request?.turnIndex !== undefined ? { turnIndex: request.turnIndex } : currentTurnIndex === undefined ? {} : { turnIndex: currentTurnIndex }),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
|
|
26
73
|
pi.on("session_start", (_event, ctx) => { bind(ctx); });
|
|
74
|
+
pi.on("resources_discover", () => {});
|
|
75
|
+
pi.on("session_info_changed", () => {});
|
|
76
|
+
pi.on("session_before_switch", () => {});
|
|
77
|
+
pi.on("session_before_fork", () => {});
|
|
78
|
+
pi.on("session_before_compact", () => {});
|
|
79
|
+
pi.on("session_compact", (event) => { prefixIntegrity.markBoundary(`session_compact:${event.reason}`); });
|
|
80
|
+
pi.on("session_before_tree", () => {});
|
|
81
|
+
pi.on("session_tree", () => { prefixIntegrity.markBoundary("session_tree"); });
|
|
82
|
+
pi.on("session_shutdown", () => {});
|
|
83
|
+
|
|
84
|
+
pi.on("before_agent_start", (event) => {
|
|
85
|
+
pendingAgent = { runId: ++runSeq };
|
|
86
|
+
});
|
|
87
|
+
pi.on("agent_start", (event, ctx) => {
|
|
88
|
+
active = null;
|
|
89
|
+
lastRequestId = null;
|
|
90
|
+
currentTurnIndex = undefined;
|
|
91
|
+
const runId = pendingAgent?.runId ?? ++runSeq;
|
|
92
|
+
append("system-prompt", {
|
|
93
|
+
systemPrompt: ctx.getSystemPrompt(),
|
|
94
|
+
provider: ctx.model?.provider,
|
|
95
|
+
model: ctx.model?.id,
|
|
96
|
+
thinkingLevel: ctx.thinkingLevel,
|
|
97
|
+
}, { runId });
|
|
98
|
+
});
|
|
99
|
+
pi.on("agent_end", () => {});
|
|
100
|
+
pi.on("agent_settled", () => { pendingAgent = null; });
|
|
101
|
+
pi.on("turn_start", (event) => {
|
|
102
|
+
currentTurnIndex = event.turnIndex;
|
|
103
|
+
active = {
|
|
104
|
+
id: ++requestSeq,
|
|
105
|
+
turnIndex: event.turnIndex,
|
|
106
|
+
};
|
|
107
|
+
lastRequestId = active.id;
|
|
108
|
+
});
|
|
109
|
+
pi.on("turn_end", () => {});
|
|
110
|
+
|
|
111
|
+
pi.on("context", () => { ensureRequest(); });
|
|
112
|
+
pi.on("before_provider_headers", () => { ensureRequest(); });
|
|
27
113
|
pi.on("before_provider_request", (event, ctx) => {
|
|
28
114
|
const startedAt = Date.now();
|
|
29
|
-
|
|
115
|
+
const request = ensureRequest(startedAt);
|
|
116
|
+
const integrity = prefixIntegrity.observe(event.payload, {
|
|
117
|
+
provider: ctx.model?.provider,
|
|
118
|
+
model: ctx.model?.id,
|
|
119
|
+
api: ctx.model?.api,
|
|
120
|
+
thinkingLevel: ctx.thinkingLevel,
|
|
121
|
+
});
|
|
122
|
+
const traceCursor = traceRecorder?.cursor() ?? 0;
|
|
123
|
+
const contributingTrace = traceRecorder?.since(previousTraceCursor) ?? [];
|
|
124
|
+
previousTraceCursor = traceCursor;
|
|
125
|
+
const attributed = integrity.verdict === "violation"
|
|
126
|
+
? contributingTrace
|
|
127
|
+
.filter(item => item.type === "unipi-trace")
|
|
128
|
+
.map(item => item.data)
|
|
129
|
+
.filter((data): data is Record<string, unknown> => Boolean(data && typeof data === "object"))
|
|
130
|
+
.filter(data => data.phase === "exit" && (
|
|
131
|
+
data.surface === "hook" && data.mutation && (data.mutation as Record<string, unknown>).changed === true ||
|
|
132
|
+
data.surface === "api" && ["sendMessage", "sendUserMessage", "setActiveTools", "setModel", "setThinkingLevel", "registerProvider", "unregisterProvider"].includes(String(data.action)) ||
|
|
133
|
+
data.surface === "context-api" && ["compact", "navigateTree", "switchSession", "newSession", "fork", "reload"].includes(String(data.action))
|
|
134
|
+
))
|
|
135
|
+
.slice(-20)
|
|
136
|
+
: [];
|
|
137
|
+
append("prefix-integrity", {
|
|
138
|
+
...integrity,
|
|
139
|
+
attribution: attributed.length > 0 ? attributed : undefined,
|
|
140
|
+
}, requestExtra(request));
|
|
30
141
|
append("request", {
|
|
31
142
|
payload: event.payload,
|
|
32
143
|
provider: ctx.model?.provider,
|
|
33
144
|
model: ctx.model?.id,
|
|
34
145
|
thinkingLevel: ctx.thinkingLevel,
|
|
35
|
-
|
|
36
|
-
? (event.payload as { tools: unknown }).tools
|
|
37
|
-
: undefined,
|
|
38
|
-
}, { requestId: active.id });
|
|
39
|
-
});
|
|
40
|
-
pi.on("after_provider_response", (event) => {
|
|
41
|
-
append("response", { status: event.status, headers: event.headers }, active ? { requestId: active.id } : {});
|
|
146
|
+
}, requestExtra(request));
|
|
42
147
|
});
|
|
148
|
+
pi.on("after_provider_response", () => {});
|
|
149
|
+
|
|
150
|
+
pi.on("message_start", () => {});
|
|
43
151
|
pi.on("message_update", (event) => {
|
|
44
|
-
|
|
152
|
+
const request = ensureRequest();
|
|
153
|
+
if (request.firstTokenAt !== undefined) return;
|
|
45
154
|
if (!["text_delta", "thinking_delta", "toolcall_delta"].includes(event.assistantMessageEvent.type)) return;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
ttftMs: active.firstTokenAt - active.startedAt,
|
|
49
|
-
eventType: event.assistantMessageEvent.type,
|
|
50
|
-
}, { requestId: active.id });
|
|
155
|
+
request.firstTokenAt = Date.now();
|
|
156
|
+
|
|
51
157
|
});
|
|
52
158
|
pi.on("message_end", (event) => {
|
|
53
159
|
if (event.message.role !== "assistant" || !active) return;
|
|
54
160
|
const completedAt = Date.now();
|
|
55
161
|
append("message-end", {
|
|
56
|
-
totalMs: completedAt - active.startedAt,
|
|
57
|
-
ttftMs: active.
|
|
162
|
+
totalMs: active.startedAt === undefined ? undefined : completedAt - active.startedAt,
|
|
163
|
+
ttftMs: active.startedAt === undefined || active.firstTokenAt === undefined
|
|
164
|
+
? undefined
|
|
165
|
+
: active.firstTokenAt - active.startedAt,
|
|
58
166
|
decodingMs: active.firstTokenAt === undefined ? undefined : completedAt - active.firstTokenAt,
|
|
59
167
|
usage: event.message.usage,
|
|
60
168
|
stopReason: event.message.stopReason,
|
|
61
169
|
errorMessage: event.message.errorMessage,
|
|
62
|
-
},
|
|
170
|
+
}, requestExtra());
|
|
63
171
|
active = null;
|
|
64
172
|
});
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
});
|
|
173
|
+
|
|
174
|
+
pi.on("tool_execution_start", (event) => { toolStarts.set(event.toolCallId, Date.now()); });
|
|
175
|
+
pi.on("tool_call", () => {});
|
|
176
|
+
pi.on("tool_execution_update", () => {});
|
|
177
|
+
pi.on("tool_result", () => {});
|
|
70
178
|
pi.on("tool_execution_end", (event) => {
|
|
71
179
|
const at = Date.now();
|
|
72
180
|
const startedAt = toolStarts.get(event.toolCallId);
|
|
73
181
|
append("tool-end", {
|
|
74
182
|
toolName: event.toolName,
|
|
75
|
-
result: event.result,
|
|
76
183
|
isError: event.isError,
|
|
77
184
|
durationMs: startedAt === undefined ? undefined : at - startedAt,
|
|
78
|
-
}, { toolCallId: event.toolCallId });
|
|
185
|
+
}, { ...requestExtra(), toolCallId: event.toolCallId });
|
|
79
186
|
toolStarts.delete(event.toolCallId);
|
|
80
187
|
});
|
|
81
188
|
|
|
82
|
-
|
|
189
|
+
pi.on("model_select", (event) => {
|
|
190
|
+
if (event.previousModel && (
|
|
191
|
+
event.previousModel.provider !== event.model.provider ||
|
|
192
|
+
event.previousModel.id !== event.model.id ||
|
|
193
|
+
event.previousModel.api !== event.model.api
|
|
194
|
+
)) prefixIntegrity.markBoundary("model_select");
|
|
195
|
+
});
|
|
196
|
+
pi.on("thinking_level_select", (event) => {
|
|
197
|
+
if (event.previousLevel !== event.level) prefixIntegrity.markBoundary("thinking_level_select");
|
|
198
|
+
});
|
|
199
|
+
pi.on("user_bash", () => {});
|
|
200
|
+
pi.on("input", () => {});
|
|
201
|
+
|
|
202
|
+
return () => sidecar?.read() ?? traceRecorder?.read() ?? [];
|
|
83
203
|
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export type PrefixVerdict = "first_request" | "identical_retry" | "prefix_extended" | "boundary" | "violation";
|
|
4
|
+
|
|
5
|
+
export interface PrefixDifference {
|
|
6
|
+
surface: "messages" | "system" | "tools" | "envelope" | "shape";
|
|
7
|
+
kind: "changed" | "inserted" | "removed" | "reordered";
|
|
8
|
+
path: string;
|
|
9
|
+
index?: number;
|
|
10
|
+
beforeFingerprint?: string;
|
|
11
|
+
afterFingerprint?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PrefixIntegrityResult {
|
|
15
|
+
epoch: number;
|
|
16
|
+
verdict: PrefixVerdict;
|
|
17
|
+
messageField: string;
|
|
18
|
+
previousItems: number;
|
|
19
|
+
currentItems: number;
|
|
20
|
+
extendedBy: number;
|
|
21
|
+
requestFingerprint: string;
|
|
22
|
+
messagesFingerprint: string;
|
|
23
|
+
systemFingerprint: string;
|
|
24
|
+
toolsFingerprint: string;
|
|
25
|
+
envelopeFingerprint: string;
|
|
26
|
+
boundaryReason?: string;
|
|
27
|
+
differences: PrefixDifference[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface RequestState {
|
|
31
|
+
requestFingerprint: string;
|
|
32
|
+
messageField: string;
|
|
33
|
+
messages: unknown[];
|
|
34
|
+
messageFingerprints: string[];
|
|
35
|
+
systemFingerprint: string;
|
|
36
|
+
toolsFingerprint: string;
|
|
37
|
+
envelopeFingerprint: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function canonicalize(value: unknown, seen = new WeakSet<object>()): unknown {
|
|
41
|
+
if (value === undefined) return { $type: "undefined" };
|
|
42
|
+
if (typeof value === "bigint") return { $type: "bigint", value: value.toString() };
|
|
43
|
+
if (typeof value === "number" && !Number.isFinite(value)) return { $type: "number", value: String(value) };
|
|
44
|
+
if (typeof value !== "object" || value === null) return value;
|
|
45
|
+
if (seen.has(value)) return { $type: "cycle" };
|
|
46
|
+
seen.add(value);
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
const result = value.map(item => canonicalize(item, seen));
|
|
49
|
+
seen.delete(value);
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
if (value instanceof Uint8Array) {
|
|
53
|
+
seen.delete(value);
|
|
54
|
+
return { $type: "bytes", length: value.byteLength };
|
|
55
|
+
}
|
|
56
|
+
const result: Record<string, unknown> = {};
|
|
57
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
58
|
+
result[key] = canonicalize((value as Record<string, unknown>)[key], seen);
|
|
59
|
+
}
|
|
60
|
+
seen.delete(value);
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function fingerprint(value: unknown): string {
|
|
65
|
+
return createHash("sha256").update(JSON.stringify(canonicalize(value))).digest("hex").slice(0, 16);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function payloadObject(payload: unknown): Record<string, unknown> {
|
|
69
|
+
return payload && typeof payload === "object" && !Array.isArray(payload)
|
|
70
|
+
? payload as Record<string, unknown>
|
|
71
|
+
: { payload };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function messageSequence(payload: Record<string, unknown>): { field: string; items: unknown[] } {
|
|
75
|
+
for (const field of ["messages", "input", "contents"] as const) {
|
|
76
|
+
if (Array.isArray(payload[field])) return { field, items: payload[field] as unknown[] };
|
|
77
|
+
}
|
|
78
|
+
return { field: "none", items: [] };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function systemSurface(payload: Record<string, unknown>, field: string, messages: unknown[]): unknown {
|
|
82
|
+
const direct = Object.fromEntries(Object.entries(payload).filter(([key]) => /^(system|instructions|developer)/i.test(key)));
|
|
83
|
+
const leading = field === "messages"
|
|
84
|
+
? messages.filter((message) => {
|
|
85
|
+
if (!message || typeof message !== "object") return false;
|
|
86
|
+
const role = (message as Record<string, unknown>).role;
|
|
87
|
+
return role === "system" || role === "developer";
|
|
88
|
+
})
|
|
89
|
+
: [];
|
|
90
|
+
return { direct, leading };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function toolsSurface(payload: Record<string, unknown>): unknown {
|
|
94
|
+
return { tools: payload.tools, toolChoice: payload.tool_choice ?? payload.toolChoice };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function envelopeSurface(payload: Record<string, unknown>, sequenceField: string): unknown {
|
|
98
|
+
const omitted = new Set([sequenceField, "tools", "tool_choice", "toolChoice"]);
|
|
99
|
+
return Object.fromEntries(Object.entries(payload).filter(([key]) => !omitted.has(key) && !/^(system|instructions|developer)/i.test(key)));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function state(payload: unknown, route?: unknown): RequestState {
|
|
103
|
+
const object = payloadObject(payload);
|
|
104
|
+
const sequence = messageSequence(object);
|
|
105
|
+
const system = systemSurface(object, sequence.field, sequence.items);
|
|
106
|
+
const tools = toolsSurface(object);
|
|
107
|
+
const envelope = { route, payload: envelopeSurface(object, sequence.field) };
|
|
108
|
+
return {
|
|
109
|
+
requestFingerprint: fingerprint({ route, payload: object }),
|
|
110
|
+
messageField: sequence.field,
|
|
111
|
+
messages: sequence.items,
|
|
112
|
+
messageFingerprints: sequence.items.map(fingerprint),
|
|
113
|
+
systemFingerprint: fingerprint(system),
|
|
114
|
+
toolsFingerprint: fingerprint(tools),
|
|
115
|
+
envelopeFingerprint: fingerprint(envelope),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function messageDifference(previous: RequestState, current: RequestState): PrefixDifference | undefined {
|
|
120
|
+
if (previous.messageField !== current.messageField) {
|
|
121
|
+
return { surface: "shape", kind: "changed", path: "$.messageField", beforeFingerprint: fingerprint(previous.messageField), afterFingerprint: fingerprint(current.messageField) };
|
|
122
|
+
}
|
|
123
|
+
const limit = Math.min(previous.messageFingerprints.length, current.messageFingerprints.length);
|
|
124
|
+
for (let index = 0; index < limit; index++) {
|
|
125
|
+
if (previous.messageFingerprints[index] === current.messageFingerprints[index]) continue;
|
|
126
|
+
const laterIndex = current.messageFingerprints.indexOf(previous.messageFingerprints[index]!, index + 1);
|
|
127
|
+
const earlierIndex = previous.messageFingerprints.indexOf(current.messageFingerprints[index]!, index + 1);
|
|
128
|
+
return {
|
|
129
|
+
surface: "messages",
|
|
130
|
+
kind: laterIndex >= 0 || earlierIndex >= 0 ? "reordered" : "changed",
|
|
131
|
+
path: `$.${current.messageField}[${index}]`, index,
|
|
132
|
+
beforeFingerprint: previous.messageFingerprints[index], afterFingerprint: current.messageFingerprints[index],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
if (current.messageFingerprints.length < previous.messageFingerprints.length) {
|
|
136
|
+
const index = current.messageFingerprints.length;
|
|
137
|
+
return { surface: "messages", kind: "removed", path: `$.${current.messageField}[${index}]`, index, beforeFingerprint: previous.messageFingerprints[index] };
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export class PrefixIntegrityTracker {
|
|
143
|
+
private epoch = 0;
|
|
144
|
+
private previous: RequestState | null = null;
|
|
145
|
+
private boundaryReason: string | undefined;
|
|
146
|
+
|
|
147
|
+
reset(): void {
|
|
148
|
+
this.epoch = 0;
|
|
149
|
+
this.previous = null;
|
|
150
|
+
this.boundaryReason = undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
markBoundary(reason: string): void {
|
|
154
|
+
if (this.previous) this.epoch++;
|
|
155
|
+
this.previous = null;
|
|
156
|
+
this.boundaryReason = reason;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
observe(payload: unknown, route?: unknown): PrefixIntegrityResult {
|
|
160
|
+
const current = state(payload, route);
|
|
161
|
+
const boundaryReason = this.boundaryReason;
|
|
162
|
+
if (!this.previous) {
|
|
163
|
+
if (this.epoch === 0) this.epoch = 1;
|
|
164
|
+
this.previous = current;
|
|
165
|
+
this.boundaryReason = undefined;
|
|
166
|
+
return {
|
|
167
|
+
epoch: this.epoch,
|
|
168
|
+
verdict: boundaryReason ? "boundary" : "first_request",
|
|
169
|
+
messageField: current.messageField,
|
|
170
|
+
previousItems: 0,
|
|
171
|
+
currentItems: current.messages.length,
|
|
172
|
+
extendedBy: current.messages.length,
|
|
173
|
+
requestFingerprint: current.requestFingerprint,
|
|
174
|
+
messagesFingerprint: fingerprint(current.messageFingerprints),
|
|
175
|
+
systemFingerprint: current.systemFingerprint,
|
|
176
|
+
toolsFingerprint: current.toolsFingerprint,
|
|
177
|
+
envelopeFingerprint: current.envelopeFingerprint,
|
|
178
|
+
...(boundaryReason ? { boundaryReason } : {}),
|
|
179
|
+
differences: [],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const previous = this.previous;
|
|
184
|
+
const differences: PrefixDifference[] = [];
|
|
185
|
+
const messageDiff = messageDifference(previous, current);
|
|
186
|
+
if (messageDiff) differences.push(messageDiff);
|
|
187
|
+
if (previous.systemFingerprint !== current.systemFingerprint) differences.push({ surface: "system", kind: "changed", path: "$.system", beforeFingerprint: previous.systemFingerprint, afterFingerprint: current.systemFingerprint });
|
|
188
|
+
if (previous.toolsFingerprint !== current.toolsFingerprint) differences.push({ surface: "tools", kind: "changed", path: "$.tools", beforeFingerprint: previous.toolsFingerprint, afterFingerprint: current.toolsFingerprint });
|
|
189
|
+
if (previous.envelopeFingerprint !== current.envelopeFingerprint) differences.push({ surface: "envelope", kind: "changed", path: "$.envelope", beforeFingerprint: previous.envelopeFingerprint, afterFingerprint: current.envelopeFingerprint });
|
|
190
|
+
|
|
191
|
+
const verdict: PrefixVerdict = current.requestFingerprint === previous.requestFingerprint
|
|
192
|
+
? "identical_retry"
|
|
193
|
+
: differences.length === 0
|
|
194
|
+
? "prefix_extended"
|
|
195
|
+
: "violation";
|
|
196
|
+
const result: PrefixIntegrityResult = {
|
|
197
|
+
epoch: this.epoch,
|
|
198
|
+
verdict,
|
|
199
|
+
messageField: current.messageField,
|
|
200
|
+
previousItems: previous.messages.length,
|
|
201
|
+
currentItems: current.messages.length,
|
|
202
|
+
extendedBy: Math.max(0, current.messages.length - previous.messages.length),
|
|
203
|
+
requestFingerprint: current.requestFingerprint,
|
|
204
|
+
messagesFingerprint: fingerprint(current.messageFingerprints),
|
|
205
|
+
systemFingerprint: current.systemFingerprint,
|
|
206
|
+
toolsFingerprint: current.toolsFingerprint,
|
|
207
|
+
envelopeFingerprint: current.envelopeFingerprint,
|
|
208
|
+
differences,
|
|
209
|
+
};
|
|
210
|
+
this.previous = current;
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { TelemetryEvent } from "./telemetry.js";
|
|
3
3
|
|
|
4
|
-
export type TrajectoryKind = "system" | "user" | "assistant" | "tool" | "compaction" | "branch";
|
|
4
|
+
export type TrajectoryKind = "system" | "unipi" | "prefix" | "user" | "assistant" | "tool" | "compaction" | "branch";
|
|
5
5
|
|
|
6
6
|
export interface TrajectoryUsage {
|
|
7
7
|
input?: number;
|
|
@@ -38,6 +38,13 @@ export interface TrajectoryRecord {
|
|
|
38
38
|
request?: unknown;
|
|
39
39
|
response?: unknown;
|
|
40
40
|
tools?: unknown;
|
|
41
|
+
data?: unknown;
|
|
42
|
+
package?: string;
|
|
43
|
+
surface?: string;
|
|
44
|
+
phase?: string;
|
|
45
|
+
verdict?: string;
|
|
46
|
+
epoch?: number;
|
|
47
|
+
attribution?: unknown;
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
export interface TrajectorySnapshot {
|
|
@@ -165,12 +172,56 @@ export function projectTrajectory(
|
|
|
165
172
|
}
|
|
166
173
|
}
|
|
167
174
|
|
|
175
|
+
const firstEntryAt = records[0]?.timestamp ?? Date.now();
|
|
176
|
+
let syntheticSeq = -telemetry.length;
|
|
177
|
+
for (const event of telemetry) {
|
|
178
|
+
if (!["system-prompt", "unipi-trace", "prefix-integrity"].includes(event.type)) continue;
|
|
179
|
+
const data = event.data as AnyRecord | undefined;
|
|
180
|
+
// Package traces are attribution evidence, not a second activity log. Only
|
|
181
|
+
// operations that changed a provider-visible surface belong in trajectory.
|
|
182
|
+
if (event.type === "unipi-trace" && data?.affectsContext !== true) continue;
|
|
183
|
+
const traceAction = String(data?.hook ?? data?.action ?? data?.channel ?? data?.surface ?? "operation");
|
|
184
|
+
const kind: TrajectoryKind = event.type === "system-prompt" ? "system" : event.type === "unipi-trace" ? "unipi" : "prefix";
|
|
185
|
+
const title = event.type === "system-prompt"
|
|
186
|
+
? "SYSTEM PROMPT"
|
|
187
|
+
: event.type === "unipi-trace"
|
|
188
|
+
? `${String(data?.package ?? "unipi")} · ${traceAction}`
|
|
189
|
+
: `PREFIX · ${String(data?.verdict ?? "unknown")}`;
|
|
190
|
+
const value = event.type === "system-prompt" ? data?.systemPrompt : data;
|
|
191
|
+
records.push({
|
|
192
|
+
id: `telemetry:${event.type}:${event.at}:${syntheticSeq}`,
|
|
193
|
+
seq: syntheticSeq++,
|
|
194
|
+
turn: event.turnIndex === undefined ? null : event.turnIndex + 1,
|
|
195
|
+
step: null,
|
|
196
|
+
kind,
|
|
197
|
+
timestamp: event.at || firstEntryAt,
|
|
198
|
+
durationMs: typeof data?.durationMs === "number" ? data.durationMs : null,
|
|
199
|
+
title,
|
|
200
|
+
preview: event.type === "prefix-integrity"
|
|
201
|
+
? `${String(data?.verdict ?? "unknown")} · epoch ${String(data?.epoch ?? "?")}${Array.isArray(data?.differences) && data.differences[0] ? ` · ${String(data.differences[0].path ?? data.differences[0].surface ?? "changed")}` : ""}`
|
|
202
|
+
: preview(event.type === "system-prompt" ? value : JSON.stringify(value ?? {})),
|
|
203
|
+
output: event.type === "system-prompt" ? value : undefined,
|
|
204
|
+
data,
|
|
205
|
+
package: typeof data?.package === "string" ? data.package : undefined,
|
|
206
|
+
surface: typeof data?.surface === "string" ? data.surface : undefined,
|
|
207
|
+
phase: typeof data?.phase === "string" ? data.phase : undefined,
|
|
208
|
+
verdict: typeof data?.verdict === "string" ? data.verdict : undefined,
|
|
209
|
+
epoch: typeof data?.epoch === "number" ? data.epoch : undefined,
|
|
210
|
+
attribution: data?.attribution,
|
|
211
|
+
isError: data?.phase === "error" || data?.verdict === "violation",
|
|
212
|
+
request: event.requestId === undefined ? undefined : { requestId: event.requestId },
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
records.sort((a, b) => a.timestamp - b.timestamp || a.seq - b.seq);
|
|
216
|
+
|
|
168
217
|
const assistantRecords = records.filter(record => record.kind === "assistant");
|
|
169
|
-
const requestIds = [...new Set(telemetry.flatMap(event => event.
|
|
170
|
-
for (const
|
|
171
|
-
const record = assistantRecords[index];
|
|
172
|
-
if (!record) break;
|
|
218
|
+
const requestIds = [...new Set(telemetry.flatMap(event => event.type === "request" && event.requestId !== undefined ? [event.requestId] : []))];
|
|
219
|
+
for (const requestId of requestIds) {
|
|
173
220
|
const events = telemetry.filter(event => event.requestId === requestId);
|
|
221
|
+
const requestAt = events.find(event => event.type === "request")?.at ?? 0;
|
|
222
|
+
const record = assistantRecords.find(candidate => candidate.request === undefined && candidate.timestamp >= requestAt)
|
|
223
|
+
?? assistantRecords.find(candidate => candidate.request === undefined);
|
|
224
|
+
if (!record) break;
|
|
174
225
|
const request = events.find(event => event.type === "request")?.data as AnyRecord | undefined;
|
|
175
226
|
const response = events.find(event => event.type === "response")?.data;
|
|
176
227
|
const end = events.find(event => event.type === "message-end")?.data as AnyRecord | undefined;
|
|
@@ -188,6 +239,7 @@ export function projectTrajectory(
|
|
|
188
239
|
const end = telemetry.find(event => event.type === "tool-end" && event.toolCallId === record.toolCallId)?.data as AnyRecord | undefined;
|
|
189
240
|
if (typeof end?.durationMs === "number") record.durationMs = end.durationMs;
|
|
190
241
|
}
|
|
242
|
+
records.forEach((record, index) => { record.seq = index; });
|
|
191
243
|
|
|
192
244
|
return { ...meta, generatedAt: Date.now(), records };
|
|
193
245
|
}
|
|
@@ -11,10 +11,17 @@ export interface TrajectoryServerOptions {
|
|
|
11
11
|
|
|
12
12
|
const HOST = "127.0.0.1";
|
|
13
13
|
|
|
14
|
-
function json(res: ServerResponse, value:
|
|
14
|
+
function json(res: ServerResponse, value: TrajectorySnapshot, requestEtag?: string): void {
|
|
15
|
+
const etag = `W/\"${value.sessionId}:${value.generatedAt}\"`;
|
|
16
|
+
if (requestEtag === etag) {
|
|
17
|
+
res.writeHead(304, { ETag: etag });
|
|
18
|
+
res.end();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
15
21
|
res.writeHead(200, {
|
|
16
|
-
"Cache-Control": "no-
|
|
22
|
+
"Cache-Control": "no-cache",
|
|
17
23
|
"Content-Type": "application/json; charset=utf-8",
|
|
24
|
+
ETag: etag,
|
|
18
25
|
"X-Content-Type-Options": "nosniff",
|
|
19
26
|
});
|
|
20
27
|
res.end(JSON.stringify(value));
|
|
@@ -35,7 +42,7 @@ export class TrajectoryServer {
|
|
|
35
42
|
try {
|
|
36
43
|
const url = new URL(req.url ?? "/", `http://${HOST}`);
|
|
37
44
|
if (req.method !== "GET") { res.writeHead(405).end("Method Not Allowed"); return; }
|
|
38
|
-
if (url.pathname === "/api/snapshot") { json(res, this.options.snapshot()); return; }
|
|
45
|
+
if (url.pathname === "/api/snapshot") { json(res, this.options.snapshot(), req.headers["if-none-match"]); return; }
|
|
39
46
|
if (url.pathname === "/favicon.ico") { res.writeHead(204).end(); return; }
|
|
40
47
|
if (url.pathname === "/app.js") {
|
|
41
48
|
res.writeHead(200, { "Cache-Control": "no-store", "Content-Type": "application/javascript; charset=utf-8", "X-Content-Type-Options": "nosniff" });
|