@tt-a1i/openpi 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +643 -0
- package/SETUP.md +74 -0
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/openpi-package.png +0 -0
- package/assets/readme-hero-mobile.svg +72 -0
- package/assets/readme-hero.svg +118 -0
- package/assets/readme-runtime-mobile.svg +91 -0
- package/assets/readme-runtime.svg +111 -0
- package/extensions/ask-user/handoff.ts +205 -0
- package/extensions/ask-user/index.ts +1110 -0
- package/extensions/ask-user/limits.ts +89 -0
- package/extensions/ask-user/prompt.ts +76 -0
- package/extensions/background-terminals/index.ts +653 -0
- package/extensions/background-terminals/src/domain.ts +99 -0
- package/extensions/background-terminals/src/manager.ts +989 -0
- package/extensions/background-terminals/src/output.ts +84 -0
- package/extensions/background-terminals/src/prompt.ts +195 -0
- package/extensions/background-terminals/src/result-delivery.ts +43 -0
- package/extensions/background-terminals/src/runtime.ts +36 -0
- package/extensions/background-terminals/src/ui/output-view.ts +55 -0
- package/extensions/background-terminals/src/ui/ps.ts +642 -0
- package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
- package/extensions/background-terminals/src/watch.ts +192 -0
- package/extensions/context-pivot/index.ts +222 -0
- package/extensions/copy-all/index.ts +65 -0
- package/extensions/cron/index.ts +173 -0
- package/extensions/cron/schedule.ts +127 -0
- package/extensions/file-mutation-display/index.ts +105 -0
- package/extensions/file-mutation-display/render.ts +107 -0
- package/extensions/file-search/index.ts +515 -0
- package/extensions/file-search/src/args.ts +129 -0
- package/extensions/file-search/src/binaries.ts +419 -0
- package/extensions/file-search/src/output.ts +142 -0
- package/extensions/file-search/src/process.ts +309 -0
- package/extensions/file-search/src/prompt.ts +53 -0
- package/extensions/git-info/index.ts +272 -0
- package/extensions/git-info/src/changed-files-view.ts +414 -0
- package/extensions/git-info/src/process.ts +107 -0
- package/extensions/git-info/src/refresh-coordinator.ts +13 -0
- package/extensions/git-info/src/runtime.ts +28 -0
- package/extensions/goal/controller.ts +794 -0
- package/extensions/goal/index.ts +521 -0
- package/extensions/goal/prompts.ts +122 -0
- package/extensions/goal/state.ts +763 -0
- package/extensions/goal/ui.ts +158 -0
- package/extensions/model-info/index.ts +234 -0
- package/extensions/plan-mode/bash-policy.ts +313 -0
- package/extensions/plan-mode/index.ts +539 -0
- package/extensions/post-edit/index.ts +129 -0
- package/extensions/sessions/LICENSE.upstream +21 -0
- package/extensions/sessions/git-stats.ts +226 -0
- package/extensions/sessions/index.ts +1092 -0
- package/extensions/sessions/sessions.ts +385 -0
- package/extensions/setup/index.ts +408 -0
- package/extensions/shared/activity-status.ts +65 -0
- package/extensions/shared/below-editor-navigation.ts +343 -0
- package/extensions/shared/child-session.ts +352 -0
- package/extensions/shared/context-utilization.ts +47 -0
- package/extensions/shared/dashboard-state.ts +102 -0
- package/extensions/shared/plan-mode-state.ts +65 -0
- package/extensions/shared/setup-config.ts +971 -0
- package/extensions/shared/subagent-roles.ts +22 -0
- package/extensions/shared/terminal-text.ts +38 -0
- package/extensions/shared/tool-call-timeout.ts +104 -0
- package/extensions/shared/worktree.ts +526 -0
- package/extensions/subagents/index.ts +1225 -0
- package/extensions/subagents/navigation.ts +121 -0
- package/extensions/subagents/src/agent-types.ts +543 -0
- package/extensions/subagents/src/backend.ts +63 -0
- package/extensions/subagents/src/backends/pi.ts +493 -0
- package/extensions/subagents/src/backends/stub.ts +296 -0
- package/extensions/subagents/src/by-the-way.ts +21 -0
- package/extensions/subagents/src/domain.ts +271 -0
- package/extensions/subagents/src/format.ts +48 -0
- package/extensions/subagents/src/manager.ts +769 -0
- package/extensions/subagents/src/prompt.ts +190 -0
- package/extensions/subagents/src/result-delivery.ts +20 -0
- package/extensions/subagents/src/runtime.ts +51 -0
- package/extensions/subagents/src/ui/takeover.ts +615 -0
- package/extensions/subagents/src/ui/transcript.ts +293 -0
- package/extensions/subagents/src/ui/wait-result.ts +89 -0
- package/extensions/suggestions/index.ts +172 -0
- package/extensions/suggestions/src/config.ts +12 -0
- package/extensions/suggestions/src/predictor.ts +147 -0
- package/extensions/suggestions/src/prompt.ts +20 -0
- package/extensions/suggestions/src/transcript.ts +233 -0
- package/extensions/suggestions/src/ui.ts +224 -0
- package/extensions/tasks/index.ts +512 -0
- package/extensions/tasks/tasks.ts +649 -0
- package/extensions/tasks/ui.ts +421 -0
- package/extensions/turn-time/index.ts +61 -0
- package/extensions/ui-customization/footer.ts +512 -0
- package/extensions/ui-customization/index.ts +217 -0
- package/extensions/workflows/acceptance.ts +298 -0
- package/extensions/workflows/artifacts.ts +225 -0
- package/extensions/workflows/controller.ts +210 -0
- package/extensions/workflows/dashboard.ts +1226 -0
- package/extensions/workflows/index.ts +1884 -0
- package/extensions/workflows/journal.ts +188 -0
- package/extensions/workflows/meta.ts +250 -0
- package/extensions/workflows/model.ts +423 -0
- package/extensions/workflows/navigation.ts +93 -0
- package/extensions/workflows/prompt.ts +212 -0
- package/extensions/workflows/replay-safety.ts +577 -0
- package/extensions/workflows/runner.ts +786 -0
- package/extensions/workflows/sandbox-child.cjs +402 -0
- package/extensions/workflows/sandbox.ts +397 -0
- package/extensions/workflows/serialization.ts +162 -0
- package/extensions/workflows/worktree-handoff.ts +216 -0
- package/package.json +87 -0
- package/scripts/prepare-effect-tsgo.mjs +16 -0
- package/skills/background-terminals/SKILL.md +30 -0
- package/skills/subagents/SKILL.md +15 -0
- package/themes/github-dark-default.json +89 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text, truncateToWidth, type Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import { sanitizeText } from "./output-view.ts";
|
|
4
|
+
|
|
5
|
+
const STREAM_PREVIEW_LINES = 2;
|
|
6
|
+
|
|
7
|
+
interface ParsedTerminalResult {
|
|
8
|
+
summary: string;
|
|
9
|
+
metadata: string[];
|
|
10
|
+
streams: Array<{
|
|
11
|
+
name: "stdout" | "stderr";
|
|
12
|
+
lines: string[];
|
|
13
|
+
truncated: boolean;
|
|
14
|
+
}>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CompactTerminalPreview {
|
|
18
|
+
summary: string;
|
|
19
|
+
metadata: string[];
|
|
20
|
+
streams: Array<{
|
|
21
|
+
name: "stdout" | "stderr";
|
|
22
|
+
lines: string[];
|
|
23
|
+
}>;
|
|
24
|
+
hiddenLines: number;
|
|
25
|
+
unknownEarlierOutput: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseTerminalResult(content: string): ParsedTerminalResult {
|
|
29
|
+
const lines = sanitizeText(content).split("\n");
|
|
30
|
+
const summary = lines.shift()?.trim() || "Background terminal result";
|
|
31
|
+
const metadata: string[] = [];
|
|
32
|
+
const streams: ParsedTerminalResult["streams"] = [];
|
|
33
|
+
let current: ParsedTerminalResult["streams"][number] | undefined;
|
|
34
|
+
|
|
35
|
+
for (const rawLine of lines) {
|
|
36
|
+
const line = rawLine.replace(/\s+$/, "");
|
|
37
|
+
const streamHeader = /^(stdout|stderr):(.*)$/.exec(line);
|
|
38
|
+
if (streamHeader) {
|
|
39
|
+
current = {
|
|
40
|
+
name: streamHeader[1] === "stdout" ? "stdout" : "stderr",
|
|
41
|
+
lines: [],
|
|
42
|
+
truncated: false,
|
|
43
|
+
};
|
|
44
|
+
streams.push(current);
|
|
45
|
+
const inline = streamHeader[2]?.trim();
|
|
46
|
+
if (inline && inline !== "(empty)") current.lines.push(inline);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!current) {
|
|
50
|
+
if (line.trim()) metadata.push(line.trim());
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (/^\[(stdout|stderr) truncated:/.test(line)) {
|
|
54
|
+
current.truncated = true;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!line || line === "(empty)") continue;
|
|
58
|
+
current.lines.push(line);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const stream of streams) {
|
|
62
|
+
while (stream.lines.at(-1) === "") stream.lines.pop();
|
|
63
|
+
}
|
|
64
|
+
return { summary, metadata, streams };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Build a bounded, tail-first preview without changing model-facing output. */
|
|
68
|
+
export function buildCompactTerminalPreview(
|
|
69
|
+
content: string,
|
|
70
|
+
maximumPerStream = STREAM_PREVIEW_LINES,
|
|
71
|
+
): CompactTerminalPreview {
|
|
72
|
+
const parsed = parseTerminalResult(content);
|
|
73
|
+
let hiddenLines = 0;
|
|
74
|
+
let unknownEarlierOutput = false;
|
|
75
|
+
const streams = parsed.streams
|
|
76
|
+
.filter((stream) => stream.lines.length > 0)
|
|
77
|
+
.map((stream) => {
|
|
78
|
+
const maximum = Math.max(0, maximumPerStream);
|
|
79
|
+
hiddenLines += Math.max(0, stream.lines.length - maximum);
|
|
80
|
+
unknownEarlierOutput ||= stream.truncated;
|
|
81
|
+
return {
|
|
82
|
+
name: stream.name,
|
|
83
|
+
lines: maximum === 0 ? [] : stream.lines.slice(-maximum),
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
summary: parsed.summary,
|
|
89
|
+
metadata: parsed.metadata,
|
|
90
|
+
streams,
|
|
91
|
+
hiddenLines,
|
|
92
|
+
unknownEarlierOutput,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function fixedRows(rows: string[]): Component {
|
|
97
|
+
return {
|
|
98
|
+
render(width) {
|
|
99
|
+
return rows.map((row) => truncateToWidth(row, width, "…"));
|
|
100
|
+
},
|
|
101
|
+
invalidate() {},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Render terminal results compactly by default; expanded mode preserves all text. */
|
|
106
|
+
export function renderTerminalResult(
|
|
107
|
+
content: string,
|
|
108
|
+
expanded: boolean,
|
|
109
|
+
theme: Theme,
|
|
110
|
+
summaryOverride?: string,
|
|
111
|
+
): Component {
|
|
112
|
+
const sanitized = sanitizeText(content);
|
|
113
|
+
if (expanded) {
|
|
114
|
+
if (!summaryOverride) return new Text(sanitized, 0, 0);
|
|
115
|
+
return new Text(
|
|
116
|
+
[summaryOverride, ...sanitized.split("\n").slice(1)].join("\n"),
|
|
117
|
+
0,
|
|
118
|
+
0,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const preview = buildCompactTerminalPreview(sanitized);
|
|
123
|
+
const rows = [
|
|
124
|
+
summaryOverride ?? theme.fg("muted", preview.summary),
|
|
125
|
+
...preview.metadata.map((line) => theme.fg("error", line)),
|
|
126
|
+
];
|
|
127
|
+
for (const stream of preview.streams) {
|
|
128
|
+
for (const line of stream.lines) {
|
|
129
|
+
rows.push(
|
|
130
|
+
theme.fg("dim", `${stream.name} · `) + theme.fg("toolOutput", line),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (preview.hiddenLines > 0 || preview.unknownEarlierOutput) {
|
|
135
|
+
const amount = preview.unknownEarlierOutput
|
|
136
|
+
? "earlier log output"
|
|
137
|
+
: `${preview.hiddenLines} earlier line${preview.hiddenLines === 1 ? "" : "s"}`;
|
|
138
|
+
rows.push(
|
|
139
|
+
theme.fg(
|
|
140
|
+
"dim",
|
|
141
|
+
`… ${amount} hidden · ${keyHint("app.tools.expand", "to expand")}`,
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return fixedRows(rows);
|
|
146
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure matcher for `bg_watch`: scans a background terminal's streamed output
|
|
3
|
+
* for a pattern, keeping a bounded carry so a match that straddles two chunks
|
|
4
|
+
* is still found. Kept free of process/IO concerns so it can be tested
|
|
5
|
+
* directly — the streaming path around it is the part that is hard to test.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
hasTerminalControls,
|
|
10
|
+
sanitizeTerminalText,
|
|
11
|
+
} from "../../shared/terminal-text.ts";
|
|
12
|
+
|
|
13
|
+
/** Bound on retained tail. A match spanning more than this is not detected. */
|
|
14
|
+
export const WATCH_CARRY_MAX_BYTES = 4 * 1024;
|
|
15
|
+
|
|
16
|
+
export interface WatchMatch {
|
|
17
|
+
/** The line (or bounded slice) containing the match. */
|
|
18
|
+
line: string;
|
|
19
|
+
/** Which stream produced it. */
|
|
20
|
+
stream: "stdout" | "stderr";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ChunkMatcher {
|
|
24
|
+
/** Feed a chunk; returns the first match in it, or undefined. */
|
|
25
|
+
push(chunk: string, stream: "stdout" | "stderr"): WatchMatch | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface CapturedOutput {
|
|
29
|
+
text: string;
|
|
30
|
+
truncatedBytes: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Refuse a watch when prior eviction made terminal parse state unknowable. */
|
|
34
|
+
export function assertWatchableOutput(
|
|
35
|
+
stdout: CapturedOutput,
|
|
36
|
+
stderr: CapturedOutput,
|
|
37
|
+
) {
|
|
38
|
+
if (stdout.truncatedBytes === 0 && stderr.truncatedBytes === 0) return;
|
|
39
|
+
throw new Error(
|
|
40
|
+
"Cannot safely arm a watch after older terminal output was dropped; inspect /ps or start a fresh terminal before watching for a signature.",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Check retained output only when its control-string parse state is intact. */
|
|
45
|
+
export function matchCapturedOutput(
|
|
46
|
+
matcher: ChunkMatcher,
|
|
47
|
+
stdout: CapturedOutput,
|
|
48
|
+
stderr: CapturedOutput,
|
|
49
|
+
) {
|
|
50
|
+
// A head-truncated buffer can begin inside an OSC/DCS payload after its
|
|
51
|
+
// opener was evicted. There is no safe way to infer that missing state, so
|
|
52
|
+
// skip historical matching for that stream and watch future chunks instead.
|
|
53
|
+
const stdoutHit =
|
|
54
|
+
stdout.truncatedBytes === 0
|
|
55
|
+
? matcher.push(stdout.text, "stdout")
|
|
56
|
+
: undefined;
|
|
57
|
+
return (
|
|
58
|
+
stdoutHit ??
|
|
59
|
+
(stderr.truncatedBytes === 0
|
|
60
|
+
? matcher.push(stderr.text, "stderr")
|
|
61
|
+
: undefined)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const CONTROL_STRING_STARTS = [
|
|
66
|
+
"\u001b]",
|
|
67
|
+
"\u001bP",
|
|
68
|
+
"\u001bX",
|
|
69
|
+
"\u001b^",
|
|
70
|
+
"\u001b_",
|
|
71
|
+
"\u0090",
|
|
72
|
+
"\u0098",
|
|
73
|
+
"\u009d",
|
|
74
|
+
"\u009e",
|
|
75
|
+
"\u009f",
|
|
76
|
+
];
|
|
77
|
+
const OSC_END = /\u0007|\u001b\\|\u009c/;
|
|
78
|
+
const ST_STRING_END = /\u001b\\|\u009c/;
|
|
79
|
+
|
|
80
|
+
/** Keep an opener when a long split control string crosses the carry bound. */
|
|
81
|
+
function boundedRawCarry(text: string) {
|
|
82
|
+
if (text.length <= WATCH_CARRY_MAX_BYTES) return text;
|
|
83
|
+
let lastStart = -1;
|
|
84
|
+
let opener = "";
|
|
85
|
+
for (const candidate of CONTROL_STRING_STARTS) {
|
|
86
|
+
const index = text.lastIndexOf(candidate);
|
|
87
|
+
if (index > lastStart) {
|
|
88
|
+
lastStart = index;
|
|
89
|
+
opener = candidate;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const terminator =
|
|
93
|
+
opener === "\u001b]" || opener === "\u009d" ? OSC_END : ST_STRING_END;
|
|
94
|
+
const open =
|
|
95
|
+
lastStart >= 0 && !terminator.test(text.slice(lastStart + opener.length));
|
|
96
|
+
return `${open ? opener : ""}${text.slice(-WATCH_CARRY_MAX_BYTES)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build a one-shot matcher from the safe RegExp produced by
|
|
101
|
+
* `compileWatchPattern`. It has no stateful flags, so each test is independent.
|
|
102
|
+
* Returns undefined-forever after the first match: callers
|
|
103
|
+
* disarm the watch, and this makes double-fire impossible even if they don't.
|
|
104
|
+
*/
|
|
105
|
+
export function createChunkMatcher(pattern: RegExp): ChunkMatcher {
|
|
106
|
+
// One carry PER STREAM. A shared buffer would splice interleaved stdout and
|
|
107
|
+
// stderr together, both missing real straddling matches and fabricating
|
|
108
|
+
// matches from text that never appeared on either stream.
|
|
109
|
+
const carries: Record<"stdout" | "stderr", string> = {
|
|
110
|
+
stdout: "",
|
|
111
|
+
stderr: "",
|
|
112
|
+
};
|
|
113
|
+
let done = false;
|
|
114
|
+
return {
|
|
115
|
+
push(chunk, stream) {
|
|
116
|
+
if (done || !chunk) return undefined;
|
|
117
|
+
const rawText = carries[stream] + chunk;
|
|
118
|
+
// Match only text that could actually be shown to the user. A readiness
|
|
119
|
+
// word hidden inside an OSC clipboard payload must not wake the agent.
|
|
120
|
+
const text = sanitizeTerminalText(rawText);
|
|
121
|
+
const match = pattern.exec(text);
|
|
122
|
+
if (!match) {
|
|
123
|
+
// Retain raw text so a control string split across chunks can still be
|
|
124
|
+
// removed as one unit before matching. Preserve its opener if its
|
|
125
|
+
// payload itself is longer than the carry bound.
|
|
126
|
+
carries[stream] = boundedRawCarry(rawText);
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
done = true;
|
|
130
|
+
carries.stdout = "";
|
|
131
|
+
carries.stderr = "";
|
|
132
|
+
// Report the containing line, not the whole buffer: the model wants the
|
|
133
|
+
// log line that matched, not every byte since the watch started. Bound
|
|
134
|
+
// it too — a process with no newlines (progress bars) would otherwise
|
|
135
|
+
// smear kilobytes into the transcript.
|
|
136
|
+
const start = text.lastIndexOf("\n", match.index) + 1;
|
|
137
|
+
const endIndex = text.indexOf("\n", match.index);
|
|
138
|
+
const end = endIndex === -1 ? text.length : endIndex;
|
|
139
|
+
return {
|
|
140
|
+
line: sanitizeLine(text.slice(start, end)),
|
|
141
|
+
stream,
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Cap on the reported match line, so a newline-free stream cannot smear. */
|
|
148
|
+
export const WATCH_LINE_MAX_CHARS = 500;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Strip ANSI/control bytes and bound the length. The matched line is injected
|
|
152
|
+
* into the transcript, and the sibling terminal-result path already sanitizes;
|
|
153
|
+
* passing raw escape sequences through would desync the renderer.
|
|
154
|
+
*/
|
|
155
|
+
function sanitizeLine(raw: string) {
|
|
156
|
+
const stripped = sanitizeTerminalText(raw).trim();
|
|
157
|
+
return stripped.length > WATCH_LINE_MAX_CHARS
|
|
158
|
+
? `${stripped.slice(0, WATCH_LINE_MAX_CHARS)}\u2026`
|
|
159
|
+
: stripped;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Cap on a signature list; watches are match hints, not a regex engine. */
|
|
163
|
+
export const WATCH_PATTERN_MAX_CHARS = 200;
|
|
164
|
+
|
|
165
|
+
function escapeRegExp(text: string) {
|
|
166
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Compile pipe-separated literal signatures. JavaScript regex executes
|
|
171
|
+
* synchronously on the process-output hot path and has no timeout, so no
|
|
172
|
+
* blacklist can make arbitrary regex safe from catastrophic backtracking.
|
|
173
|
+
* Literal alternatives preserve the useful `Ready|ERROR|FAILED` contract and
|
|
174
|
+
* make matching time linear in the bounded input.
|
|
175
|
+
*/
|
|
176
|
+
export function compileWatchPattern(pattern: string): RegExp {
|
|
177
|
+
if (pattern.length > WATCH_PATTERN_MAX_CHARS) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Watch pattern is too long (max ${WATCH_PATTERN_MAX_CHARS} chars); use short signatures like "Ready in|Traceback|ERROR".`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (hasTerminalControls(pattern)) {
|
|
183
|
+
throw new Error("Watch signatures cannot contain control characters.");
|
|
184
|
+
}
|
|
185
|
+
const alternatives = pattern.split("|");
|
|
186
|
+
if (alternatives.some((alternative) => alternative.length === 0)) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
'Watch signatures cannot be empty; use literals like "Ready in|Traceback|ERROR".',
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return new RegExp(alternatives.map(escapeRegExp).join("|"));
|
|
192
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
SessionEntry,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Type } from "typebox";
|
|
7
|
+
|
|
8
|
+
export const MIN_CONTEXT_PIVOT_TOKENS = 30_000;
|
|
9
|
+
const STATUS_KEY = "context-pivot";
|
|
10
|
+
|
|
11
|
+
interface PendingPivot {
|
|
12
|
+
brief: string;
|
|
13
|
+
generation: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ContextUsage {
|
|
17
|
+
tokens?: number | null;
|
|
18
|
+
percent?: number | null;
|
|
19
|
+
contextWindow?: number | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function estimateContextTokens(
|
|
23
|
+
usage: ContextUsage | null | undefined,
|
|
24
|
+
): number | null {
|
|
25
|
+
if (!usage) return null;
|
|
26
|
+
if (typeof usage.tokens === "number") {
|
|
27
|
+
return Number.isFinite(usage.tokens) && usage.tokens >= 0
|
|
28
|
+
? usage.tokens
|
|
29
|
+
: null;
|
|
30
|
+
}
|
|
31
|
+
if (
|
|
32
|
+
typeof usage.percent !== "number" ||
|
|
33
|
+
!Number.isFinite(usage.percent) ||
|
|
34
|
+
usage.percent < 0 ||
|
|
35
|
+
typeof usage.contextWindow !== "number" ||
|
|
36
|
+
!Number.isFinite(usage.contextWindow) ||
|
|
37
|
+
usage.contextWindow <= 0
|
|
38
|
+
) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return (usage.contextWindow * usage.percent) / 100;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildPivotSummary(brief: string): string {
|
|
45
|
+
return [
|
|
46
|
+
"## Context Pivot — Continue in a Clean Context",
|
|
47
|
+
"",
|
|
48
|
+
"The previous phase was deliberately compressed. Continue from this brief instead of reconstructing discarded mechanics.",
|
|
49
|
+
"",
|
|
50
|
+
"## Next Phase",
|
|
51
|
+
"",
|
|
52
|
+
brief.trim(),
|
|
53
|
+
].join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function impossibleKeptId(entries: readonly SessionEntry[]) {
|
|
57
|
+
return `${entries.at(-1)?.id ?? "context-pivot"}-context-pivot-cut`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function validateBrief(brief: string, ctx: ExtensionContext) {
|
|
61
|
+
if (!brief.trim())
|
|
62
|
+
throw new Error("context_pivot requires a non-empty brief.");
|
|
63
|
+
|
|
64
|
+
const tokens = estimateContextTokens(ctx.getContextUsage());
|
|
65
|
+
if (tokens === null) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
"Context usage is unavailable; context pivot was not started.",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (tokens < MIN_CONTEXT_PIVOT_TOKENS) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Context is only ${Math.round(tokens).toLocaleString()} tokens; use context_pivot once context reaches at least ${MIN_CONTEXT_PIVOT_TOKENS.toLocaleString()} tokens, or /handoff for a genuinely new session.`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default function contextPivot(pi: ExtensionAPI) {
|
|
78
|
+
let generation = 0;
|
|
79
|
+
let pending: PendingPivot | undefined;
|
|
80
|
+
let compacting = false;
|
|
81
|
+
|
|
82
|
+
pi.on("session_before_compact", (event) => {
|
|
83
|
+
const pivot = pending;
|
|
84
|
+
if (!pivot || pivot.generation !== generation) return;
|
|
85
|
+
|
|
86
|
+
pending = undefined;
|
|
87
|
+
const summary = buildPivotSummary(pivot.brief);
|
|
88
|
+
return {
|
|
89
|
+
compaction: {
|
|
90
|
+
summary,
|
|
91
|
+
firstKeptEntryId: impossibleKeptId(event.branchEntries),
|
|
92
|
+
tokensBefore: event.preparation.tokensBefore,
|
|
93
|
+
details: { contextPivot: true },
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
99
|
+
generation += 1;
|
|
100
|
+
pending = undefined;
|
|
101
|
+
compacting = false;
|
|
102
|
+
if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
pi.registerTool({
|
|
106
|
+
name: "context_pivot",
|
|
107
|
+
label: "Context Pivot",
|
|
108
|
+
description:
|
|
109
|
+
"Deliberately replace a long, noisy active context with a concise brief for the next phase while staying in the same Pi session. Use once context is at least 30k tokens and the work is moving between phases such as research → implementation or implementation → review; below 30k it is rejected. Use /handoff instead for a genuinely new session.",
|
|
110
|
+
promptSnippet:
|
|
111
|
+
"Compress a long current session into a clean brief before changing phase",
|
|
112
|
+
promptGuidelines: [
|
|
113
|
+
"Use context_pivot only when the current context is at least 30k tokens and the work is changing phase. Put the current state, decisions, blockers, failed paths worth avoiding, relevant artifact paths, and exact next steps in its brief.",
|
|
114
|
+
],
|
|
115
|
+
parameters: Type.Object({
|
|
116
|
+
brief: Type.String({
|
|
117
|
+
description:
|
|
118
|
+
"Self-contained next-phase brief: goal, current state, decisions and constraints, blockers, failed paths, artifact paths, and immediate next steps.",
|
|
119
|
+
}),
|
|
120
|
+
}),
|
|
121
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
122
|
+
if (compacting)
|
|
123
|
+
throw new Error("A context pivot is already in progress.");
|
|
124
|
+
validateBrief(params.brief, ctx);
|
|
125
|
+
|
|
126
|
+
const pivotGeneration = ++generation;
|
|
127
|
+
pending = { brief: params.brief.trim(), generation: pivotGeneration };
|
|
128
|
+
compacting = true;
|
|
129
|
+
if (ctx.hasUI) {
|
|
130
|
+
ctx.ui.setStatus(
|
|
131
|
+
STATUS_KEY,
|
|
132
|
+
ctx.ui.theme.fg("accent", "↻ pivoting context…"),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const clear = () => {
|
|
137
|
+
if (pivotGeneration !== generation) return;
|
|
138
|
+
compacting = false;
|
|
139
|
+
if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
ctx.compact({
|
|
144
|
+
onComplete: () => {
|
|
145
|
+
clear();
|
|
146
|
+
if (pivotGeneration !== generation) return;
|
|
147
|
+
if (ctx.hasUI) {
|
|
148
|
+
ctx.ui.notify(
|
|
149
|
+
"Context pivot complete. Continuing from the next-phase brief.",
|
|
150
|
+
"info",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
pi.sendUserMessage("Continue with the next phase now.");
|
|
154
|
+
},
|
|
155
|
+
onError: (error) => {
|
|
156
|
+
if (pivotGeneration === generation) pending = undefined;
|
|
157
|
+
clear();
|
|
158
|
+
if (ctx.hasUI) {
|
|
159
|
+
ctx.ui.notify(
|
|
160
|
+
`Context pivot failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
161
|
+
"error",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
} catch (error) {
|
|
167
|
+
pending = undefined;
|
|
168
|
+
clear();
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
content: [{ type: "text", text: "Context pivot started." }],
|
|
174
|
+
details: { contextPivot: true },
|
|
175
|
+
terminate: true,
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
pi.registerCommand("context-pivot", {
|
|
181
|
+
description:
|
|
182
|
+
"Ask the agent to compact this long session into a clean next-phase brief",
|
|
183
|
+
handler: async (args, ctx) => {
|
|
184
|
+
const direction = args.trim();
|
|
185
|
+
if (!direction) {
|
|
186
|
+
if (ctx.hasUI) {
|
|
187
|
+
ctx.ui.notify("Usage: /context-pivot <next phase>", "error");
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const tokens = estimateContextTokens(ctx.getContextUsage());
|
|
193
|
+
if (tokens === null || tokens < MIN_CONTEXT_PIVOT_TOKENS) {
|
|
194
|
+
if (ctx.hasUI) {
|
|
195
|
+
ctx.ui.notify(
|
|
196
|
+
tokens === null
|
|
197
|
+
? "Context usage is unavailable; context pivot was not started."
|
|
198
|
+
: `Context is only ${Math.round(tokens).toLocaleString()} tokens; a pivot is not useful yet.`,
|
|
199
|
+
"warning",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
pi.sendUserMessage(
|
|
206
|
+
[
|
|
207
|
+
`Context pivot direction: ${direction}`,
|
|
208
|
+
"",
|
|
209
|
+
"Prepare a concise, self-contained next-phase brief containing:",
|
|
210
|
+
"- the exact next goal and current state",
|
|
211
|
+
"- decisions and constraints that still matter",
|
|
212
|
+
"- blockers, unresolved questions, and failed paths worth avoiding",
|
|
213
|
+
"- relevant artifact, file, issue, or commit references",
|
|
214
|
+
"- immediate executable next steps",
|
|
215
|
+
"",
|
|
216
|
+
"Then call context_pivot with that brief. Do not continue normal work first.",
|
|
217
|
+
].join("\n"),
|
|
218
|
+
ctx.isIdle() ? undefined : { deliverAs: "followUp" },
|
|
219
|
+
);
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
copyToClipboard,
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
function textFromContent(content: unknown) {
|
|
7
|
+
if (typeof content === "string") return content;
|
|
8
|
+
if (!Array.isArray(content)) return "";
|
|
9
|
+
|
|
10
|
+
return content
|
|
11
|
+
.map((block) => {
|
|
12
|
+
if (!block || typeof block !== "object") return "";
|
|
13
|
+
if (!("type" in block)) return "";
|
|
14
|
+
|
|
15
|
+
if (
|
|
16
|
+
block.type === "text" &&
|
|
17
|
+
"text" in block &&
|
|
18
|
+
typeof block.text === "string"
|
|
19
|
+
) {
|
|
20
|
+
return block.text;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (block.type === "image") return "[image]";
|
|
24
|
+
|
|
25
|
+
return "";
|
|
26
|
+
})
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.join("\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default function (pi: ExtensionAPI) {
|
|
32
|
+
pi.registerCommand("copy-all", {
|
|
33
|
+
description:
|
|
34
|
+
"Copy the current branch's visible user and assistant conversation to the clipboard",
|
|
35
|
+
handler: async (_args, ctx) => {
|
|
36
|
+
await ctx.waitForIdle();
|
|
37
|
+
|
|
38
|
+
const sections = ctx.sessionManager
|
|
39
|
+
.getBranch()
|
|
40
|
+
.filter((entry) => entry.type === "message")
|
|
41
|
+
.map((entry) => entry.message)
|
|
42
|
+
.filter(
|
|
43
|
+
(message) => message.role === "user" || message.role === "assistant",
|
|
44
|
+
)
|
|
45
|
+
.map((message) => ({
|
|
46
|
+
role: message.role,
|
|
47
|
+
content: textFromContent(message.content).trim(),
|
|
48
|
+
}))
|
|
49
|
+
.filter(({ content }) => content)
|
|
50
|
+
.map(({ role, content }) => `${role.toUpperCase()}:\n${content}`);
|
|
51
|
+
|
|
52
|
+
if (sections.length === 0) {
|
|
53
|
+
ctx.ui.notify("No user or assistant messages to copy", "info");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const conversation = sections.join("\n\n---\n\n");
|
|
58
|
+
await copyToClipboard(conversation);
|
|
59
|
+
ctx.ui.notify(
|
|
60
|
+
`Copied ${sections.length} visible messages (${conversation.length.toLocaleString()} characters) from the current branch`,
|
|
61
|
+
"info",
|
|
62
|
+
);
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|