@pi-unipi/trajectory 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/README.md +23 -7
- package/index.ts +23 -3
- package/package.json +1 -1
- package/src/capture.ts +150 -30
- package/src/prefix-integrity.ts +213 -0
- package/src/project.ts +57 -5
- package/src/server.ts +10 -3
- package/src/telemetry.ts +79 -18
- package/src/tracer.ts +401 -0
- package/src/ui.ts +14 -12
package/src/server.ts
CHANGED
|
@@ -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" });
|
package/src/telemetry.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import { appendFileSync, mkdirSync,
|
|
1
|
+
import { appendFileSync, closeSync, mkdirSync, openSync, readSync, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
5
5
|
export type TelemetryEventType =
|
|
6
|
+
| "hook"
|
|
7
|
+
| "unipi-trace"
|
|
8
|
+
| "prefix-integrity"
|
|
9
|
+
| "system-prompt"
|
|
6
10
|
| "request"
|
|
7
11
|
| "response"
|
|
8
12
|
| "first-token"
|
|
@@ -15,6 +19,8 @@ export interface TelemetryEvent {
|
|
|
15
19
|
type: TelemetryEventType;
|
|
16
20
|
at: number;
|
|
17
21
|
requestId?: number | string;
|
|
22
|
+
runId?: number | string;
|
|
23
|
+
turnIndex?: number;
|
|
18
24
|
toolCallId?: string;
|
|
19
25
|
data?: unknown;
|
|
20
26
|
}
|
|
@@ -24,8 +30,58 @@ const SECRET_VALUE = /^(?:bearer\s+|sk-[a-z0-9_-]{12,}|gh[pousr]_[a-z0-9]{12,}|A
|
|
|
24
30
|
const MAX_STRING = 200_000;
|
|
25
31
|
/** Hard ceiling on a serialized event; guards against pathological payloads. */
|
|
26
32
|
const MAX_EVENT_BYTES = 2_000_000;
|
|
27
|
-
/**
|
|
28
|
-
const MAX_READ_BYTES =
|
|
33
|
+
/** Only a small recent tail is needed by the live context inspector. */
|
|
34
|
+
const MAX_READ_BYTES = 5_000_000;
|
|
35
|
+
|
|
36
|
+
interface SharedSidecarState {
|
|
37
|
+
events: TelemetryEvent[];
|
|
38
|
+
sizes: number[];
|
|
39
|
+
retainedBytes: number;
|
|
40
|
+
revision: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sharedStates = new Map<string, SharedSidecarState>();
|
|
44
|
+
|
|
45
|
+
function readTail(file: string): { events: TelemetryEvent[]; sizes: number[] } {
|
|
46
|
+
let fd: number | undefined;
|
|
47
|
+
try {
|
|
48
|
+
const size = statSync(file).size;
|
|
49
|
+
const length = Math.min(size, MAX_READ_BYTES);
|
|
50
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
51
|
+
fd = openSync(file, "r");
|
|
52
|
+
readSync(fd, buffer, 0, length, size - length);
|
|
53
|
+
let text = buffer.toString("utf8");
|
|
54
|
+
if (size > length) text = text.slice(text.indexOf("\n") + 1);
|
|
55
|
+
const events: TelemetryEvent[] = [];
|
|
56
|
+
const sizes: number[] = [];
|
|
57
|
+
for (const line of text.split("\n")) {
|
|
58
|
+
if (!line) continue;
|
|
59
|
+
try {
|
|
60
|
+
events.push(JSON.parse(line) as TelemetryEvent);
|
|
61
|
+
sizes.push(Buffer.byteLength(line) + 1);
|
|
62
|
+
} catch { /* Ignore an incomplete or malformed line. */ }
|
|
63
|
+
}
|
|
64
|
+
return { events, sizes };
|
|
65
|
+
} catch {
|
|
66
|
+
return { events: [], sizes: [] };
|
|
67
|
+
} finally {
|
|
68
|
+
if (fd !== undefined) closeSync(fd);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function stateFor(file: string): SharedSidecarState {
|
|
73
|
+
const existing = sharedStates.get(file);
|
|
74
|
+
if (existing) return existing;
|
|
75
|
+
const loaded = readTail(file);
|
|
76
|
+
const state = {
|
|
77
|
+
events: loaded.events,
|
|
78
|
+
sizes: loaded.sizes,
|
|
79
|
+
retainedBytes: loaded.sizes.reduce((total, size) => total + size, 0),
|
|
80
|
+
revision: 0,
|
|
81
|
+
};
|
|
82
|
+
sharedStates.set(file, state);
|
|
83
|
+
return state;
|
|
84
|
+
}
|
|
29
85
|
|
|
30
86
|
export function redactTelemetry(value: unknown, seen = new WeakSet<object>()): unknown {
|
|
31
87
|
if (typeof value === "string") {
|
|
@@ -44,32 +100,37 @@ export function redactTelemetry(value: unknown, seen = new WeakSet<object>()): u
|
|
|
44
100
|
|
|
45
101
|
export class TelemetrySidecar {
|
|
46
102
|
readonly file: string;
|
|
103
|
+
private readonly state: SharedSidecarState;
|
|
47
104
|
|
|
48
105
|
constructor(sessionId: string, root = join(homedir(), ".unipi", "trajectory")) {
|
|
49
106
|
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
50
107
|
this.file = join(root, `${sessionId.replace(/[^a-zA-Z0-9._-]/g, "_")}.jsonl`);
|
|
108
|
+
this.state = stateFor(this.file);
|
|
51
109
|
}
|
|
52
110
|
|
|
53
111
|
append(event: Omit<TelemetryEvent, "v">): void {
|
|
54
|
-
const
|
|
55
|
-
|
|
112
|
+
const safe = redactTelemetry({ v: 1, ...event }) as TelemetryEvent;
|
|
113
|
+
const serialized = JSON.stringify(safe);
|
|
114
|
+
const bytes = Buffer.byteLength(serialized) + 1;
|
|
115
|
+
if (bytes > MAX_EVENT_BYTES) return;
|
|
56
116
|
appendFileSync(this.file, `${serialized}\n`, { encoding: "utf8", mode: 0o600 });
|
|
117
|
+
this.state.events.push(safe);
|
|
118
|
+
this.state.sizes.push(bytes);
|
|
119
|
+
this.state.retainedBytes += bytes;
|
|
120
|
+
this.state.revision++;
|
|
121
|
+
while (this.state.retainedBytes > MAX_READ_BYTES && this.state.events.length > 1) {
|
|
122
|
+
this.state.events.shift();
|
|
123
|
+
this.state.retainedBytes -= this.state.sizes.shift() ?? 0;
|
|
124
|
+
}
|
|
57
125
|
}
|
|
58
126
|
|
|
127
|
+
/** Return the cached recent tail; this never rereads the whole JSONL file. */
|
|
59
128
|
read(): TelemetryEvent[] {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
lines = keep.slice(keep.indexOf("\n") + 1);
|
|
66
|
-
}
|
|
67
|
-
return lines.split("\n").filter(Boolean).flatMap(line => {
|
|
68
|
-
try { return [JSON.parse(line) as TelemetryEvent]; } catch { return []; }
|
|
69
|
-
});
|
|
70
|
-
} catch {
|
|
71
|
-
return [];
|
|
72
|
-
}
|
|
129
|
+
return this.state.events.slice();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
revision(): number {
|
|
133
|
+
return this.state.revision;
|
|
73
134
|
}
|
|
74
135
|
}
|
|
75
136
|
|
package/src/tracer.ts
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { performance } from "node:perf_hooks";
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { TelemetrySidecar, type TelemetryEvent } from "./telemetry.js";
|
|
5
|
+
|
|
6
|
+
export interface UnipiTraceRecorder {
|
|
7
|
+
bind(sessionId: string): void;
|
|
8
|
+
record(data: unknown, extra?: Record<string, unknown>): void;
|
|
9
|
+
read(): TelemetryEvent[];
|
|
10
|
+
revision(): number;
|
|
11
|
+
cursor(): number;
|
|
12
|
+
since(cursor: number): TelemetryEvent[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface UnipiTracer {
|
|
16
|
+
scope(packageName: string): ExtensionAPI;
|
|
17
|
+
recorder: UnipiTraceRecorder;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function canonicalize(value: unknown, seen = new WeakSet<object>()): unknown {
|
|
21
|
+
if (value === undefined) return { $type: "undefined" };
|
|
22
|
+
if (typeof value === "bigint") return { $type: "bigint", value: value.toString() };
|
|
23
|
+
if (typeof value === "number" && !Number.isFinite(value)) return { $type: "number", value: String(value) };
|
|
24
|
+
if (typeof value !== "object" || value === null) return value;
|
|
25
|
+
if (seen.has(value)) return { $type: "cycle" };
|
|
26
|
+
seen.add(value);
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
const result = value.map(item => canonicalize(item, seen));
|
|
29
|
+
seen.delete(value);
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
if (value instanceof Uint8Array) {
|
|
33
|
+
seen.delete(value);
|
|
34
|
+
return { $type: "bytes", length: value.byteLength };
|
|
35
|
+
}
|
|
36
|
+
const result: Record<string, unknown> = {};
|
|
37
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
38
|
+
result[key] = canonicalize((value as Record<string, unknown>)[key], seen);
|
|
39
|
+
}
|
|
40
|
+
seen.delete(value);
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function mutationSurface(event: unknown): unknown {
|
|
45
|
+
if (!event || typeof event !== "object") return undefined;
|
|
46
|
+
const value = event as Record<string, unknown>;
|
|
47
|
+
if (value.type === "context") return value.messages;
|
|
48
|
+
if (value.type === "before_provider_request") return value.payload;
|
|
49
|
+
if (value.type === "before_provider_headers") return value.headers;
|
|
50
|
+
if (value.type === "before_agent_start") return { systemPrompt: value.systemPrompt };
|
|
51
|
+
if (value.type === "input") return { text: value.text, images: value.images };
|
|
52
|
+
if (value.type === "message_end") return value.message;
|
|
53
|
+
if (value.type === "tool_call") return value.input;
|
|
54
|
+
if (value.type === "tool_result") {
|
|
55
|
+
return { content: value.content, details: value.details, isError: value.isError, usage: value.usage };
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function fingerprint(value: unknown): string {
|
|
61
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sample(value: unknown): unknown {
|
|
65
|
+
if (typeof value === "string") return value.length > 500 ? `${value.slice(0, 500)}…[truncated]` : value;
|
|
66
|
+
const text = JSON.stringify(value);
|
|
67
|
+
return text.length > 1_000 ? `${text.slice(0, 1_000)}…[truncated]` : value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function firstDifference(before: unknown, after: unknown, path = "$", depth = 0): Record<string, unknown> | undefined {
|
|
71
|
+
if (Object.is(before, after)) return undefined;
|
|
72
|
+
if (depth >= 20) return { path, kind: "changed", before: sample(before), after: sample(after) };
|
|
73
|
+
if (Array.isArray(before) && Array.isArray(after)) {
|
|
74
|
+
const count = Math.max(before.length, after.length);
|
|
75
|
+
for (let index = 0; index < count; index++) {
|
|
76
|
+
if (index >= before.length) return { path: `${path}[${index}]`, kind: "inserted", after: sample(after[index]) };
|
|
77
|
+
if (index >= after.length) return { path: `${path}[${index}]`, kind: "removed", before: sample(before[index]) };
|
|
78
|
+
const difference = firstDifference(before[index], after[index], `${path}[${index}]`, depth + 1);
|
|
79
|
+
if (difference) return difference;
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
if (before && after && typeof before === "object" && typeof after === "object") {
|
|
84
|
+
const beforeRecord = before as Record<string, unknown>;
|
|
85
|
+
const afterRecord = after as Record<string, unknown>;
|
|
86
|
+
for (const key of [...new Set([...Object.keys(beforeRecord), ...Object.keys(afterRecord)])].sort()) {
|
|
87
|
+
if (!(key in beforeRecord)) return { path: `${path}.${key}`, kind: "inserted", after: sample(afterRecord[key]) };
|
|
88
|
+
if (!(key in afterRecord)) return { path: `${path}.${key}`, kind: "removed", before: sample(beforeRecord[key]) };
|
|
89
|
+
const difference = firstDifference(beforeRecord[key], afterRecord[key], `${path}.${key}`, depth + 1);
|
|
90
|
+
if (difference) return difference;
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
return { path, kind: "changed", before: sample(before), after: sample(after) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function mutationEvidence(before: unknown, after: unknown): Record<string, unknown> | undefined {
|
|
98
|
+
if (before === undefined || after === undefined) return undefined;
|
|
99
|
+
const beforeFingerprint = fingerprint(before);
|
|
100
|
+
const afterFingerprint = fingerprint(after);
|
|
101
|
+
return {
|
|
102
|
+
changed: beforeFingerprint !== afterFingerprint,
|
|
103
|
+
beforeFingerprint,
|
|
104
|
+
afterFingerprint,
|
|
105
|
+
...(beforeFingerprint === afterFingerprint ? {} : { firstDifference: firstDifference(before, after) }),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function effectiveSurface(eventName: string, event: unknown, result: unknown): unknown {
|
|
110
|
+
const current = mutationSurface(event);
|
|
111
|
+
if (!result || typeof result !== "object") {
|
|
112
|
+
return eventName === "before_provider_request" && result !== undefined ? result : current;
|
|
113
|
+
}
|
|
114
|
+
const value = result as Record<string, unknown>;
|
|
115
|
+
if (eventName === "context" && value.messages !== undefined) return value.messages;
|
|
116
|
+
if (eventName === "before_provider_request") return result;
|
|
117
|
+
if (eventName === "before_agent_start" && value.systemPrompt !== undefined) return { systemPrompt: value.systemPrompt };
|
|
118
|
+
if (eventName === "input" && value.action === "transform") return { text: value.text, images: value.images };
|
|
119
|
+
if (eventName === "message_end" && value.message !== undefined) return value.message;
|
|
120
|
+
if (eventName === "tool_result" && current && typeof current === "object") {
|
|
121
|
+
return { ...(current as Record<string, unknown>), ...value };
|
|
122
|
+
}
|
|
123
|
+
return current;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function resultEvidence(result: unknown): Record<string, unknown> | undefined {
|
|
127
|
+
if (result === undefined) return undefined;
|
|
128
|
+
const canonical = canonicalize(result);
|
|
129
|
+
return { fingerprint: fingerprint(canonical), preview: sample(canonical) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const CONTEXT_API_ACTIONS = new Set([
|
|
133
|
+
"sendMessage", "sendUserMessage", "setActiveTools", "setModel", "setThinkingLevel",
|
|
134
|
+
"registerProvider", "unregisterProvider",
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
function affectsContext(data: Record<string, unknown>): boolean {
|
|
138
|
+
if (data.phase !== "exit") return false;
|
|
139
|
+
if (data.surface === "hook") {
|
|
140
|
+
return Boolean(data.mutation && (data.mutation as Record<string, unknown>).changed === true);
|
|
141
|
+
}
|
|
142
|
+
if (data.surface === "api") return CONTEXT_API_ACTIONS.has(String(data.action));
|
|
143
|
+
if (data.surface === "context-api") {
|
|
144
|
+
return ["compact", "navigateTree", "switchSession", "newSession", "fork", "reload"].includes(String(data.action));
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function traceArgs(action: string, args: unknown[]): unknown {
|
|
150
|
+
switch (action) {
|
|
151
|
+
case "registerCommand": return { name: args[0] };
|
|
152
|
+
case "registerShortcut": return { shortcut: args[0] };
|
|
153
|
+
case "registerFlag": return { name: args[0], options: args[1] };
|
|
154
|
+
case "registerMessageRenderer": return { customType: args[0] };
|
|
155
|
+
case "registerEntryRenderer": return { customType: args[0] };
|
|
156
|
+
case "registerMarkdownTransformer": return {};
|
|
157
|
+
case "sendMessage": return { message: args[0], options: args[1] };
|
|
158
|
+
case "sendUserMessage": return { content: args[0], options: args[1] };
|
|
159
|
+
case "appendEntry": return { customType: args[0], data: args[1] };
|
|
160
|
+
case "setSessionName": return { name: args[0] };
|
|
161
|
+
case "setLabel": return { entryId: args[0], label: args[1] };
|
|
162
|
+
case "setActiveTools": return { toolNames: args[0] };
|
|
163
|
+
case "setModel": return { model: args[0] };
|
|
164
|
+
case "setThinkingLevel": return { level: args[0] };
|
|
165
|
+
case "registerTool": {
|
|
166
|
+
const tool = args[0] as Record<string, unknown> | undefined;
|
|
167
|
+
return { name: tool?.name, description: tool?.description, parameters: tool?.parameters };
|
|
168
|
+
}
|
|
169
|
+
case "registerProvider": return { name: typeof args[0] === "string" ? args[0] : (args[0] as Record<string, unknown> | undefined)?.name };
|
|
170
|
+
case "unregisterProvider": return { name: args[0] };
|
|
171
|
+
default: return args;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const TRACED_ACTIONS = new Set([
|
|
176
|
+
"registerFlag", "registerMessageRenderer", "registerEntryRenderer", "registerMarkdownTransformer",
|
|
177
|
+
"sendMessage", "sendUserMessage", "appendEntry", "setSessionName", "setLabel",
|
|
178
|
+
"setActiveTools", "setModel", "setThinkingLevel", "registerProvider", "unregisterProvider",
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
const CONTEXT_ACTIONS = new Set([
|
|
182
|
+
"abort", "shutdown", "compact", "newSession", "fork", "navigateTree", "switchSession", "reload",
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
function errorData(error: unknown): unknown {
|
|
186
|
+
return error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : String(error);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function scopedContext(ctx: unknown, packageName: string, recorder: UnipiTraceRecorder): unknown {
|
|
190
|
+
if (!ctx || typeof ctx !== "object") return ctx;
|
|
191
|
+
return new Proxy(ctx as Record<string, unknown>, {
|
|
192
|
+
get(target, property, receiver) {
|
|
193
|
+
const original = Reflect.get(target, property, receiver);
|
|
194
|
+
if (typeof property !== "string" || !CONTEXT_ACTIONS.has(property) || typeof original !== "function") return original;
|
|
195
|
+
return (...args: unknown[]) => {
|
|
196
|
+
const started = performance.now();
|
|
197
|
+
recorder.record({ package: packageName, surface: "context-api", phase: "enter", action: property, input: args });
|
|
198
|
+
try {
|
|
199
|
+
const result = original.apply(target, args);
|
|
200
|
+
recorder.record({ package: packageName, surface: "context-api", phase: "exit", action: property, result: resultEvidence(result), durationMs: performance.now() - started });
|
|
201
|
+
return result;
|
|
202
|
+
} catch (error) {
|
|
203
|
+
recorder.record({ package: packageName, surface: "context-api", phase: "error", action: property, durationMs: performance.now() - started, error: errorData(error) });
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function createUnipiTracer(pi: ExtensionAPI, sidecarRoot?: string): UnipiTracer {
|
|
212
|
+
let sidecar: TelemetrySidecar | null = null;
|
|
213
|
+
let sessionId: string | null = null;
|
|
214
|
+
let traceSeq = 0;
|
|
215
|
+
const recent: Array<{ seq: number; event: TelemetryEvent }> = [];
|
|
216
|
+
const pending: Array<{ data: unknown; extra: Record<string, unknown>; at: number }> = [];
|
|
217
|
+
const retain = (event: TelemetryEvent) => {
|
|
218
|
+
recent.push({ seq: traceSeq++, event });
|
|
219
|
+
if (recent.length > 2_000) recent.splice(0, recent.length - 2_000);
|
|
220
|
+
};
|
|
221
|
+
const persist = (data: unknown, extra: Record<string, unknown>, at: number) => {
|
|
222
|
+
const event = { v: 1 as const, type: "unipi-trace" as const, at, ...extra, data };
|
|
223
|
+
retain(event);
|
|
224
|
+
sidecar?.append({ type: event.type, at: event.at, ...extra, data });
|
|
225
|
+
};
|
|
226
|
+
const recorder: UnipiTraceRecorder = {
|
|
227
|
+
bind(nextSessionId) {
|
|
228
|
+
if (sessionId === nextSessionId && sidecar) return;
|
|
229
|
+
sessionId = nextSessionId;
|
|
230
|
+
sidecar = new TelemetrySidecar(nextSessionId, sidecarRoot);
|
|
231
|
+
recent.length = 0;
|
|
232
|
+
traceSeq = 0;
|
|
233
|
+
for (const event of pending.splice(0)) persist(event.data, event.extra, event.at);
|
|
234
|
+
},
|
|
235
|
+
record(data, extra = {}) {
|
|
236
|
+
const tagged = data && typeof data === "object"
|
|
237
|
+
? { ...(data as Record<string, unknown>), affectsContext: affectsContext(data as Record<string, unknown>) }
|
|
238
|
+
: data;
|
|
239
|
+
if (!sidecar) {
|
|
240
|
+
pending.push({ data: tagged, extra, at: Date.now() });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
persist(tagged, extra, Date.now());
|
|
244
|
+
},
|
|
245
|
+
read() { return sidecar?.read() ?? []; },
|
|
246
|
+
revision() { return sidecar?.revision() ?? traceSeq; },
|
|
247
|
+
cursor() { return traceSeq; },
|
|
248
|
+
since(cursor) { return recent.filter(item => item.seq >= cursor).map(item => item.event); },
|
|
249
|
+
};
|
|
250
|
+
const cache = new Map<string, ExtensionAPI>();
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
recorder,
|
|
254
|
+
scope(packageName) {
|
|
255
|
+
const cached = cache.get(packageName);
|
|
256
|
+
if (cached) return cached;
|
|
257
|
+
const events = {
|
|
258
|
+
emit(channel: string, data: unknown) {
|
|
259
|
+
recorder.record({ package: packageName, surface: "event-bus", phase: "emit", channel, data });
|
|
260
|
+
return pi.events.emit(channel, data);
|
|
261
|
+
},
|
|
262
|
+
on(channel: string, handler: (data: unknown) => void) {
|
|
263
|
+
return pi.events.on(channel, (data) => {
|
|
264
|
+
const started = performance.now();
|
|
265
|
+
recorder.record({ package: packageName, surface: "event-bus", phase: "enter", channel, data });
|
|
266
|
+
try {
|
|
267
|
+
const result = (handler as (value: unknown) => unknown)(data);
|
|
268
|
+
if (result && typeof result === "object" && "then" in result) {
|
|
269
|
+
void Promise.resolve(result).then((value) => {
|
|
270
|
+
recorder.record({ package: packageName, surface: "event-bus", phase: "exit", channel, result: resultEvidence(value), durationMs: performance.now() - started });
|
|
271
|
+
}, (error) => {
|
|
272
|
+
recorder.record({ package: packageName, surface: "event-bus", phase: "error", channel, durationMs: performance.now() - started, error: errorData(error) });
|
|
273
|
+
});
|
|
274
|
+
} else {
|
|
275
|
+
recorder.record({ package: packageName, surface: "event-bus", phase: "exit", channel, result: resultEvidence(result), durationMs: performance.now() - started });
|
|
276
|
+
}
|
|
277
|
+
} catch (error) {
|
|
278
|
+
recorder.record({ package: packageName, surface: "event-bus", phase: "error", channel, durationMs: performance.now() - started, error: errorData(error) });
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
const scoped = new Proxy(pi as ExtensionAPI & Record<string, unknown>, {
|
|
285
|
+
get(target, property, receiver) {
|
|
286
|
+
if (property === "events") return events;
|
|
287
|
+
const original = Reflect.get(target, property, receiver);
|
|
288
|
+
if (property === "on" && typeof original === "function") {
|
|
289
|
+
return (eventName: string, handler: (event: unknown, ctx: unknown) => unknown) => original.call(target, eventName, async (event: unknown, ctx: unknown) => {
|
|
290
|
+
if (eventName === "session_start") {
|
|
291
|
+
const sessionId = (ctx as { sessionManager?: { getSessionId?(): string } })?.sessionManager?.getSessionId?.();
|
|
292
|
+
if (sessionId) recorder.bind(sessionId);
|
|
293
|
+
}
|
|
294
|
+
const started = performance.now();
|
|
295
|
+
const before = canonicalize(mutationSurface(event));
|
|
296
|
+
recorder.record({ package: packageName, surface: "hook", phase: "enter", hook: eventName, inputFingerprint: before === undefined ? undefined : fingerprint(before) });
|
|
297
|
+
try {
|
|
298
|
+
const result = await handler(event, scopedContext(ctx, packageName, recorder));
|
|
299
|
+
const after = canonicalize(effectiveSurface(eventName, event, result));
|
|
300
|
+
recorder.record({ package: packageName, surface: "hook", phase: "exit", hook: eventName, result: resultEvidence(result), mutation: mutationEvidence(before, after), durationMs: performance.now() - started });
|
|
301
|
+
return result;
|
|
302
|
+
} catch (error) {
|
|
303
|
+
recorder.record({ package: packageName, surface: "hook", phase: "error", hook: eventName, durationMs: performance.now() - started, error: errorData(error) });
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
if (property === "registerTool" && typeof original === "function") {
|
|
309
|
+
return (tool: Record<string, unknown>) => {
|
|
310
|
+
const wrapped = typeof tool.execute === "function" ? {
|
|
311
|
+
...tool,
|
|
312
|
+
execute: async (...args: unknown[]) => {
|
|
313
|
+
const started = performance.now();
|
|
314
|
+
recorder.record({ package: packageName, surface: "tool", phase: "enter", action: "execute", tool: tool.name, toolCallId: args[0], input: args[1] });
|
|
315
|
+
try {
|
|
316
|
+
const result = await (tool.execute as (...args: unknown[]) => unknown)(...args.slice(0, 4), scopedContext(args[4], packageName, recorder));
|
|
317
|
+
recorder.record({ package: packageName, surface: "tool", phase: "exit", action: "execute", tool: tool.name, toolCallId: args[0], result: resultEvidence(result), durationMs: performance.now() - started });
|
|
318
|
+
return result;
|
|
319
|
+
} catch (error) {
|
|
320
|
+
recorder.record({ package: packageName, surface: "tool", phase: "error", action: "execute", tool: tool.name, toolCallId: args[0], durationMs: performance.now() - started, error: errorData(error) });
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
} : tool;
|
|
325
|
+
recorder.record({ package: packageName, surface: "api", phase: "enter", action: property, input: traceArgs(property, [tool]) });
|
|
326
|
+
const result = original.call(target, wrapped);
|
|
327
|
+
recorder.record({ package: packageName, surface: "api", phase: "exit", action: property });
|
|
328
|
+
return result;
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
if (property === "registerCommand" && typeof original === "function") {
|
|
332
|
+
return (name: string, options: Record<string, unknown>) => {
|
|
333
|
+
const wrapped = typeof options.handler === "function" ? {
|
|
334
|
+
...options,
|
|
335
|
+
handler: async (args: string, ctx: unknown) => {
|
|
336
|
+
const started = performance.now();
|
|
337
|
+
recorder.record({ package: packageName, surface: "command", phase: "enter", action: name, input: { args } });
|
|
338
|
+
try {
|
|
339
|
+
const result = await (options.handler as (args: string, ctx: unknown) => unknown)(args, scopedContext(ctx, packageName, recorder));
|
|
340
|
+
recorder.record({ package: packageName, surface: "command", phase: "exit", action: name, result: resultEvidence(result), durationMs: performance.now() - started });
|
|
341
|
+
return result;
|
|
342
|
+
} catch (error) {
|
|
343
|
+
recorder.record({ package: packageName, surface: "command", phase: "error", action: name, durationMs: performance.now() - started, error: errorData(error) });
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
} : options;
|
|
348
|
+
recorder.record({ package: packageName, surface: "api", phase: "enter", action: property, input: { name } });
|
|
349
|
+
const result = original.call(target, name, wrapped);
|
|
350
|
+
recorder.record({ package: packageName, surface: "api", phase: "exit", action: property });
|
|
351
|
+
return result;
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
if (property === "registerShortcut" && typeof original === "function") {
|
|
355
|
+
return (shortcut: unknown, options: Record<string, unknown>) => original.call(target, shortcut, typeof options.handler === "function" ? {
|
|
356
|
+
...options,
|
|
357
|
+
handler: async (ctx: unknown) => {
|
|
358
|
+
const started = performance.now();
|
|
359
|
+
recorder.record({ package: packageName, surface: "shortcut", phase: "enter", action: String(shortcut) });
|
|
360
|
+
try {
|
|
361
|
+
const result = await (options.handler as (ctx: unknown) => unknown)(scopedContext(ctx, packageName, recorder));
|
|
362
|
+
recorder.record({ package: packageName, surface: "shortcut", phase: "exit", action: String(shortcut), result: resultEvidence(result), durationMs: performance.now() - started });
|
|
363
|
+
return result;
|
|
364
|
+
} catch (error) {
|
|
365
|
+
recorder.record({ package: packageName, surface: "shortcut", phase: "error", action: String(shortcut), durationMs: performance.now() - started, error: errorData(error) });
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
} : options);
|
|
370
|
+
}
|
|
371
|
+
if (typeof property === "string" && TRACED_ACTIONS.has(property) && typeof original === "function") {
|
|
372
|
+
return (...args: unknown[]) => {
|
|
373
|
+
const started = performance.now();
|
|
374
|
+
recorder.record({ package: packageName, surface: "api", phase: "enter", action: property, input: traceArgs(property, args) });
|
|
375
|
+
try {
|
|
376
|
+
const result = original.apply(target, args);
|
|
377
|
+
if (result && typeof result === "object" && "then" in result) {
|
|
378
|
+
return Promise.resolve(result).then((value) => {
|
|
379
|
+
recorder.record({ package: packageName, surface: "api", phase: "exit", action: property, result: value, durationMs: performance.now() - started });
|
|
380
|
+
return value;
|
|
381
|
+
}, (error) => {
|
|
382
|
+
recorder.record({ package: packageName, surface: "api", phase: "error", action: property, durationMs: performance.now() - started, error: errorData(error) });
|
|
383
|
+
throw error;
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
recorder.record({ package: packageName, surface: "api", phase: "exit", action: property, result, durationMs: performance.now() - started });
|
|
387
|
+
return result;
|
|
388
|
+
} catch (error) {
|
|
389
|
+
recorder.record({ package: packageName, surface: "api", phase: "error", action: property, durationMs: performance.now() - started, error: errorData(error) });
|
|
390
|
+
throw error;
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
return typeof original === "function" ? original.bind(target) : original;
|
|
395
|
+
},
|
|
396
|
+
}) as ExtensionAPI;
|
|
397
|
+
cache.set(packageName, scoped);
|
|
398
|
+
return scoped;
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|