@xprnvd/pi-context-view 0.0.0-stage → 0.8.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/LICENSE +21 -0
- package/README.md +114 -2
- package/package.json +55 -5
- package/src/capture/adjustments.ts +649 -0
- package/src/capture/attribution.ts +25 -0
- package/src/capture/builder.ts +128 -0
- package/src/capture/diff.ts +514 -0
- package/src/capture/dispatch.ts +36 -0
- package/src/capture/guard.ts +287 -0
- package/src/capture/messages.ts +246 -0
- package/src/capture/payload.ts +584 -0
- package/src/capture/redact.ts +58 -0
- package/src/capture/register.ts +129 -0
- package/src/capture/request.ts +251 -0
- package/src/capture/tools.ts +69 -0
- package/src/capture/tracker.ts +71 -0
- package/src/command.ts +164 -0
- package/src/compaction.ts +57 -0
- package/src/config.ts +408 -0
- package/src/index.ts +191 -0
- package/src/injections.ts +670 -0
- package/src/measure.ts +887 -0
- package/src/message-preview.ts +72 -0
- package/src/model-identity.ts +48 -0
- package/src/model.ts +548 -0
- package/src/pi-version.ts +29 -0
- package/src/probe/failure.ts +34 -0
- package/src/probe/filter.ts +126 -0
- package/src/probe/silent-probe.ts +349 -0
- package/src/probe/token.ts +28 -0
- package/src/probe/trigger.ts +178 -0
- package/src/probe/view.ts +26 -0
- package/src/projection.ts +122 -0
- package/src/prompt-additions.ts +226 -0
- package/src/prompt-blocks.ts +80 -0
- package/src/replay.ts +234 -0
- package/src/settings.ts +62 -0
- package/src/snapshot.ts +188 -0
- package/src/system-prompt-rows.ts +311 -0
- package/src/text.ts +46 -0
- package/src/token-diff.ts +211 -0
- package/src/transcript.ts +19 -0
- package/src/ui/color.ts +52 -0
- package/src/ui/diff-preview.ts +181 -0
- package/src/ui/injections-model.ts +487 -0
- package/src/ui/injections-view.ts +1023 -0
- package/src/ui/json-preview.ts +41 -0
- package/src/ui/layout.ts +161 -0
- package/src/ui/markers.ts +253 -0
- package/src/ui/notes.ts +117 -0
- package/src/ui/search.ts +427 -0
- package/src/ui/section-preview.ts +267 -0
- package/src/ui/skill-preview.ts +83 -0
- package/src/ui/status-bar.ts +94 -0
- package/src/ui/usage-map.ts +157 -0
- package/src/ui/usage-preview.ts +202 -0
- package/src/ui/usage-view.ts +1428 -0
- package/src/ui/wheel.ts +65 -0
- package/src/usage.ts +461 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Pi version support check; callers pass pi's exported `VERSION`. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Oldest Pi release whose request lifecycle, probe behavior, hidden tool
|
|
5
|
+
* reporting, and provider message adjustments this extension relies on.
|
|
6
|
+
*/
|
|
7
|
+
export const MIN_PI_VERSION = "1.3.0";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether `version` is MIN_PI_VERSION or newer. Only the numeric
|
|
11
|
+
* `major.minor.patch` core is compared, so a prerelease counts as its release.
|
|
12
|
+
* An unparseable version is assumed supported: there is no evidence to refuse it.
|
|
13
|
+
*/
|
|
14
|
+
export function isSupportedPiVersion(version: string): boolean {
|
|
15
|
+
const current = parseVersionCore(version);
|
|
16
|
+
const minimum = parseVersionCore(MIN_PI_VERSION);
|
|
17
|
+
if (current === undefined || minimum === undefined) return true;
|
|
18
|
+
for (const [index, part] of current.entries()) {
|
|
19
|
+
if (part !== minimum[index]) return part > minimum[index];
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Numeric `[major, minor, patch]` of a version string, or undefined when it has none. */
|
|
25
|
+
function parseVersionCore(version: string): [number, number, number] | undefined {
|
|
26
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
|
|
27
|
+
if (match === null) return undefined;
|
|
28
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
29
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Probe failure identity is separate from its user-facing explanation. */
|
|
2
|
+
export type ProbeFailureCause =
|
|
3
|
+
| "compaction" | "pending-messages" | "missing-model" | "virtual-model" | "missing-auth"
|
|
4
|
+
| "idle-warming" | "context-limit" | "settings" | "timeout" | "foreign-run" | "nested-send"
|
|
5
|
+
| "shutdown" | "startup" | "missing-snapshot";
|
|
6
|
+
|
|
7
|
+
/** Process-local failure metadata; never persisted with probe identities. */
|
|
8
|
+
export interface ProbeFailure {
|
|
9
|
+
readonly status: "failed";
|
|
10
|
+
readonly cause: ProbeFailureCause;
|
|
11
|
+
readonly reason: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Keep diagnostic wording at one boundary; detail names a provider or an existing startup error. */
|
|
15
|
+
export function probeFailure(cause: ProbeFailureCause, detail = ""): ProbeFailure {
|
|
16
|
+
const reasons: Record<ProbeFailureCause, string> = {
|
|
17
|
+
compaction: "Silent probe unavailable: context compaction is in progress.",
|
|
18
|
+
"pending-messages": "Silent probe unavailable: messages are waiting to be delivered.",
|
|
19
|
+
"missing-model": "Silent probe unavailable: no model is selected.",
|
|
20
|
+
"virtual-model": "Silent probe unavailable: a virtual model is selected.",
|
|
21
|
+
"missing-auth": `Silent probe unavailable: ${detail} has no configured authentication.`,
|
|
22
|
+
"idle-warming": "Silent probe unavailable: idle cache warming is enabled.",
|
|
23
|
+
"context-limit": "Silent probe unavailable: context exceeds the auto-compaction threshold.",
|
|
24
|
+
settings: "Silent probe unavailable: Pi settings could not be checked.",
|
|
25
|
+
timeout: "Silent probe timed out.",
|
|
26
|
+
"foreign-run": "Another agent run started before the silent probe was recognized.",
|
|
27
|
+
"nested-send": "Another extension sent a user message during the silent probe; neither run was stopped, " +
|
|
28
|
+
"so the empty probe prompt may reach the model.",
|
|
29
|
+
shutdown: "Session ended before the silent probe completed.",
|
|
30
|
+
startup: detail,
|
|
31
|
+
"missing-snapshot": "Silent probe settled without a request snapshot.",
|
|
32
|
+
};
|
|
33
|
+
return { status: "failed", cause, reason: reasons[cause] };
|
|
34
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProbeFilter: the identities of recorded probe messages and the filter that
|
|
3
|
+
* removes those messages from every request and capture baseline. It stays
|
|
4
|
+
* active without a probe of this runtime, because sessions keep probe messages
|
|
5
|
+
* from earlier runtimes.
|
|
6
|
+
*/
|
|
7
|
+
import type { ContextEvent, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
/** Session custom-entry type persisting probe message identities across extension runtimes. */
|
|
10
|
+
export const PROBE_IDENTITIES_CUSTOM_TYPE = "pi-context-view:probe-identities";
|
|
11
|
+
|
|
12
|
+
/** Exact identity used to remove only synthetic probe messages. */
|
|
13
|
+
export interface SyntheticMessageIdentity {
|
|
14
|
+
readonly role: "user" | "assistant";
|
|
15
|
+
readonly timestamp: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Data of one persisted identities entry: role and timestamp only, never content. */
|
|
19
|
+
export interface PersistedIdentities {
|
|
20
|
+
readonly messages: readonly SyntheticMessageIdentity[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Known probe message identities of this runtime, restored ones included. */
|
|
24
|
+
export class ProbeFilter {
|
|
25
|
+
private readonly identities = new Map<string, SyntheticMessageIdentity>();
|
|
26
|
+
|
|
27
|
+
/** Defensive copies of every known probe message identity. */
|
|
28
|
+
public get syntheticMessages(): SyntheticMessageIdentity[] {
|
|
29
|
+
return [...this.identities.values()].map((identity) => ({ ...identity }));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Merge probe identities persisted by an earlier extension runtime so prior
|
|
34
|
+
* probe messages stay excluded after resume, reload, or fork.
|
|
35
|
+
*/
|
|
36
|
+
public restoreIdentities(identities: readonly SyntheticMessageIdentity[]): void {
|
|
37
|
+
for (const identity of identities) this.record(identity);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Record one probe message identity; returns false when it was already known. */
|
|
41
|
+
public record(identity: SyntheticMessageIdentity): boolean {
|
|
42
|
+
const key = identityKey(identity);
|
|
43
|
+
if (this.identities.has(key)) return false;
|
|
44
|
+
this.identities.set(key, { role: identity.role, timestamp: identity.timestamp });
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Whether this exact role and timestamp was recorded for a probe. */
|
|
49
|
+
public has(message: SyntheticMessageIdentity): boolean {
|
|
50
|
+
return this.identities.has(identityKey(message));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether a stored or requested message is a probe message: its exact role
|
|
55
|
+
* and timestamp are recorded, and a user message is blank, as every stored
|
|
56
|
+
* probe prompt is. A genuine user message sent while the probe run started
|
|
57
|
+
* can share the prompt's millisecond, but it keeps its content.
|
|
58
|
+
*/
|
|
59
|
+
public matches(message: ContextEvent["messages"][number]): boolean {
|
|
60
|
+
if (message.role === "assistant") return this.has(message);
|
|
61
|
+
return message.role === "user" && isBlankContent(message.content) && this.has(message);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Remove only probe messages, as `matches()` decides. Returns the same array when none matches. */
|
|
65
|
+
public filterMessages(messages: ContextEvent["messages"]): ContextEvent["messages"] {
|
|
66
|
+
if (this.identities.size === 0) return messages;
|
|
67
|
+
const filtered = messages.filter((message) => !this.matches(message));
|
|
68
|
+
return filtered.length === messages.length ? messages : filtered;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Restore persisted identities on `session_start` and filter every request in
|
|
74
|
+
* `context_with_system`. Register before capture: capture's own
|
|
75
|
+
* `context_with_system` handler must see the filtered messages.
|
|
76
|
+
*/
|
|
77
|
+
export function registerProbeFilter(pi: ExtensionAPI, filter: ProbeFilter): void {
|
|
78
|
+
pi.on("session_start", (_event, ctx) => {
|
|
79
|
+
// Identities from all prior runtimes keep persisted probe messages out of
|
|
80
|
+
// later model contexts and Usage after resume, reload, or fork
|
|
81
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
82
|
+
if (entry.type === "custom" && entry.customType === PROBE_IDENTITIES_CUSTOM_TYPE) {
|
|
83
|
+
filter.restoreIdentities(parsePersistedIdentities(entry.data));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
pi.on("context_with_system", (event) => {
|
|
89
|
+
// Filtering here preserves system-message positions and cached prefixes
|
|
90
|
+
const messages = filter.filterMessages(event.messages);
|
|
91
|
+
return messages === event.messages ? undefined : { messages };
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parse one persisted probe-identities entry payload. Malformed or foreign
|
|
97
|
+
* records are ignored so a corrupt entry can never suppress genuine messages.
|
|
98
|
+
*/
|
|
99
|
+
export function parsePersistedIdentities(data: unknown): SyntheticMessageIdentity[] {
|
|
100
|
+
if (typeof data !== "object" || data === null) return [];
|
|
101
|
+
const messages = (data as { messages?: unknown }).messages;
|
|
102
|
+
if (!Array.isArray(messages)) return [];
|
|
103
|
+
const identities: SyntheticMessageIdentity[] = [];
|
|
104
|
+
for (const message of messages) {
|
|
105
|
+
if (typeof message !== "object" || message === null) continue;
|
|
106
|
+
const { role, timestamp } = message as { role?: unknown; timestamp?: unknown };
|
|
107
|
+
if ((role === "user" || role === "assistant") && typeof timestamp === "number" && Number.isFinite(timestamp)) {
|
|
108
|
+
identities.push({ role, timestamp });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return identities;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether user content is blank: no parts, as SilentProbe stores its prompt,
|
|
116
|
+
* or only empty text, as older versions stored the empty prompt Pi created.
|
|
117
|
+
*/
|
|
118
|
+
function isBlankContent(content: Extract<ContextEvent["messages"][number], { role: "user" }>["content"]): boolean {
|
|
119
|
+
if (typeof content === "string") return content === "";
|
|
120
|
+
return content.every((part) => part.type === "text" && part.text === "");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Map key uniquely identifying one probe message by role and timestamp. */
|
|
124
|
+
function identityKey(identity: SyntheticMessageIdentity): string {
|
|
125
|
+
return `${identity.role}:${identity.timestamp}`;
|
|
126
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SilentProbe: the one on-demand probe run of an extension runtime. It claims
|
|
3
|
+
* the run by token, aborts it before the provider, blanks and omits its
|
|
4
|
+
* messages, and records and persists their identities through ProbeFilter.
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
BeforeAgentStartEvent,
|
|
8
|
+
ContextEditEntryDraft,
|
|
9
|
+
ContextEvent,
|
|
10
|
+
ExtensionAPI,
|
|
11
|
+
InputEvent,
|
|
12
|
+
ProjectedSessionEntry,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
import { probeFailure, type ProbeFailure, type ProbeFailureCause } from "./failure.ts";
|
|
16
|
+
import { type PersistedIdentities, PROBE_IDENTITIES_CUSTOM_TYPE, type ProbeFilter } from "./filter.ts";
|
|
17
|
+
import { createProbeToken, type ProbeToken, readProbeToken } from "./token.ts";
|
|
18
|
+
|
|
19
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 5_000;
|
|
20
|
+
/** `AbortError` text of the JavaScript runtime that runs pi, for the probe's `turn_start` abort. */
|
|
21
|
+
const SETUP_ABORT_ERROR_MESSAGES = new Set([
|
|
22
|
+
"This operation was aborted", // Node.js
|
|
23
|
+
"The operation was aborted.", // Bun
|
|
24
|
+
]);
|
|
25
|
+
/** Reported once for a wrong claim; names no message, since raw content never reaches notifications. */
|
|
26
|
+
const WRONG_CLAIM_WARNING = "Silent probe may have stopped another extension's message instead of its own; " +
|
|
27
|
+
"its empty prompt may reach the model.";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Result of the one allowed silent-probe attempt. A settled run says nothing
|
|
31
|
+
* about capture; ProbeTrigger reads the probe's snapshot from the store.
|
|
32
|
+
*/
|
|
33
|
+
export type ProbeOutcome =
|
|
34
|
+
| { readonly status: "settled" }
|
|
35
|
+
| ProbeFailure;
|
|
36
|
+
|
|
37
|
+
/** A probe start request; concurrent callers share `token` and `completion`. */
|
|
38
|
+
export interface ProbeAttempt {
|
|
39
|
+
readonly started: boolean;
|
|
40
|
+
/** Correlation token to send the synthetic prompt under. */
|
|
41
|
+
readonly token: ProbeToken;
|
|
42
|
+
readonly completion: Promise<ProbeOutcome>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How SilentProbe treats a starting run:
|
|
47
|
+
* claimed the probe's own run: aborted before the provider, blanked, and omitted
|
|
48
|
+
* wrong-claim the first user-message run with the token after the claim: a nested send
|
|
49
|
+
* took the probe's identity, and this run is likely the probe prompt
|
|
50
|
+
* foreign any other run
|
|
51
|
+
* Only a claimed run is changed; the others run untouched.
|
|
52
|
+
*/
|
|
53
|
+
export type RunClaim = "claimed" | "wrong-claim" | "foreign";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Lifecycle of the single probe attempt. Ownership outlives the attempt's own
|
|
57
|
+
* completion, so a probe run arriving after a timeout or after an unattributed
|
|
58
|
+
* run is still claimed, aborted, and sanitized. A second idle input with the
|
|
59
|
+
* token ends ownership without a claim: the attempt becomes `ambiguous`.
|
|
60
|
+
*/
|
|
61
|
+
type ProbePhase = "idle" | "waiting" | "running" | "settled" | "ambiguous";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Progress of the owned run's prompt, its first user message, through its
|
|
65
|
+
* message events. A message sent while the run started follows the prompt and
|
|
66
|
+
* can share its millisecond timestamp, so only this order tells them apart.
|
|
67
|
+
*/
|
|
68
|
+
type PromptProgress = "pending" | "started" | "ended";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* State for one on-demand silent probe. It owns the correlation token and the
|
|
72
|
+
* timeout, and records the exact synthetic message identities in ProbeFilter.
|
|
73
|
+
* Pi API calls stay in `registerSilentProbe()` and ProbeTrigger.
|
|
74
|
+
*/
|
|
75
|
+
export class SilentProbe {
|
|
76
|
+
private readonly filter: ProbeFilter;
|
|
77
|
+
private phase: ProbePhase = "idle";
|
|
78
|
+
private attempt: ProbeAttempt | undefined;
|
|
79
|
+
private resolveCompletion: ((outcome: ProbeOutcome) => void) | undefined;
|
|
80
|
+
private outcome: ProbeOutcome | undefined;
|
|
81
|
+
private timeout: NodeJS.Timeout | undefined;
|
|
82
|
+
private promptProgress: PromptProgress = "pending";
|
|
83
|
+
/** Whether an idle input with the token was claimed; a second one makes ownership ambiguous. */
|
|
84
|
+
private inputClaimed = false;
|
|
85
|
+
/** Whether a wrong claim was reported; runs started from the probe's handlers inherit the token too. */
|
|
86
|
+
private wrongClaimReported = false;
|
|
87
|
+
/** Identities this runtime recorded since its last persisted entry. */
|
|
88
|
+
private unpersistedCount = 0;
|
|
89
|
+
|
|
90
|
+
/** Record probe identities in `filter`, which also holds those of earlier runtimes. */
|
|
91
|
+
public constructor(filter: ProbeFilter) {
|
|
92
|
+
this.filter = filter;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** True while the probe owns the in-flight agent run (including after a timeout). */
|
|
96
|
+
public get isCurrentRun(): boolean {
|
|
97
|
+
return this.phase === "running";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Begin the one allowed probe attempt with a failure timeout. Repeat calls
|
|
102
|
+
* return the original attempt's completion with `started: false`.
|
|
103
|
+
*/
|
|
104
|
+
public start(timeoutMs = DEFAULT_PROBE_TIMEOUT_MS): ProbeAttempt {
|
|
105
|
+
if (this.attempt !== undefined) {
|
|
106
|
+
return { ...this.attempt, started: false };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.phase = "waiting";
|
|
110
|
+
const { promise: completion, resolve } = Promise.withResolvers<ProbeOutcome>();
|
|
111
|
+
this.resolveCompletion = resolve;
|
|
112
|
+
this.timeout = setTimeout(() => {
|
|
113
|
+
this.resolve(probeFailure("timeout"));
|
|
114
|
+
}, timeoutMs);
|
|
115
|
+
this.attempt = { started: true, token: createProbeToken(), completion };
|
|
116
|
+
return this.attempt;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Claim the probe's own synthetic prompt among idle inputs. Recognition is
|
|
121
|
+
* causal rather than textual: the token is visible only inside the async
|
|
122
|
+
* context of this extension's own `sendUserMessage()` call. The probe sends
|
|
123
|
+
* while idle, so queued input is never its own, even with the token another
|
|
124
|
+
* extension's send from `before_agent_start` inherits. A second idle input
|
|
125
|
+
* with the token comes from a nested send in another extension's `input`
|
|
126
|
+
* handler, and either may be the probe's. Ownership is then ambiguous: reset
|
|
127
|
+
* no later input, claim no run, and fail the attempt.
|
|
128
|
+
*/
|
|
129
|
+
public claimInput(
|
|
130
|
+
input: Pick<InputEvent, "source" | "streamingBehavior">,
|
|
131
|
+
token: ProbeToken | undefined,
|
|
132
|
+
): boolean {
|
|
133
|
+
const isIdleInputWithToken = this.phase === "waiting"
|
|
134
|
+
&& input.source === "extension"
|
|
135
|
+
&& input.streamingBehavior === undefined
|
|
136
|
+
&& this.ownsToken(token);
|
|
137
|
+
if (!isIdleInputWithToken) return false;
|
|
138
|
+
if (!this.inputClaimed) {
|
|
139
|
+
this.inputClaimed = true;
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
this.phase = "ambiguous";
|
|
143
|
+
this.fail("nested-send");
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Claim the run this probe started: a user-message run carrying the token.
|
|
149
|
+
* Any other run is not provably ours, so it fails the attempt instead of
|
|
150
|
+
* activating the abort guard: it may belong to the user or to another
|
|
151
|
+
* extension and must run untouched. A custom-message run is foreign even
|
|
152
|
+
* with the token, which another extension's nested `sendMessage()` inherits.
|
|
153
|
+
* Ownership stays open afterwards so a delayed probe run is still claimed.
|
|
154
|
+
* The first user-message run with the token after the claim is a wrong
|
|
155
|
+
* claim: a nested send likely took the probe's identity. It runs untouched
|
|
156
|
+
* too, since a later send of another extension inherits the token as well.
|
|
157
|
+
*/
|
|
158
|
+
public beginRun(messageType: BeforeAgentStartEvent["messageType"], token: ProbeToken | undefined): RunClaim {
|
|
159
|
+
const isUserRunWithToken = messageType === "user" && this.ownsToken(token);
|
|
160
|
+
if (this.phase === "waiting") {
|
|
161
|
+
if (!isUserRunWithToken) {
|
|
162
|
+
this.fail("foreign-run");
|
|
163
|
+
return "foreign";
|
|
164
|
+
}
|
|
165
|
+
this.phase = "running";
|
|
166
|
+
return "claimed";
|
|
167
|
+
}
|
|
168
|
+
const hasClaimed = this.phase === "running" || this.phase === "settled";
|
|
169
|
+
if (!isUserRunWithToken || !hasClaimed || this.wrongClaimReported) return "foreign";
|
|
170
|
+
this.wrongClaimReported = true;
|
|
171
|
+
return "wrong-claim";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Record the owned run's prompt and abort identities as their message events
|
|
176
|
+
* arrive. Pi emits the run's starting messages before queued ones, so only
|
|
177
|
+
* the first user message is the probe's; later ones were sent while the run
|
|
178
|
+
* started. Every assistant message is a probe abort: the probe aborts each
|
|
179
|
+
* turn at `turn_start`, including the extra turns Pi runs for queued messages.
|
|
180
|
+
*/
|
|
181
|
+
public recordMessage(message: ContextEvent["messages"][number]): void {
|
|
182
|
+
if (!this.isCurrentRun) return;
|
|
183
|
+
if (message.role === "user") {
|
|
184
|
+
if (this.promptProgress !== "pending") return;
|
|
185
|
+
this.promptProgress = "started";
|
|
186
|
+
} else if (message.role !== "assistant") {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (this.filter.record({ role: message.role, timestamp: message.timestamp })) this.unpersistedCount++;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Replace a recorded probe message with an artifact-free version, or return
|
|
194
|
+
* undefined to keep pi's own. Filtering keeps probe messages out of later
|
|
195
|
+
* model contexts; blanking keeps them out of the transcript.
|
|
196
|
+
*/
|
|
197
|
+
public sanitizeMessage(
|
|
198
|
+
message: ContextEvent["messages"][number],
|
|
199
|
+
): ContextEvent["messages"][number] | undefined {
|
|
200
|
+
if (!this.isCurrentRun) return undefined;
|
|
201
|
+
if (message.role === "user") return this.blankProbePrompt(message);
|
|
202
|
+
if (message.role === "assistant") return this.blankProbeAbort(message);
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Omit known probe entries still visible in Pi's boundary projection at an
|
|
208
|
+
* owned run's `turn_end`. The projection already applies compaction, earlier
|
|
209
|
+
* edits, and earlier handlers' drafts, so each target is omitted once. Each
|
|
210
|
+
* entry is matched by its stored message, which a replacement does not change.
|
|
211
|
+
*/
|
|
212
|
+
public createContextEdits(contextEntries: readonly ProjectedSessionEntry[]): ContextEditEntryDraft[] {
|
|
213
|
+
if (!this.isCurrentRun) return [];
|
|
214
|
+
const edits: ContextEditEntryDraft[] = [];
|
|
215
|
+
for (const { sourceEntry, messages } of contextEntries) {
|
|
216
|
+
if (messages.length > 0 && sourceEntry.type === "message" && this.filter.matches(sourceEntry.message)) {
|
|
217
|
+
edits.push({ type: "context_edit", targetId: sourceEntry.id, replacement: null });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return edits;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Resolve a running attempt from `agent_settled`. */
|
|
224
|
+
public settle(): boolean {
|
|
225
|
+
if (!this.isCurrentRun) return false;
|
|
226
|
+
this.phase = "settled";
|
|
227
|
+
this.resolve({ status: "settled" });
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* End a pending attempt during shutdown or a synchronous startup failure.
|
|
233
|
+
* Ownership is untouched: only the attempt's own completion is resolved.
|
|
234
|
+
*/
|
|
235
|
+
public fail(cause: ProbeFailureCause, detail?: string): void {
|
|
236
|
+
if (this.attempt === undefined) return;
|
|
237
|
+
this.resolve(probeFailure(cause, detail));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Write every known identity, restored ones included, once this runtime
|
|
242
|
+
* recorded new ones since its last write. Records never contain content.
|
|
243
|
+
*/
|
|
244
|
+
public persistIdentities(write: (data: PersistedIdentities) => void): void {
|
|
245
|
+
if (this.unpersistedCount === 0) return;
|
|
246
|
+
write({ messages: this.filter.syntheticMessages });
|
|
247
|
+
this.unpersistedCount = 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Empty the synthetic prompt so no stored message keeps text another
|
|
252
|
+
* extension's input transform added to it. Only the first user message of
|
|
253
|
+
* the run is the prompt, even when a later one has the same timestamp.
|
|
254
|
+
*/
|
|
255
|
+
private blankProbePrompt(
|
|
256
|
+
message: Extract<ContextEvent["messages"][number], { role: "user" }>,
|
|
257
|
+
): ContextEvent["messages"][number] | undefined {
|
|
258
|
+
if (this.promptProgress !== "started") return undefined;
|
|
259
|
+
this.promptProgress = "ended";
|
|
260
|
+
if (message.content.length === 0 || !this.filter.has(message)) return undefined;
|
|
261
|
+
return { ...message, content: [] };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Replace a recorded probe abort with an empty successful message so pi does
|
|
266
|
+
* not render an abort transcript row. Pi reports the `turn_start` abort,
|
|
267
|
+
* which authentication rejects before streaming, as an error.
|
|
268
|
+
*/
|
|
269
|
+
private blankProbeAbort(
|
|
270
|
+
message: Extract<ContextEvent["messages"][number], { role: "assistant" }>,
|
|
271
|
+
): ContextEvent["messages"][number] | undefined {
|
|
272
|
+
const isProbeAbort = message.stopReason === "error"
|
|
273
|
+
&& message.errorMessage !== undefined
|
|
274
|
+
&& SETUP_ABORT_ERROR_MESSAGES.has(message.errorMessage);
|
|
275
|
+
if (!isProbeAbort || !this.filter.has(message)) return undefined;
|
|
276
|
+
return { ...message, content: [], stopReason: "stop", errorMessage: undefined };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Whether `token` identifies the current attempt. */
|
|
280
|
+
private ownsToken(token: ProbeToken | undefined): boolean {
|
|
281
|
+
return token !== undefined && token === this.attempt?.token;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Settle the completion promise exactly once and clear the timeout. */
|
|
285
|
+
private resolve(outcome: ProbeOutcome): void {
|
|
286
|
+
if (this.outcome !== undefined) return;
|
|
287
|
+
if (this.timeout !== undefined) clearTimeout(this.timeout);
|
|
288
|
+
this.timeout = undefined;
|
|
289
|
+
this.outcome = outcome;
|
|
290
|
+
const resolve = this.resolveCompletion;
|
|
291
|
+
this.resolveCompletion = undefined;
|
|
292
|
+
resolve?.(outcome);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Register the probe run's lifecycle handlers; SilentProbe imports no capture code. */
|
|
297
|
+
export function registerSilentProbe(pi: ExtensionAPI, probe: SilentProbe): void {
|
|
298
|
+
/** Append the identities entry when this runtime recorded new probe messages. */
|
|
299
|
+
function persistIdentities(): void {
|
|
300
|
+
probe.persistIdentities((data) => pi.appendEntry(PROBE_IDENTITIES_CUSTOM_TYPE, data));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
pi.on("input", (event) => {
|
|
304
|
+
// Reset text earlier input transforms added to our own synthetic prompt:
|
|
305
|
+
// the probe carries no instructions, and its run is identified by token.
|
|
306
|
+
// Claim an empty prompt too, so that a later nested send is recognized.
|
|
307
|
+
if (!probe.claimInput(event, readProbeToken()) || event.text === "") return undefined;
|
|
308
|
+
return { action: "transform", text: "" } as const;
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
312
|
+
if (probe.beginRun(event.messageType, readProbeToken()) === "wrong-claim") {
|
|
313
|
+
ctx.ui.notify(WRONG_CLAIM_WARNING, "warning");
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
pi.on("turn_start", (_event, ctx) => {
|
|
318
|
+
if (probe.isCurrentRun) ctx.abort();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
pi.on("message_start", (event) => {
|
|
322
|
+
probe.recordMessage(event.message);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
pi.on("message_end", (event) => {
|
|
326
|
+
const message = probe.sanitizeMessage(event.message);
|
|
327
|
+
return message === undefined ? undefined : { message };
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
pi.on("turn_end", (event) => {
|
|
331
|
+
if (!probe.isCurrentRun) return;
|
|
332
|
+
const entries = probe.createContextEdits(event.context.contextEntries);
|
|
333
|
+
return entries.length === 0 ? undefined : { entries: [...event.entries, ...entries] };
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
337
|
+
if (!probe.isCurrentRun) return;
|
|
338
|
+
if (ctx.mode === "tui") ctx.ui.setWorkingVisible(true);
|
|
339
|
+
probe.settle();
|
|
340
|
+
persistIdentities();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
pi.on("session_shutdown", () => {
|
|
344
|
+
// A shutdown mid-probe can leave probe messages already persisted in the
|
|
345
|
+
// session; write their identities so the next runtime keeps filtering them.
|
|
346
|
+
persistIdentities();
|
|
347
|
+
probe.fail("shutdown");
|
|
348
|
+
});
|
|
349
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-local correlation token for the silent probe. The token travels with
|
|
3
|
+
* the async execution context of this extension's own `sendUserMessage()` call,
|
|
4
|
+
* so lifecycle handlers can recognize the probe run itself instead of guessing
|
|
5
|
+
* from prompt text another extension's input transform may have rewritten.
|
|
6
|
+
*/
|
|
7
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
|
|
10
|
+
/** Opaque per-attempt correlation value. Never persisted, rendered, or logged. */
|
|
11
|
+
export type ProbeToken = string;
|
|
12
|
+
|
|
13
|
+
const PROBE_TOKEN_STORAGE = new AsyncLocalStorage<ProbeToken>();
|
|
14
|
+
|
|
15
|
+
/** Create the correlation token for one probe attempt. */
|
|
16
|
+
export function createProbeToken(): ProbeToken {
|
|
17
|
+
return randomUUID();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Run `send` so every handler pi invokes from that call observes `token`. */
|
|
21
|
+
export function runWithProbeToken<T>(token: ProbeToken, send: () => T): T {
|
|
22
|
+
return PROBE_TOKEN_STORAGE.run(token, send);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The token of the probe call the current handler runs inside, if any. */
|
|
26
|
+
export function readProbeToken(): ProbeToken | undefined {
|
|
27
|
+
return PROBE_TOKEN_STORAGE.getStore();
|
|
28
|
+
}
|