@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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -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 +514 -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 +251 -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 +487 -0
  46. package/src/ui/injections-view.ts +1023 -0
  47. package/src/ui/json-preview.ts +41 -0
  48. package/src/ui/layout.ts +161 -0
  49. package/src/ui/markers.ts +253 -0
  50. package/src/ui/notes.ts +117 -0
  51. package/src/ui/search.ts +427 -0
  52. package/src/ui/section-preview.ts +267 -0
  53. package/src/ui/skill-preview.ts +83 -0
  54. package/src/ui/status-bar.ts +94 -0
  55. package/src/ui/usage-map.ts +157 -0
  56. package/src/ui/usage-preview.ts +202 -0
  57. package/src/ui/usage-view.ts +1428 -0
  58. package/src/ui/wheel.ts +65 -0
  59. package/src/usage.ts +461 -0
@@ -0,0 +1,178 @@
1
+ /**
2
+ * ProbeTrigger: the automatic probe policy. It waits for idle, applies
3
+ * the probe preconditions, starts the one SilentProbe attempt of this runtime,
4
+ * and waits for its snapshot in SnapshotStore. It imports no capture module and
5
+ * applies no run-mode guard; consumers decide when to ask for a probe.
6
+ */
7
+ import { type ExtensionAPI, type ExtensionCommandContext, shouldCompact } from "@earendil-works/pi-coding-agent";
8
+
9
+ import type { CompactionState } from "../compaction.ts";
10
+ import { readGlobalCacheWarmingMode, readLiveSettings } from "../settings.ts";
11
+ import type { SnapshotReader } from "../snapshot.ts";
12
+ import { probeFailure, type ProbeFailure } from "./failure.ts";
13
+ import type { ProbeOutcome, SilentProbe } from "./silent-probe.ts";
14
+ import { runWithProbeToken } from "./token.ts";
15
+
16
+ /**
17
+ * How long to wait for the probe snapshot after the probe settles. Capture
18
+ * settles the probe's guard in its own `agent_settled` handler, which runs
19
+ * after SilentProbe's, so the snapshot normally arrives at once.
20
+ */
21
+ const DEFAULT_SNAPSHOT_GRACE_MS = 1_000;
22
+
23
+ /**
24
+ * Result of asking for a probe: the store received the probe's snapshot with a
25
+ * settled guard, or why it did not. The result holds no snapshot, so the
26
+ * cached attempt keeps none in memory; consumers read it from the store.
27
+ */
28
+ export type ProbeResult =
29
+ | { readonly status: "captured" }
30
+ | ProbeFailure;
31
+
32
+ /** Dependencies and timing of a ProbeTrigger. */
33
+ export interface ProbeTriggerOptions {
34
+ readonly pi: ExtensionAPI;
35
+ readonly probe: SilentProbe;
36
+ readonly snapshots: SnapshotReader;
37
+ readonly compaction: CompactionState;
38
+ /** Wait after the probe settles before reporting a missing snapshot; defaults to 1 s. */
39
+ readonly snapshotGraceMs?: number;
40
+ }
41
+
42
+ /**
43
+ * Starts at most one probe per extension runtime; concurrent and later callers
44
+ * share its result. A skipped precondition does not consume the attempt.
45
+ */
46
+ export class ProbeTrigger {
47
+ private readonly pi: ExtensionAPI;
48
+ private readonly probe: SilentProbe;
49
+ private readonly snapshots: SnapshotReader;
50
+ private readonly compaction: CompactionState;
51
+ private readonly snapshotGraceMs: number;
52
+ private attempt: Promise<ProbeResult> | undefined;
53
+
54
+ public constructor(options: ProbeTriggerOptions) {
55
+ this.pi = options.pi;
56
+ this.probe = options.probe;
57
+ this.snapshots = options.snapshots;
58
+ this.compaction = options.compaction;
59
+ this.snapshotGraceMs = options.snapshotGraceMs ?? DEFAULT_SNAPSHOT_GRACE_MS;
60
+ }
61
+
62
+ /**
63
+ * Wait for idle, then resolve once the first `synthetic-probe` snapshot
64
+ * published after the probe started has a settled guard, or with the reason
65
+ * the probe was skipped or failed.
66
+ */
67
+ public async request(context: ExtensionCommandContext): Promise<ProbeResult> {
68
+ await context.waitForIdle();
69
+ const failure = getProbeUnavailableFailure(this.pi, context, this.compaction.isActive);
70
+ if (failure !== undefined) return failure;
71
+ this.attempt ??= this.run(context);
72
+ return this.attempt;
73
+ }
74
+
75
+ /** Start the probe run with the working row hidden and wait for its result. */
76
+ private async run(context: ExtensionCommandContext): Promise<ProbeResult> {
77
+ const attempt = this.probe.start();
78
+ // Subscribe before sending, so no publication of this probe is missed
79
+ const result = waitForProbeResult(this.snapshots, attempt.completion, this.snapshotGraceMs);
80
+ // Only the attempt's starter sends: a repeated empty send would be a real turn
81
+ if (!attempt.started) return result;
82
+
83
+ context.ui.setWorkingVisible(false);
84
+ try {
85
+ // Pi emits `input` and `before_agent_start` from inside this call, so the
86
+ // token reaches both handlers and identifies the run even when another
87
+ // extension's input transform rewrites the prompt text.
88
+ runWithProbeToken(attempt.token, () => this.pi.sendUserMessage(""));
89
+ } catch (error) {
90
+ this.probe.fail("startup", error instanceof Error ? error.message : String(error));
91
+ }
92
+ try {
93
+ return await result;
94
+ } finally {
95
+ context.ui.setWorkingVisible(true);
96
+ }
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Resolve once the probe's snapshot has a settled guard, with the attempt's
102
+ * failure, or with a missing-snapshot failure once `graceMs` passed after the
103
+ * probe settled.
104
+ * Probes run one at a time, so any `synthetic-probe` publication belongs to
105
+ * this attempt.
106
+ */
107
+ function waitForProbeResult(
108
+ snapshots: SnapshotReader,
109
+ completion: Promise<ProbeOutcome>,
110
+ graceMs: number,
111
+ ): Promise<ProbeResult> {
112
+ return new Promise((resolve) => {
113
+ let finished = false;
114
+ let graceTimer: NodeJS.Timeout | undefined;
115
+ const unsubscribe = snapshots.subscribe((snapshot) => {
116
+ if (snapshot.origin === "synthetic-probe" && snapshot.guard.status !== "pending") {
117
+ finish({ status: "captured" });
118
+ }
119
+ });
120
+
121
+ /** Resolve once, then stop listening and cancel the grace timer. */
122
+ function finish(result: ProbeResult): void {
123
+ if (finished) return;
124
+ finished = true;
125
+ unsubscribe();
126
+ if (graceTimer !== undefined) clearTimeout(graceTimer);
127
+ resolve(result);
128
+ }
129
+
130
+ void completion.then((outcome) => {
131
+ if (finished) return;
132
+ if (outcome.status === "failed") {
133
+ finish(outcome);
134
+ return;
135
+ }
136
+ graceTimer = setTimeout(() => finish(probeFailure("missing-snapshot")), graceMs);
137
+ });
138
+ });
139
+ }
140
+
141
+ /** Explain why a silent probe cannot run now, or undefined when it can. */
142
+ function getProbeUnavailableFailure(
143
+ pi: ExtensionAPI,
144
+ context: ExtensionCommandContext,
145
+ compactionInProgress: boolean,
146
+ ): ProbeFailure | undefined {
147
+ if (compactionInProgress) return probeFailure("compaction");
148
+ if (context.hasPendingMessages()) return probeFailure("pending-messages");
149
+ if (context.model === undefined) return probeFailure("missing-model");
150
+ // Pi's virtual-model discriminator is not exported from its package root
151
+ if (context.model.api === "pi-virtual") return probeFailure("virtual-model");
152
+ if (!context.modelRegistry.hasConfiguredAuth(context.model)) {
153
+ return probeFailure("missing-auth", context.model.provider);
154
+ }
155
+ return getProbeSettingsUnavailableFailure(pi, context);
156
+ }
157
+
158
+ /** Check live compaction settings and both sources of the global-only warming preference. */
159
+ function getProbeSettingsUnavailableFailure(
160
+ pi: ExtensionAPI,
161
+ context: ExtensionCommandContext,
162
+ ): ProbeFailure | undefined {
163
+ try {
164
+ const settings = readLiveSettings(pi);
165
+ // The merged snapshot can mask global idle warming with an ignored project override
166
+ if (settings.getCacheWarmingMode() === "idle" || readGlobalCacheWarmingMode(context.cwd) === "idle") {
167
+ return probeFailure("idle-warming");
168
+ }
169
+ const compaction = settings.getCompactionSettings(context.model);
170
+ const usage = context.getContextUsage();
171
+ if (usage?.tokens != null && shouldCompact(usage.tokens, usage.contextWindow, compaction)) {
172
+ return probeFailure("context-limit");
173
+ }
174
+ } catch {
175
+ return probeFailure("settings");
176
+ }
177
+ return undefined;
178
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * ProbeView: all that capture reads from the probe layer. Capture never sees
3
+ * how or when a probe starts.
4
+ */
5
+ import type { ContextEvent } from "@earendil-works/pi-coding-agent";
6
+
7
+ import type { ProbeFilter } from "./filter.ts";
8
+ import type { SilentProbe } from "./silent-probe.ts";
9
+
10
+ /** Run origin and probe-message filter, read by capture. */
11
+ export interface ProbeView {
12
+ /** True while SilentProbe owns the current run. */
13
+ readonly isCurrentRun: boolean;
14
+ /** Remove recorded probe messages; returns the same array when none match. */
15
+ filterMessages(messages: ContextEvent["messages"]): ContextEvent["messages"];
16
+ }
17
+
18
+ /** Combine ProbeFilter and SilentProbe into the one view capture depends on. */
19
+ export function createProbeView(filter: ProbeFilter, probe: SilentProbe): ProbeView {
20
+ return {
21
+ get isCurrentRun() {
22
+ return probe.isCurrentRun;
23
+ },
24
+ filterMessages: (messages) => filter.filterMessages(messages),
25
+ };
26
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Session projections for the views: the filtered projection at a leaf with
3
+ * each message's source entry, and the latest request snapshot, forced prompt
4
+ * included, applied to the current projection for Usage
5
+ * (doc/architecture/views.md). Reads snapshots and Pi data only; imports no
6
+ * capture or probe module.
7
+ */
8
+ import { declarationsEqual, getCurrentSystemMessage, type SystemMessage } from "@earendil-works/pi-ai";
9
+ import { buildSessionProjection, type SessionEntry } from "@earendil-works/pi-coding-agent";
10
+
11
+ import type { ConversationChange, RequestMessage, RequestSnapshot, SystemChange } from "./snapshot.ts";
12
+
13
+ /** A projected message with the session entry that owns it. */
14
+ export interface ProjectedMessage {
15
+ readonly entryId: string;
16
+ readonly message: RequestMessage;
17
+ }
18
+
19
+ /** Removes recorded probe messages, as capture did for its baseline. */
20
+ export type MessageFilter = (messages: RequestMessage[]) => RequestMessage[];
21
+
22
+ /** Inputs for applying a request snapshot to the current projection. */
23
+ export interface SnapshotApplicationInput {
24
+ /** Latest request snapshot, or undefined when none was captured. */
25
+ readonly snapshot: RequestSnapshot | undefined;
26
+ /** Current session entries; they are append-only, so the snapshot's leaf still rebuilds its baseline. */
27
+ readonly entries: SessionEntry[];
28
+ /** Current session leaf. */
29
+ readonly leafId: string | null;
30
+ readonly filterMessages: MessageFilter;
31
+ }
32
+
33
+ /** Current messages with the request's changes applied, and the system changes that still apply. */
34
+ export interface AppliedRequest {
35
+ /** Current branch messages, system messages included, with the request's conversation changes. */
36
+ readonly messages: RequestMessage[];
37
+ /** Current branch messages without the request's changes, as Pi's session holds them. */
38
+ readonly branchMessages: RequestMessage[];
39
+ /** Request-only system changes; empty unless the recorded system state is unchanged since capture. */
40
+ readonly systemChanges: readonly SystemChange[];
41
+ /** The request's forced prompt, under the same freshness rule as `systemChanges`. */
42
+ readonly forcedPrompt?: string;
43
+ }
44
+
45
+ /** The filtered session projection at `leafId`, keeping each message's source entry. */
46
+ export function readProjection(
47
+ entries: SessionEntry[],
48
+ leafId: string | null,
49
+ filterMessages: MessageFilter,
50
+ ): ProjectedMessage[] {
51
+ return buildSessionProjection(entries, leafId).entries.flatMap(({ sourceEntry, messages }) =>
52
+ filterMessages(messages).map((message) => ({ entryId: sourceEntry.id, message })));
53
+ }
54
+
55
+ /**
56
+ * Apply a request snapshot to the current projection. Conversation changes
57
+ * apply by baseline entry: additions are appended, a modification replaces its
58
+ * entry's message, and a deletion removes it. A change whose entry has left the
59
+ * projection is stale and dropped. System changes are deltas against the
60
+ * snapshot's replayed system state, and a forced prompt is a rendering of it,
61
+ * so both apply only while the current replayed state still equals it.
62
+ */
63
+ export function applyRequestSnapshot(input: SnapshotApplicationInput): AppliedRequest {
64
+ const current = readProjection(input.entries, input.leafId, input.filterMessages);
65
+ const branchMessages = current.map(({ message }) => message);
66
+ if (input.snapshot === undefined) return { messages: branchMessages, branchMessages, systemChanges: [] };
67
+ const messages = applyConversationChanges(current, input.snapshot.changes.conversation);
68
+ return { messages, branchMessages, ...freshSystemState(input.snapshot, input.entries, messages) };
69
+ }
70
+
71
+ /** Current messages with modifications and deletions applied in place, then additions appended. */
72
+ function applyConversationChanges(
73
+ current: readonly ProjectedMessage[],
74
+ changes: readonly ConversationChange[],
75
+ ): RequestMessage[] {
76
+ // A deletion maps its target to undefined
77
+ const replaced = new Map<ProjectedMessage, RequestMessage | undefined>();
78
+ const additions: RequestMessage[] = [];
79
+ for (const change of changes) {
80
+ if (change.type === "added") {
81
+ additions.push(change.message);
82
+ continue;
83
+ }
84
+ // An entry rarely holds more than one conversation message; take the first one not yet changed
85
+ const target = current.find((candidate) => candidate.entryId === change.entryId
86
+ && candidate.message.role !== "system" && !replaced.has(candidate));
87
+ // Compaction or branch navigation removed the entry since capture
88
+ if (target === undefined) continue;
89
+ replaced.set(target, change.type === "modified" ? change.message : undefined);
90
+ }
91
+ const applied = current.flatMap((projected) => {
92
+ if (!replaced.has(projected)) return [projected.message];
93
+ const replacement = replaced.get(projected);
94
+ return replacement === undefined ? [] : [replacement];
95
+ });
96
+ return [...applied, ...additions];
97
+ }
98
+
99
+ /** The snapshot's system changes and forced prompt, or neither once the replayed state changed since capture. */
100
+ function freshSystemState(
101
+ snapshot: RequestSnapshot,
102
+ entries: SessionEntry[],
103
+ messages: readonly RequestMessage[],
104
+ ): Omit<AppliedRequest, "messages" | "branchMessages"> {
105
+ const { changes: { system }, forcedPrompt } = snapshot;
106
+ if (system.length === 0 && forcedPrompt === undefined) return { systemChanges: [] };
107
+ const baseline = getCurrentSystemMessage(buildSessionProjection(entries, snapshot.leafId).messages);
108
+ if (!sameSystemState(baseline, getCurrentSystemMessage(messages))) return { systemChanges: [] };
109
+ return { systemChanges: system, forcedPrompt };
110
+ }
111
+
112
+ /** Whether two replayed system states have the same content, sections in order, and declarations. */
113
+ function sameSystemState(a: SystemMessage | undefined, b: SystemMessage | undefined): boolean {
114
+ if (a === undefined || b === undefined) return a === b;
115
+ if (JSON.stringify(a.content) !== JSON.stringify(b.content)) return false;
116
+ if (JSON.stringify(Object.entries(a.sections ?? {})) !== JSON.stringify(Object.entries(b.sections ?? {}))) {
117
+ return false;
118
+ }
119
+ const aTools = a.toolsAdded ?? [];
120
+ const bTools = b.toolsAdded ?? [];
121
+ return aTools.length === bTools.length && aTools.every((tool, index) => declarationsEqual(tool, bTools[index]));
122
+ }
@@ -0,0 +1,226 @@
1
+ /** Pure, deliberately heuristic attribution of the unwrapped text around pi's prompt sections. */
2
+ import type { SlashCommandInfo, SourceInfo, ToolInfo } from "@earendil-works/pi-coding-agent";
3
+
4
+ import { AGGREGATE_SOURCE, extensionSource, type InjectionSource, type TextSpan } from "./model.ts";
5
+
6
+ /** Public tool/command provenance only; no extension files are read to guess an owner. */
7
+ export interface PromptSourceSlice {
8
+ readonly source: string;
9
+ readonly path: string;
10
+ /** Package root only, never a shared directory of loose extension files. */
11
+ readonly baseDir?: string;
12
+ /** Tools and slash commands this source registered; commands carry their `/`. */
13
+ readonly names?: readonly string[];
14
+ }
15
+
16
+ /** Optional observations that improve guesses without claiming handler-level provenance. */
17
+ export interface PromptAdditionOptions {
18
+ readonly sources?: readonly PromptSourceSlice[];
19
+ /** Ordered, non-overlapping ranges counted as System Prompt sections, not additions. */
20
+ readonly excluded?: readonly TextSpan[];
21
+ }
22
+
23
+ /** One contiguous captured run outside prompt sections and whitespace-only gaps. */
24
+ export interface PromptAdditionRun {
25
+ readonly text: string;
26
+ readonly source: InjectionSource;
27
+ /** Tool or command of `source` the run named, when exactly one of them occurs in it. */
28
+ readonly tool?: string;
29
+ readonly attribution?: "guess";
30
+ }
31
+
32
+ /** Characters that may follow a matched path: its own separator, or ordinary prose punctuation. */
33
+ const PATH_BOUNDARY = "[/\\s\"'`<>\\[\\](),;:]";
34
+
35
+ /** Shortest registered name worth guessing; shorter tokens match ordinary prose too readily. */
36
+ const MIN_NAME_LENGTH = 3;
37
+
38
+ /**
39
+ * Split the text after `start` into blank-line blocks, then guess a source only
40
+ * on a unique package-name or full-path match. Unmatched/ambiguous text stays
41
+ * unattributed.
42
+ */
43
+ export function splitPromptAdditions(
44
+ prompt: string,
45
+ start: number,
46
+ options: PromptAdditionOptions,
47
+ ): PromptAdditionRun[] {
48
+ const regions = additionRegions(prompt, start, prompt.length, options.excluded ?? []);
49
+ const runs: Array<{ text: string; source: InjectionSource; tool?: string }> = [];
50
+ for (const region of regions) {
51
+ let previous: { text: string; source: InjectionSource; tool?: string } | undefined;
52
+ for (const text of splitBlocks(region)) {
53
+ const owner = guessOwner(text, options.sources ?? []);
54
+ // Merge only inside one region: text on either side of a section has different authors.
55
+ if (previous?.source.id === owner.source.id && previous.tool === owner.tool) {
56
+ previous.text += text;
57
+ continue;
58
+ }
59
+ previous = { text, ...owner };
60
+ runs.push(previous);
61
+ }
62
+ }
63
+ return runs.map((run) => ({
64
+ ...run,
65
+ attribution: run.source.id === AGGREGATE_SOURCE.id ? undefined : "guess",
66
+ }));
67
+ }
68
+
69
+ /**
70
+ * Collect the provenance of every loaded extension that registered a tool or a
71
+ * command, together with the names it registered. It is the only extension
72
+ * roster pi exposes, and it feeds attribution guesses alone: extensions
73
+ * registering neither are invisible here.
74
+ */
75
+ export function collectPromptSources(
76
+ allTools: readonly ToolInfo[],
77
+ commands: readonly SlashCommandInfo[],
78
+ ): PromptSourceSlice[] {
79
+ const sources = new Map<string, CollectedPromptSource>();
80
+ for (const tool of allTools) addPromptSource(sources, tool.sourceInfo, tool.name);
81
+ // Prompt text refers to a command the way a user types it, so keep its slash.
82
+ for (const command of commands) {
83
+ addPromptSource(sources, command.sourceInfo, command.name.startsWith("/") ? command.name : `/${command.name}`);
84
+ }
85
+ return [...sources.values()];
86
+ }
87
+
88
+ /** Keep gaps separate: removing a section must not join evidence from different additions. */
89
+ function additionRegions(prompt: string, start: number, end: number, excluded: readonly TextSpan[]): string[] {
90
+ const regions: string[] = [];
91
+ let cursor = start;
92
+ for (const span of excluded) {
93
+ if (span.end <= cursor || span.start >= end) continue;
94
+ if (span.start > cursor) regions.push(prompt.slice(cursor, span.start));
95
+ cursor = Math.min(end, span.end);
96
+ }
97
+ if (cursor < end) regions.push(prompt.slice(cursor, end));
98
+ return regions;
99
+ }
100
+
101
+ /** Keep separators with the following block; trailing whitespace stays with the final block. */
102
+ function splitBlocks(text: string): string[] {
103
+ const blocks: string[] = [];
104
+ let start = 0;
105
+ for (const separator of text.matchAll(/\r?\n[\t ]*\r?\n(?:[\t ]*\r?\n)*/g)) {
106
+ if (text.slice(start, separator.index).trim().length === 0) continue;
107
+ if (text.slice(separator.index).trim().length === 0) break;
108
+ blocks.push(text.slice(start, separator.index));
109
+ start = separator.index;
110
+ }
111
+ // Removing a section can leave a separator behind; blank text has no author.
112
+ if (text.slice(start).trim().length > 0) blocks.push(text.slice(start));
113
+ return blocks;
114
+ }
115
+
116
+ /** Extension a block was guessed to belong to, and the tool or command it named. */
117
+ interface PromptAdditionOwner {
118
+ readonly source: InjectionSource;
119
+ readonly tool?: string;
120
+ }
121
+
122
+ /**
123
+ * Several tools/commands from one package are one candidate, not an ambiguous
124
+ * match; two path extensions are two candidates.
125
+ */
126
+ function guessOwner(text: string, sources: readonly PromptSourceSlice[]): PromptAdditionOwner {
127
+ const normalized = text.replaceAll("\\", "/");
128
+ const matches = new Map<string, InjectionSource>();
129
+ for (const source of sources) {
130
+ if (source.source === "builtin" || source.source === "sdk") continue;
131
+ const packageName = source.source.match(/^npm:((?:@[^/]+\/)?[^@]+)(?:@.*)?$/)?.[1];
132
+ if (
133
+ containsPath(normalized, source.path) || containsPath(normalized, source.baseDir) ||
134
+ (packageName !== undefined && containsPackage(normalized, packageName)) ||
135
+ (/^(npm:|git:|https?:\/\/|ssh:\/\/)/.test(source.source) && containsPackage(normalized, source.source))
136
+ ) {
137
+ const owner = extensionSource(source, sources);
138
+ matches.set(owner.id, owner);
139
+ }
140
+ }
141
+ const [match] = matches.values();
142
+ if (matches.size !== 1 || match === undefined) return { source: AGGREGATE_SOURCE };
143
+ return { source: match, tool: guessTool(normalized, sources, match.id) };
144
+ }
145
+
146
+ /**
147
+ * Registered tool or command of the owning extension the block names, when
148
+ * exactly one of them occurs in it. Two mentions name no tool, as two packages
149
+ * name no extension; the result only qualifies a guessed label, so it never
150
+ * changes which item counts the text.
151
+ */
152
+ function guessTool(
153
+ text: string,
154
+ sources: readonly PromptSourceSlice[],
155
+ ownerId: string,
156
+ ): string | undefined {
157
+ const matches = new Set<string>();
158
+ for (const source of sources) {
159
+ if (extensionSource(source).id !== ownerId) continue;
160
+ for (const name of source.names ?? []) {
161
+ if (name.length >= MIN_NAME_LENGTH && containsName(text, name)) matches.add(name);
162
+ }
163
+ }
164
+ const [name] = matches;
165
+ return matches.size === 1 ? name : undefined;
166
+ }
167
+
168
+ /**
169
+ * Require a complete name token, so a longer identifier and a path segment are
170
+ * no mention: `web_search` matches in prose but not in `tools/web_search.md`,
171
+ * and the command `/ask` matches only where it is written with its slash.
172
+ */
173
+ function containsName(text: string, name: string): boolean {
174
+ return new RegExp(`(?<![\\w/-])${escapePattern(name)}(?![\\w-])`).test(text);
175
+ }
176
+
177
+ /**
178
+ * Require a complete package token: a longer package name must not match, while
179
+ * ordinary sentence punctuation after the name must, so `pi-web-providers` and
180
+ * `pi-web.js` are rejected where `npm:pi-web.` is accepted.
181
+ */
182
+ function containsPackage(text: string, name: string): boolean {
183
+ const escaped = escapePattern(name);
184
+ return new RegExp(`(?:^|[^\\w@/.-]|/node_modules/)${escaped}(?![\\w-])(?!\\.[\\w-])`).test(text);
185
+ }
186
+
187
+ /** Match absolute paths at boundaries; a package root may be followed by a child path. */
188
+ function containsPath(text: string, path: string | undefined): boolean {
189
+ if (path === undefined) return false;
190
+ const normalized = path.replaceAll("\\", "/").replace(/\/+$/, "");
191
+ // A relative or synthetic provenance such as `<builtin:read>` is no evidence of authorship.
192
+ if (!/^(\/|[A-Za-z]:\/).+/.test(normalized)) return false;
193
+ return new RegExp(`(?:^|[^\\w/.-])${escapePattern(normalized)}(?=$|${PATH_BOUNDARY})`).test(text);
194
+ }
195
+
196
+ /** Escape literal provenance before using it in a boundary-sensitive pattern. */
197
+ function escapePattern(text: string): string {
198
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
199
+ }
200
+
201
+ /** Slice under construction: its names arrive one tool or command at a time. */
202
+ interface CollectedPromptSource extends Omit<PromptSourceSlice, "names"> {
203
+ readonly names: string[];
204
+ }
205
+
206
+ /** Record one registered name under its extension's provenance, skipping pi's own sources. */
207
+ function addPromptSource(
208
+ sources: Map<string, CollectedPromptSource>,
209
+ sourceInfo: SourceInfo,
210
+ name: string,
211
+ ): void {
212
+ if (sourceInfo.source === "builtin" || sourceInfo.source === "sdk") return;
213
+ const key = `${sourceInfo.source}\n${sourceInfo.path}`;
214
+ let collected = sources.get(key);
215
+ if (collected === undefined) {
216
+ collected = {
217
+ source: sourceInfo.source,
218
+ path: sourceInfo.path,
219
+ // A top-level extension's baseDir is a shared directory, not its own root.
220
+ baseDir: sourceInfo.origin === "package" ? sourceInfo.baseDir : undefined,
221
+ names: [],
222
+ };
223
+ sources.set(key, collected);
224
+ }
225
+ if (!collected.names.includes(name)) collected.names.push(name);
226
+ }
@@ -0,0 +1,80 @@
1
+ /** Locate pi's XML prompt sections without assuming extensions preserve their order. */
2
+ import type { TextSpan } from "./model.ts";
3
+
4
+ /** Block pi renders one bullet per visible tool into, in its `tools` section. */
5
+ export const AVAILABLE_TOOLS_BLOCK = { id: "base-prompt:available-tools", label: "Tools" };
6
+ /** Block pi renders tool guideline bullets into, in its `rules` section. */
7
+ export const GUIDELINES_BLOCK = { id: "base-prompt:guidelines", label: "Rules" };
8
+ /** Block pi renders its own documentation into, in its `docs` section. */
9
+ export const DOCUMENTATION_BLOCK = { id: "base-prompt:documentation", label: "Docs" };
10
+
11
+ /** Pi's normal emission order, also the blocks a custom prompt replaces. */
12
+ export const BASE_PROMPT_BLOCKS = [AVAILABLE_TOOLS_BLOCK, GUIDELINES_BLOCK, DOCUMENTATION_BLOCK];
13
+
14
+ /** One complete top-level XML section, with transport framing separated from its body. */
15
+ export interface PromptSection extends TextSpan {
16
+ readonly name: string;
17
+ /** Includes the newline before the body, but not the newline before the closing tag. */
18
+ readonly body: TextSpan;
19
+ }
20
+
21
+ /**
22
+ * Locate pi's sections in rendered order. Skip whole sections and fenced examples
23
+ * so tags in instruction files, skills, addenda, and custom sections are not blocks.
24
+ */
25
+ export function findPromptSections(prompt: string): PromptSection[] {
26
+ const sections: PromptSection[] = [];
27
+ let fence: string | undefined;
28
+ const pattern = /^<([a-z][a-z0-9_-]*)>\n|^ {0,3}(`{3,}|~{3,})([^\n]*)/gm;
29
+ for (let match = pattern.exec(prompt); match !== null; match = pattern.exec(prompt)) {
30
+ const marker = match[2];
31
+ if (marker !== undefined) {
32
+ if (fence === undefined) fence = marker;
33
+ else if (marker[0] === fence[0] && marker.length >= fence.length && match[3].trim() === "") fence = undefined;
34
+ continue;
35
+ }
36
+ if (fence !== undefined) continue;
37
+ const name = match[1];
38
+ const closing = `\n</${name}>`;
39
+ const end = prompt.indexOf(closing, pattern.lastIndex);
40
+ if (end === -1) continue;
41
+ const after = end + closing.length;
42
+ if (after < prompt.length && prompt[after] !== "\n") continue;
43
+ sections.push({ name, start: match.index, end: after, body: { start: pattern.lastIndex - 1, end } });
44
+ pattern.lastIndex = after;
45
+ }
46
+ return sections;
47
+ }
48
+
49
+ /** Locate native XML tool surfaces with bounded, consecutive bullet regions and positional markers. */
50
+ export function findSectionToolBlocks(prompt: string, sections: readonly PromptSection[]): LocatedPromptBlock[] {
51
+ const order = ["tools", "rules", "docs", "addendum", "project_context", "skills", "cwd"];
52
+ return sections.flatMap((section): LocatedPromptBlock[] => {
53
+ if (section.name !== "tools" && section.name !== "rules") return [];
54
+ const block = section.name === "tools" ? AVAILABLE_TOOLS_BLOCK : GUIDELINES_BLOCK;
55
+ const moved = sections.some((other) => other.start < section.start &&
56
+ order.indexOf(other.name) > order.indexOf(section.name));
57
+ const bullets = findBulletSpan(prompt, section.body.start);
58
+ return [{ ...block, ...section.body, bullets, moved: moved || undefined }];
59
+ });
60
+ }
61
+
62
+ /** One native tool-surface section body and its bullet region, in original prompt coordinates. */
63
+ export interface LocatedPromptBlock extends TextSpan {
64
+ readonly id: string;
65
+ readonly label: string;
66
+ /** Exact bullet region, including the newline before the first bullet. */
67
+ readonly bullets?: TextSpan;
68
+ /** Position differs from pi's normal block order; no claim about which handler moved it. */
69
+ readonly moved?: boolean;
70
+ }
71
+
72
+ /** Consecutive complete bullet lines; stop before any unrelated prose. */
73
+ function findBulletSpan(prompt: string, start: number): TextSpan | undefined {
74
+ let end = start;
75
+ while (prompt.startsWith("\n- ", end)) {
76
+ const newline = prompt.indexOf("\n", end + 1);
77
+ end = newline === -1 ? prompt.length : newline;
78
+ }
79
+ return end === start ? undefined : { start, end };
80
+ }