@xprnvd/pi-context-view 0.0.0-stage → 0.7.2

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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -2
  3. package/package.json +55 -5
  4. package/src/capture/adjustments.ts +649 -0
  5. package/src/capture/attribution.ts +25 -0
  6. package/src/capture/builder.ts +128 -0
  7. package/src/capture/diff.ts +455 -0
  8. package/src/capture/dispatch.ts +36 -0
  9. package/src/capture/guard.ts +287 -0
  10. package/src/capture/messages.ts +246 -0
  11. package/src/capture/payload.ts +584 -0
  12. package/src/capture/redact.ts +58 -0
  13. package/src/capture/register.ts +129 -0
  14. package/src/capture/request.ts +218 -0
  15. package/src/capture/tools.ts +69 -0
  16. package/src/capture/tracker.ts +71 -0
  17. package/src/command.ts +164 -0
  18. package/src/compaction.ts +57 -0
  19. package/src/config.ts +408 -0
  20. package/src/index.ts +191 -0
  21. package/src/injections.ts +670 -0
  22. package/src/measure.ts +887 -0
  23. package/src/message-preview.ts +72 -0
  24. package/src/model-identity.ts +48 -0
  25. package/src/model.ts +548 -0
  26. package/src/pi-version.ts +29 -0
  27. package/src/probe/failure.ts +34 -0
  28. package/src/probe/filter.ts +126 -0
  29. package/src/probe/silent-probe.ts +349 -0
  30. package/src/probe/token.ts +28 -0
  31. package/src/probe/trigger.ts +178 -0
  32. package/src/probe/view.ts +26 -0
  33. package/src/projection.ts +122 -0
  34. package/src/prompt-additions.ts +226 -0
  35. package/src/prompt-blocks.ts +80 -0
  36. package/src/replay.ts +234 -0
  37. package/src/settings.ts +62 -0
  38. package/src/snapshot.ts +188 -0
  39. package/src/system-prompt-rows.ts +311 -0
  40. package/src/text.ts +46 -0
  41. package/src/token-diff.ts +211 -0
  42. package/src/transcript.ts +19 -0
  43. package/src/ui/color.ts +52 -0
  44. package/src/ui/diff-preview.ts +181 -0
  45. package/src/ui/injections-model.ts +378 -0
  46. package/src/ui/injections-view.ts +729 -0
  47. package/src/ui/json-preview.ts +41 -0
  48. package/src/ui/layout.ts +148 -0
  49. package/src/ui/markers.ts +253 -0
  50. package/src/ui/notes.ts +117 -0
  51. package/src/ui/section-preview.ts +267 -0
  52. package/src/ui/skill-preview.ts +83 -0
  53. package/src/ui/status-bar.ts +94 -0
  54. package/src/ui/usage-map.ts +157 -0
  55. package/src/ui/usage-preview.ts +194 -0
  56. package/src/ui/usage-view.ts +1255 -0
  57. package/src/ui/wheel.ts +65 -0
  58. package/src/usage.ts +461 -0
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Capture layer wiring: reads Pi's hidden tools in `before_agent_start`,
3
+ * observes every agent request in `context_with_system`, pairs it with its
4
+ * provider payload in `before_provider_request`, and confirms
5
+ * the dispatch from assistant message events. Observe only:
6
+ * handlers return nothing and never change provider-bound data. Runs in every
7
+ * run mode, without any consumer, and imports no trigger or consumer code.
8
+ */
9
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
10
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
+
12
+ import type { ProbeView } from "../probe/view.ts";
13
+ import type { SnapshotBuilder } from "./builder.ts";
14
+ import { DispatchConfirmer } from "./dispatch.ts";
15
+ import { PayloadGuard } from "./guard.ts";
16
+ import { type CapturedRequest, captureRequest } from "./request.ts";
17
+ import { NO_PAYLOAD_REASON, RequestTracker } from "./tracker.ts";
18
+
19
+ /**
20
+ * Register RequestTracker, ProjectionReader, TranscriptCapture, PayloadGuard,
21
+ * and DispatchConfirmer. Register after ProbeFilter: its `context_with_system`
22
+ * handler must remove probe messages before this one compares and copies the
23
+ * transcript.
24
+ */
25
+ export function registerCapture(pi: ExtensionAPI, probe: ProbeView, builder: SnapshotBuilder): void {
26
+ const tracker = new RequestTracker<CapturedRequest>();
27
+ const confirmer = new DispatchConfirmer<number>();
28
+ const guard = new PayloadGuard({
29
+ publisher: builder,
30
+ blockImages: () => readBlockImages(pi),
31
+ });
32
+ /**
33
+ * Pi's hidden declarations, as every run's `before_agent_start` reports them.
34
+ * Only a later handler or a mid-run tool change can make this copy stale.
35
+ */
36
+ let hiddenTools: readonly string[] = [];
37
+
38
+ /** Settle the latest unpaired capture: no payload reached the monitor for it. */
39
+ function settleUnpaired(): void {
40
+ const capture = tracker.takeUnpaired();
41
+ if (capture === undefined) return;
42
+ builder.settleGuard(capture.id, { status: "incomplete", reason: NO_PAYLOAD_REASON });
43
+ builder.release(capture.id);
44
+ }
45
+
46
+ /** Hand the first assistant dispatch identity of the waiting request to the guard. */
47
+ function confirmDispatch(message: AgentMessage, ctx: ExtensionContext): void {
48
+ if (message.role !== "assistant") return;
49
+ const request = confirmer.take();
50
+ if (request === undefined) return;
51
+ const dispatch = { provider: message.provider, api: message.api, model: message.model };
52
+ guard.confirm(request, dispatch, (provider, modelId) => ctx.modelRegistry.find(provider, modelId));
53
+ }
54
+
55
+ pi.on("before_agent_start", (event) => {
56
+ // Pi before 1.0.4 leaves `hiddenTools` out of the options
57
+ hiddenTools = [...(event.systemPromptOptions.hiddenTools ?? [])];
58
+ });
59
+
60
+ pi.on("context_with_system", (event, ctx) => {
61
+ settleUnpaired();
62
+ let request: CapturedRequest;
63
+ try {
64
+ request = captureRequest({
65
+ id: tracker.nextId(),
66
+ origin: probe.isCurrentRun ? "synthetic-probe" : "real-turn",
67
+ messages: event.messages,
68
+ sessionManager: ctx.sessionManager,
69
+ effectivePrompt: ctx.getSystemPrompt(),
70
+ probe,
71
+ requestModel: ctx.model,
72
+ hiddenTools,
73
+ });
74
+ } catch {
75
+ // A request whose changes cannot be cloned is not captured; the request itself proceeds unchanged
76
+ return;
77
+ }
78
+ tracker.track(request);
79
+ builder.build(request);
80
+ });
81
+
82
+ pi.on("cache_warming_decision", () => {
83
+ tracker.noteWarmDecision();
84
+ });
85
+
86
+ pi.on("before_provider_request", (event) => {
87
+ const pairing = tracker.pair(event.payload);
88
+ if (pairing.type !== "paired") return;
89
+ // Later handlers can edit the payload in place, so copy it now
90
+ guard.accept(pairing.capture, event.payload);
91
+ const previous = confirmer.expect(pairing.capture.id);
92
+ if (previous !== undefined) guard.finishUnconfirmed(previous);
93
+ });
94
+
95
+ // No provider_stream_event handler: Pi emits that event, with an awaited emit per chunk, only when one exists
96
+ pi.on("message_start", (event, ctx) => {
97
+ confirmDispatch(event.message, ctx);
98
+ });
99
+
100
+ pi.on("message_end", (event, ctx) => {
101
+ confirmDispatch(event.message, ctx);
102
+ });
103
+
104
+ pi.on("agent_settled", () => {
105
+ settleUnpaired();
106
+ const unconfirmed = confirmer.take();
107
+ if (unconfirmed !== undefined) guard.finishUnconfirmed(unconfirmed);
108
+ });
109
+
110
+ pi.on("session_shutdown", () => {
111
+ hiddenTools = [];
112
+ tracker.clear();
113
+ confirmer.clear();
114
+ guard.clear();
115
+ builder.clear();
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Pi's live `images.blockImages` setting: the evidence for its blocked-image
121
+ * text in a payload. Unreadable settings count as off, so that text is reported.
122
+ */
123
+ function readBlockImages(pi: ExtensionAPI): boolean {
124
+ try {
125
+ return pi.getSettings().images?.blockImages === true;
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * ProjectionReader and TranscriptCapture: the synchronous part of a structured
3
+ * capture in `context_with_system`. Everything here runs inside the
4
+ * handler, before later handlers can mutate the shared request messages.
5
+ */
6
+ import {
7
+ contentText, getCurrentSystemMessage, getCurrentSystemPrompt, getDeclaredTools, type SystemMessage, type Tool,
8
+ } from "@earendil-works/pi-ai";
9
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+
11
+ import type { ProbeView } from "../probe/view.ts";
12
+ import type { CaptureOrigin, RequestMessage } from "../snapshot.ts";
13
+ import { trimMatchedEnds, type UnmatchedMessages } from "./diff.ts";
14
+ import type { GuardModel } from "./guard.ts";
15
+
16
+ /**
17
+ * Forced prompt per capture, resolved by whichever deferred job asks first.
18
+ * Weak keys release an entry with its capture.
19
+ */
20
+ const FORCED_PROMPTS = new WeakMap<ForcedPromptInput, string | undefined>();
21
+
22
+ /** A baseline message with the session entry that owns it. */
23
+ export interface BaselineMessage {
24
+ readonly entryId: string;
25
+ readonly message: RequestMessage;
26
+ }
27
+
28
+ /** Filtered session projection at capture time. */
29
+ export interface Baseline {
30
+ /** Session leaf; `buildSessionProjection(entries, leafId)` rebuilds these messages. */
31
+ readonly leafId: string | null;
32
+ readonly messages: readonly BaselineMessage[];
33
+ }
34
+
35
+ /**
36
+ * Text of one request system message, without its tool changes. The payload
37
+ * guard renders it as Pi does, at its position, for models that keep later
38
+ * system messages in place.
39
+ */
40
+ export interface SystemText {
41
+ /** Number of non-system request messages before this one. */
42
+ readonly position: number;
43
+ /** Plain content; text blocks are joined as Pi renders them. */
44
+ readonly content: string;
45
+ readonly sections?: Readonly<Record<string, string | null>>;
46
+ }
47
+
48
+ /**
49
+ * The parts of a request the deferred diff and the payload guard need, as this
50
+ * extension's handler saw them. Request-side values are owned copies.
51
+ */
52
+ export interface RequestCopy {
53
+ /** The request's replayed system state; undefined when it has none. */
54
+ readonly system?: SystemMessage;
55
+ /** Every request system message's text, in order. */
56
+ readonly systemTexts: readonly SystemText[];
57
+ /** Every definition declared in the request, latest per name, including tools removed later. */
58
+ readonly declarations: readonly Tool[];
59
+ /**
60
+ * Non-system messages left after removing the ends both sides share: session
61
+ * originals on the baseline side, copies on the request side.
62
+ */
63
+ readonly conversation: UnmatchedMessages;
64
+ }
65
+
66
+ /**
67
+ * Everything the deferred diff needs from one request. Process-local: it holds
68
+ * raw content and is released once its snapshot is built.
69
+ */
70
+ export interface CapturedRequest extends RequestCopy {
71
+ readonly id: number;
72
+ readonly origin: CaptureOrigin;
73
+ readonly baseline: Baseline;
74
+ /**
75
+ * `ctx.getSystemPrompt()` at capture. Deferred jobs compare it with the
76
+ * baseline replay to detect a forced prompt ({@link resolveForcedPrompt}).
77
+ */
78
+ readonly effectivePrompt: string;
79
+ /** `ctx.model` at capture: the dispatched model on a physical selection, the virtual one otherwise. */
80
+ readonly requestModel?: GuardModel;
81
+ /** Tools Pi left out of the request; absent when it hid none. */
82
+ readonly hiddenTools?: readonly string[];
83
+ }
84
+
85
+ /** What forced-prompt detection reads from a capture. */
86
+ export interface ForcedPromptInput {
87
+ readonly effectivePrompt: string;
88
+ readonly baseline: { readonly messages: readonly BaselineMessage[] };
89
+ }
90
+
91
+ /** Inputs of one capture, read from the `context_with_system` event and its context. */
92
+ export interface CaptureInput {
93
+ readonly id: number;
94
+ readonly origin: CaptureOrigin;
95
+ readonly messages: readonly RequestMessage[];
96
+ readonly sessionManager: Pick<ExtensionContext["sessionManager"], "buildSessionProjection" | "getLeafId">;
97
+ /** `ctx.getSystemPrompt()`: Pi's effective prompt for the current run. */
98
+ readonly effectivePrompt: string;
99
+ readonly probe: Pick<ProbeView, "filterMessages">;
100
+ /** `ctx.model`; its identity is copied before later handlers can change it. */
101
+ readonly requestModel?: GuardModel;
102
+ /** Pi's hidden declarations for the current run, from its `before_agent_start` options. */
103
+ readonly hiddenTools?: readonly string[];
104
+ }
105
+
106
+ /**
107
+ * Read the baseline, copy what differs from it, and keep the effective prompt
108
+ * for deferred forced-prompt detection. Throws when a differing message cannot
109
+ * be cloned, for example when a handler added a function value to it.
110
+ */
111
+ export function captureRequest(input: CaptureInput): CapturedRequest {
112
+ const baseline = readBaseline(input.sessionManager, input.probe);
113
+ return {
114
+ id: input.id,
115
+ origin: input.origin,
116
+ baseline,
117
+ ...copyRequest(baseline.messages, input.messages),
118
+ effectivePrompt: input.effectivePrompt,
119
+ ...(input.requestModel === undefined ? {} : { requestModel: copyGuardModel(input.requestModel) }),
120
+ ...(input.hiddenTools === undefined || input.hiddenTools.length === 0
121
+ ? {}
122
+ : { hiddenTools: [...input.hiddenTools] }),
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Copy the request's replayed system state and the messages that differ from
128
+ * the baseline. Messages equal to the baseline at both ends are compared in
129
+ * place and never copied. System text positions and historical declarations
130
+ * also belong to the copy: serializers may keep them instead of collapsing.
131
+ */
132
+ export function copyRequest(baseline: readonly BaselineMessage[], messages: readonly RequestMessage[]): RequestCopy {
133
+ const unmatched = trimMatchedEnds(baseline, messages);
134
+ // Later handlers share these objects, tool schemas included, and may edit them in place
135
+ const copy = structuredClone({
136
+ system: getCurrentSystemMessage(messages), declarations: getDeclaredTools(messages), request: unmatched.request,
137
+ });
138
+ return {
139
+ ...(copy.system === undefined ? {} : { system: copy.system }),
140
+ systemTexts: copySystemTexts(messages),
141
+ declarations: copy.declarations,
142
+ conversation: { prefix: unmatched.prefix, baseline: unmatched.baseline, request: copy.request },
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Read the canonical session projection Pi rebuilt this request from, without
148
+ * recorded probe messages, keeping each message's source entry. The projection
149
+ * returns stored session objects; they are only read, never changed.
150
+ */
151
+ export function readBaseline(
152
+ sessionManager: CaptureInput["sessionManager"],
153
+ probe: CaptureInput["probe"],
154
+ ): Baseline {
155
+ const projection = sessionManager.buildSessionProjection();
156
+ const messages = projection.entries.flatMap(({ sourceEntry, messages: entryMessages }) =>
157
+ probe.filterMessages(entryMessages).map((message) => ({ entryId: sourceEntry.id, message })));
158
+ return { leafId: sessionManager.getLeafId(), messages };
159
+ }
160
+
161
+ /**
162
+ * The capture's forced prompt, replaying its baseline at most once: the
163
+ * snapshot builder and the payload guard both read it, in either order.
164
+ */
165
+ export function resolveForcedPrompt(capture: ForcedPromptInput): string | undefined {
166
+ if (FORCED_PROMPTS.has(capture)) return FORCED_PROMPTS.get(capture);
167
+ const forced = detectForcedPrompt(capture.effectivePrompt, capture.baseline.messages);
168
+ FORCED_PROMPTS.set(capture, forced);
169
+ return forced;
170
+ }
171
+
172
+ /**
173
+ * The forced prompt of the current run, or undefined when the effective prompt
174
+ * is the one the baseline replays. Pi records structured sections even for a
175
+ * forced prompt and projects the forced text onto the request afterwards.
176
+ * Runs off the request's critical path: baseline messages are append-only
177
+ * session objects, so the replay matches the one at capture.
178
+ */
179
+ export function detectForcedPrompt(effectivePrompt: string, baseline: readonly BaselineMessage[]): string | undefined {
180
+ const replayed = getCurrentSystemPrompt(baseline.map(({ message }) => message));
181
+ return effectivePrompt === replayed ? undefined : effectivePrompt;
182
+ }
183
+
184
+ /**
185
+ * Copy the text of every system message with its position. Strings are
186
+ * immutable, so a shallow copy of the sections suffices; tool changes are
187
+ * left out because the replayed system state already holds them.
188
+ */
189
+ function copySystemTexts(messages: readonly RequestMessage[]): SystemText[] {
190
+ const texts: SystemText[] = [];
191
+ let position = 0;
192
+ for (const message of messages) {
193
+ if (message.role !== "system") {
194
+ position++;
195
+ continue;
196
+ }
197
+ const content = contentText(message.content);
198
+ texts.push(message.sections === undefined
199
+ ? { position, content }
200
+ : { position, content, sections: { ...message.sections } });
201
+ }
202
+ return texts;
203
+ }
204
+
205
+ /**
206
+ * Copy the identity and the capabilities message normalization reads, before
207
+ * later handlers can change the model object. Only top-level `compat` flags
208
+ * are read, so a shallow copy suffices.
209
+ */
210
+ function copyGuardModel(model: GuardModel): GuardModel {
211
+ return {
212
+ provider: model.provider,
213
+ api: model.api,
214
+ id: model.id,
215
+ input: [...model.input],
216
+ ...(model.compat === undefined ? {} : { compat: { ...model.compat } }),
217
+ };
218
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * PayloadGuard's tool-declaration channel: compare the declarations a
3
+ * payload carries with the tools the structured capture replayed, less the ones
4
+ * Pi hid. Pure functions over process-local data.
5
+ */
6
+ import type { GuardFinding } from "../snapshot.ts";
7
+ import { diffLines, normalizeText } from "./messages.ts";
8
+ import type { PayloadDeclaration } from "./payload.ts";
9
+
10
+ /** Inputs of one tool-channel comparison. */
11
+ export interface ToolComparisonInput {
12
+ /** Tools replayed from the request at the monitor's handler, without the ones Pi hid. */
13
+ readonly expected: readonly PayloadDeclaration[];
14
+ /** Declarations of the payload after its inline changes were replayed. */
15
+ readonly declarations: readonly PayloadDeclaration[];
16
+ /**
17
+ * Match payload names to expected ones without case, as Pi does for Claude
18
+ * Code tool names with an Anthropic OAuth token.
19
+ */
20
+ readonly ignoreNameCase: boolean;
21
+ }
22
+
23
+ /**
24
+ * Compare the declarations; each difference is edited after monitor:
25
+ * missing from the payload deleted, with the captured description lines
26
+ * added by the payload added, with the payload's description lines
27
+ * description differs modified, with the changed lines
28
+ * Descriptions are compared without whitespace, as message text is. Schemas
29
+ * are not compared: Pi adapts them per provider, such as for strict mode.
30
+ */
31
+ export function compareToolDeclarations(input: ToolComparisonInput): GuardFinding[] {
32
+ const expectedByName = new Map(input.expected.map((tool) => [tool.name, tool]));
33
+ const declared = input.declarations.map((declaration) => ({
34
+ ...declaration,
35
+ name: resolveName(declaration.name, expectedByName, input.ignoreNameCase),
36
+ }));
37
+ const declaredNames = new Set(declared.map((declaration) => declaration.name));
38
+ const findings: GuardFinding[] = [];
39
+
40
+ for (const tool of input.expected) {
41
+ if (declaredNames.has(tool.name)) continue;
42
+ findings.push({
43
+ type: "payload-tool-change",
44
+ change: "deleted",
45
+ name: tool.name,
46
+ lines: diffLines(tool.description ?? "", ""),
47
+ });
48
+ }
49
+ for (const declaration of declared) {
50
+ const tool = expectedByName.get(declaration.name);
51
+ const before = tool?.description ?? "";
52
+ const after = declaration.description ?? "";
53
+ if (tool !== undefined && normalizeText(before) === normalizeText(after)) continue;
54
+ findings.push({
55
+ type: "payload-tool-change",
56
+ change: tool === undefined ? "added" : "modified",
57
+ name: declaration.name,
58
+ lines: diffLines(before, after),
59
+ });
60
+ }
61
+ return findings;
62
+ }
63
+
64
+ /** The expected name a payload name stands for; itself when it matches exactly or nothing matches. */
65
+ function resolveName(name: string, expected: ReadonlyMap<string, unknown>, ignoreCase: boolean): string {
66
+ if (!ignoreCase || expected.has(name)) return name;
67
+ const lower = name.toLowerCase();
68
+ return [...expected.keys()].find((candidate) => candidate.toLowerCase() === lower) ?? name;
69
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * RequestTracker: numbers captures and pairs each agent payload with the
3
+ * latest capture still waiting for one. `turnIndex` restarts with every agent
4
+ * run, so only a local number identifies a request.
5
+ */
6
+ import { hasWarmOutputLimit } from "./payload.ts";
7
+
8
+ /** Guard reason of a capture that ended without a paired provider payload. */
9
+ export const NO_PAYLOAD_REASON = "No provider payload was observed for this request.";
10
+
11
+ /**
12
+ * How a payload relates to the captures:
13
+ * paired the payload of the latest unpaired capture
14
+ * warm-refresh a cache-warm refresh repeating an earlier request; not compared
15
+ * unpaired no capture is waiting, as for a refresh after its run or a nonstandard host
16
+ */
17
+ export type PayloadPairing<Capture> =
18
+ | { readonly type: "paired"; readonly capture: Capture }
19
+ | { readonly type: "warm-refresh" }
20
+ | { readonly type: "unpaired" };
21
+
22
+ /**
23
+ * Capture numbering and payload pairing state. Agent-level retries start a
24
+ * new run with a new capture; provider-level retries resend a payload without
25
+ * another `before_provider_request`, so neither needs special pairing.
26
+ */
27
+ export class RequestTracker<Capture> {
28
+ private captureCount = 0;
29
+ private unpaired: Capture | undefined;
30
+ private warmDecisionSinceCapture = false;
31
+
32
+ /** Number the next capture; later captures have larger numbers. */
33
+ public nextId(): number {
34
+ return ++this.captureCount;
35
+ }
36
+
37
+ /** Make `capture` the latest unpaired capture; earlier warm decisions no longer apply. */
38
+ public track(capture: Capture): void {
39
+ this.unpaired = capture;
40
+ this.warmDecisionSinceCapture = false;
41
+ }
42
+
43
+ /** Take the unpaired capture, if any, so its guard can settle without a payload. */
44
+ public takeUnpaired(): Capture | undefined {
45
+ const capture = this.unpaired;
46
+ this.unpaired = undefined;
47
+ return capture;
48
+ }
49
+
50
+ /** Note a `cache_warming_decision`: a refresh may follow before the next capture. */
51
+ public noteWarmDecision(): void {
52
+ this.warmDecisionSinceCapture = true;
53
+ }
54
+
55
+ /**
56
+ * Pair a payload. A warm decision alone is not enough: handler results do
57
+ * not update the decision event, so a later handler may have stopped the
58
+ * refresh. The refresh's one-token output limit confirms it.
59
+ */
60
+ public pair(payload: unknown): PayloadPairing<Capture> {
61
+ if (this.warmDecisionSinceCapture && hasWarmOutputLimit(payload)) return { type: "warm-refresh" };
62
+ const capture = this.takeUnpaired();
63
+ return capture === undefined ? { type: "unpaired" } : { type: "paired", capture };
64
+ }
65
+
66
+ /** Forget the unpaired capture and warm decisions, as at session shutdown. */
67
+ public clear(): void {
68
+ this.unpaired = undefined;
69
+ this.warmDecisionSinceCapture = false;
70
+ }
71
+ }
package/src/command.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * `/context` command grammar, argument completions, and resolution of the
3
+ * latest request snapshot that both views read.
4
+ */
5
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
7
+
8
+ import type { ConfigCreationResult } from "./config.ts";
9
+ import { MIN_PI_VERSION } from "./pi-version.ts";
10
+ import type { ProbeTrigger } from "./probe/trigger.ts";
11
+ import type { RequestSnapshot, SnapshotReader } from "./snapshot.ts";
12
+ import { normalizePreviewText } from "./text.ts";
13
+
14
+ const COMMAND_USAGE = "Usage: /context [usage|injections|config]";
15
+ /**
16
+ * Slash-command palette text, kept beside the grammar it describes.
17
+ * RegisteredCommand has no argumentHint; mimic pi's `<hint> — <description>` style.
18
+ */
19
+ export const CONTEXT_COMMAND_DESCRIPTION =
20
+ "[usage|injections|config] - Inspect context usage, injections";
21
+ /** Cap for reported messages, which may quote configuration files and OS error text. */
22
+ const MAX_REPORTED_MESSAGE_LENGTH = 500;
23
+ const DEFAULT_VIEW: ContextView = "usage";
24
+ const ARGUMENT_OPTIONS = [
25
+ { value: "usage", label: "usage", description: "Show estimated context usage" },
26
+ { value: "injections", label: "injections", description: "Explore the latest request's injections" },
27
+ { value: "config", label: "config", description: "Create config file populated with defaults" },
28
+ ] satisfies AutocompleteItem[];
29
+
30
+ /** The focused view a `/context` invocation requests. */
31
+ export type ContextView = "usage" | "injections";
32
+
33
+ /** Parsed `/context` argument grammar. */
34
+ export type ContextCommand =
35
+ | { readonly type: "view"; readonly view: ContextView }
36
+ | { readonly type: "config" }
37
+ | { readonly type: "invalid"; readonly message: string };
38
+
39
+ /**
40
+ * The latest request snapshot, or why the view opens without one:
41
+ * missing a probe was skipped or failed; the view must explain why it degraded
42
+ * unprobed none was captured yet and `autoProbing` is off, an expected state
43
+ */
44
+ export type RequestSnapshotResult =
45
+ | { readonly type: "snapshot"; readonly snapshot: RequestSnapshot }
46
+ | { readonly type: "missing"; readonly degradedReason: string }
47
+ | { readonly type: "unprobed" };
48
+
49
+ /** Parse the complete, intentionally small `/context` argument grammar. */
50
+ export function parseContextCommand(argumentsText: string): ContextCommand {
51
+ const words = argumentsText.trim().toLowerCase().split(/\s+/).filter(Boolean);
52
+ if (words.length === 0) {
53
+ return { type: "view", view: DEFAULT_VIEW };
54
+ }
55
+ if (words.length === 1 && words[0] === "usage") {
56
+ return { type: "view", view: "usage" };
57
+ }
58
+ if (words.length === 1 && words[0] === "injections") {
59
+ return { type: "view", view: "injections" };
60
+ }
61
+ if (words.length === 1 && words[0] === "config") {
62
+ return { type: "config" };
63
+ }
64
+ return { type: "invalid", message: COMMAND_USAGE };
65
+ }
66
+
67
+ /** Complete full argument values for the supported `/context` grammar. */
68
+ export function getContextArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
69
+ const normalizedPrefix = argumentPrefix.trimStart().toLowerCase();
70
+ const matches = ARGUMENT_OPTIONS.filter((option) => option.value.startsWith(normalizedPrefix));
71
+ return matches.length > 0 ? matches.map((option) => ({ ...option })) : null;
72
+ }
73
+
74
+ /**
75
+ * Obtain the latest request snapshot of this runtime, asking ProbeTrigger
76
+ * only when the store has none after the agent is idle: a real turn that was
77
+ * running publishes one without a probe. A probe's snapshot is read from the
78
+ * store, not from the probe result. With `autoProbing` off, an empty store
79
+ * resolves to `unprobed` without asking ProbeTrigger.
80
+ */
81
+ export async function resolveRequestSnapshot(
82
+ snapshots: SnapshotReader,
83
+ trigger: ProbeTrigger,
84
+ context: ExtensionCommandContext,
85
+ autoProbing: boolean,
86
+ ): Promise<RequestSnapshotResult> {
87
+ const existing = snapshots.latest();
88
+ if (existing !== undefined) return { type: "snapshot", snapshot: existing };
89
+ await context.waitForIdle();
90
+ const afterIdle = snapshots.latest();
91
+ if (afterIdle !== undefined) return { type: "snapshot", snapshot: afterIdle };
92
+
93
+ if (!autoProbing) return { type: "unprobed" };
94
+
95
+ const result = await trigger.request(context);
96
+ const probed = snapshots.latest();
97
+ if (probed !== undefined) return { type: "snapshot", snapshot: probed };
98
+ const reason = result.status === "failed" ? result.reason : "Silent probe did not capture a request.";
99
+ return { type: "missing", degradedReason: `${reason} Extension additions were not observed.` };
100
+ }
101
+
102
+ /**
103
+ * Report command errors in both interactive and headless modes. Messages can
104
+ * quote untrusted text such as configuration keys, so they are sanitized and
105
+ * capped before reaching the terminal.
106
+ */
107
+ export function reportCommandMessage(
108
+ context: ExtensionCommandContext,
109
+ message: string,
110
+ type: "info" | "warning" | "error",
111
+ ): void {
112
+ const safeMessage = truncate(normalizePreviewText(message), MAX_REPORTED_MESSAGE_LENGTH);
113
+ if (context.hasUI) {
114
+ context.ui.notify(safeMessage, type);
115
+ return;
116
+ }
117
+ process.stderr.write(`${safeMessage}\n`);
118
+ }
119
+
120
+ /** Refuse a view outside TUI mode, naming the form the user typed. */
121
+ export function reportTuiOnly(context: ExtensionCommandContext, view: ContextView): void {
122
+ reportCommandMessage(context, `/context ${view} is available in TUI mode only.`, "warning");
123
+ }
124
+
125
+ /**
126
+ * Refuse a view while compaction is active: the session projection both views
127
+ * read is about to be replaced, and a probe could not run safely anyway.
128
+ */
129
+ export function reportCompactionInProgress(context: ExtensionCommandContext, view: ContextView): void {
130
+ reportCommandMessage(context, `/context ${view} is unavailable while compaction is in progress.`, "warning");
131
+ }
132
+
133
+ /** Refuse any `/context` form on a Pi version whose lifecycle this extension does not capture. */
134
+ export function reportUnsupportedPi(context: ExtensionCommandContext, version: string): void {
135
+ reportCommandMessage(context,
136
+ `/context requires Pi ${MIN_PI_VERSION} or newer; this is Pi ${version}. Nothing was captured.`,
137
+ "error",
138
+ );
139
+ }
140
+
141
+ /** Report the outcome of the explicit create-only configuration command. */
142
+ export function reportConfigCreation(context: ExtensionCommandContext, result: ConfigCreationResult): void {
143
+ switch (result.type) {
144
+ case "created":
145
+ reportCommandMessage(context, `Created default configuration: ${result.filePath}`, "info");
146
+ break;
147
+ case "exists":
148
+ reportCommandMessage(context, `Configuration already exists; left unchanged: ${result.filePath}`, "warning");
149
+ break;
150
+ case "failed":
151
+ reportCommandMessage(context, `Cannot create configuration at ${result.filePath}: ${result.reason}`, "error");
152
+ break;
153
+ default: {
154
+ // Compile-time proof that every result variant is reported.
155
+ const _exhaustive: never = result;
156
+ return _exhaustive;
157
+ }
158
+ }
159
+ }
160
+
161
+ /** Shorten over-long text with an ellipsis marker. */
162
+ function truncate(text: string, maxLength: number): string {
163
+ return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
164
+ }