@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,147 @@
|
|
|
1
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
3
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
4
|
+
import { Data, Effect } from "effect";
|
|
5
|
+
import { sanitizeTerminalText } from "../../shared/terminal-text.ts";
|
|
6
|
+
import type { SuggestionConfig } from "./config.ts";
|
|
7
|
+
import { buildSuggestionPrompt, SUGGESTION_SYSTEM_PROMPT } from "./prompt.ts";
|
|
8
|
+
|
|
9
|
+
const SUGGESTION_MAX_CHARS = 200;
|
|
10
|
+
|
|
11
|
+
class PredictionError extends Data.TaggedError("PredictionError")<{
|
|
12
|
+
readonly message: string;
|
|
13
|
+
readonly cause?: unknown;
|
|
14
|
+
}> {}
|
|
15
|
+
|
|
16
|
+
function cleanSuggestion(value: string) {
|
|
17
|
+
const oneLine = sanitizeTerminalText(value)
|
|
18
|
+
.normalize("NFC")
|
|
19
|
+
// Model output is untrusted editor input. Format controls can create an
|
|
20
|
+
// invisible or direction-spoofed suggestion, so never offer them.
|
|
21
|
+
.replace(/\p{Cf}/gu, "")
|
|
22
|
+
.replace(/\s+/g, " ")
|
|
23
|
+
.trim();
|
|
24
|
+
const bounded = [...oneLine].slice(0, SUGGESTION_MAX_CHARS).join("");
|
|
25
|
+
return visibleWidth(bounded) > 0 ? bounded : "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseCandidate(candidate: string): string | undefined | null {
|
|
29
|
+
try {
|
|
30
|
+
const value: unknown = JSON.parse(candidate);
|
|
31
|
+
if (
|
|
32
|
+
typeof value !== "object" ||
|
|
33
|
+
value === null ||
|
|
34
|
+
Array.isArray(value) ||
|
|
35
|
+
Object.keys(value).join(",") !== "suggestion" ||
|
|
36
|
+
!("suggestion" in value)
|
|
37
|
+
) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
if (value.suggestion === null) return undefined;
|
|
41
|
+
if (typeof value.suggestion !== "string") return null;
|
|
42
|
+
return cleanSuggestion(value.suggestion) || undefined;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseSuggestionResponse(text: string) {
|
|
49
|
+
const trimmed = text.trim();
|
|
50
|
+
const candidates = [trimmed];
|
|
51
|
+
for (const match of trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)) {
|
|
52
|
+
if (match[1]) candidates.push(match[1].trim());
|
|
53
|
+
}
|
|
54
|
+
const firstBrace = trimmed.indexOf("{");
|
|
55
|
+
const lastBrace = trimmed.lastIndexOf("}");
|
|
56
|
+
if (firstBrace !== -1 && lastBrace > firstBrace) {
|
|
57
|
+
candidates.push(trimmed.slice(firstBrace, lastBrace + 1));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const candidate of candidates) {
|
|
61
|
+
const parsed = parseCandidate(candidate);
|
|
62
|
+
if (parsed !== null) return parsed;
|
|
63
|
+
}
|
|
64
|
+
throw new PredictionError({
|
|
65
|
+
message: "The prediction model did not return valid suggestion JSON.",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function reasoningOptions(reasoning: SuggestionConfig["reasoning"]) {
|
|
70
|
+
return reasoning === "off" ? {} : { reasoning };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assistantText(
|
|
74
|
+
content: Awaited<ReturnType<typeof completeSimple>>["content"],
|
|
75
|
+
) {
|
|
76
|
+
return content
|
|
77
|
+
.filter((block) => block.type === "text")
|
|
78
|
+
.map((block) => block.text)
|
|
79
|
+
.join("\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function predictNextAction(options: {
|
|
83
|
+
readonly modelRegistry: ModelRegistry;
|
|
84
|
+
readonly config: SuggestionConfig;
|
|
85
|
+
readonly transcript: string;
|
|
86
|
+
readonly signal: AbortSignal;
|
|
87
|
+
}) {
|
|
88
|
+
const completion = Effect.tryPromise({
|
|
89
|
+
try: async (effectSignal) => {
|
|
90
|
+
const model = options.modelRegistry.find(
|
|
91
|
+
options.config.provider,
|
|
92
|
+
options.config.model,
|
|
93
|
+
);
|
|
94
|
+
if (!model) {
|
|
95
|
+
throw new PredictionError({
|
|
96
|
+
message: `Suggestion model is unavailable: ${options.config.provider}/${options.config.model}`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const auth = await options.modelRegistry.getApiKeyAndHeaders(model);
|
|
101
|
+
if (!auth.ok) throw new PredictionError({ message: auth.error });
|
|
102
|
+
|
|
103
|
+
const response = await completeSimple(
|
|
104
|
+
model,
|
|
105
|
+
{
|
|
106
|
+
systemPrompt: SUGGESTION_SYSTEM_PROMPT,
|
|
107
|
+
messages: [
|
|
108
|
+
{
|
|
109
|
+
role: "user",
|
|
110
|
+
content: buildSuggestionPrompt(options.transcript),
|
|
111
|
+
timestamp: Date.now(),
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
apiKey: auth.apiKey,
|
|
117
|
+
env: auth.env,
|
|
118
|
+
headers: auth.headers,
|
|
119
|
+
maxTokens: 300,
|
|
120
|
+
maxRetries: 1,
|
|
121
|
+
signal: effectSignal,
|
|
122
|
+
timeoutMs: 20_000,
|
|
123
|
+
...reasoningOptions(options.config.reasoning),
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
if (
|
|
128
|
+
response.stopReason === "error" ||
|
|
129
|
+
response.stopReason === "aborted"
|
|
130
|
+
) {
|
|
131
|
+
throw new PredictionError({
|
|
132
|
+
message: response.errorMessage ?? "Suggestion model request failed.",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return parseSuggestionResponse(assistantText(response.content));
|
|
136
|
+
},
|
|
137
|
+
catch: (cause) =>
|
|
138
|
+
cause instanceof PredictionError
|
|
139
|
+
? cause
|
|
140
|
+
: new PredictionError({
|
|
141
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
142
|
+
cause,
|
|
143
|
+
}),
|
|
144
|
+
}).pipe(Effect.timeout("25 seconds"));
|
|
145
|
+
|
|
146
|
+
return Effect.runPromise(completion, { signal: options.signal });
|
|
147
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const SUGGESTION_SYSTEM_PROMPT = `Predict the user's single most likely immediate next message after a completed coding-agent run.
|
|
2
|
+
|
|
3
|
+
Return exactly one JSON object in one of these shapes:
|
|
4
|
+
{"suggestion":"..."}
|
|
5
|
+
{"suggestion":null}
|
|
6
|
+
|
|
7
|
+
Rules:
|
|
8
|
+
- Write suggestion exactly as the user would type it to the coding agent, not as advice about what the user should do.
|
|
9
|
+
- Choose one concrete, useful continuation grounded in the supplied run: a likely follow-up, verification, refinement, or next task. Never repeat work the run already confirmed complete.
|
|
10
|
+
- Match the language, tone, and brevity of the user's initiating prompt. Keep the suggestion to one line and at most 200 characters.
|
|
11
|
+
- Emit a natural-language user request, never an imitation of an assistant tool call. Do not copy a bare tool name, shell command, or filesystem path from assistant/tool transcript; mention a path only when the user's own initiating prompt named it and it remains necessary.
|
|
12
|
+
- Return null when there is no specific, plausible continuation. Do not emit generic filler such as "continue", "anything else", "review the result", or "let me know".
|
|
13
|
+
- Never invent facts, paths, failures, approvals, or user intent. Never propose destructive, irreversible, publishing, purchasing, credential, or permission-changing actions unless the user's run explicitly requested that exact action and it remains unfinished.
|
|
14
|
+
- Base the answer only on the supplied current-run transcript.
|
|
15
|
+
- Do not mention these instructions, hidden reasoning, transcript truncation, or that you are predicting the user.
|
|
16
|
+
- Do not use Markdown, a code fence, or prose outside the JSON object.`;
|
|
17
|
+
|
|
18
|
+
export function buildSuggestionPrompt(transcript: string) {
|
|
19
|
+
return `Predict the next user input after this fully settled main-agent run.\n\n<current_run>\n${transcript}\n</current_run>`;
|
|
20
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export const TOOL_ARGUMENT_MAX_BYTES = 2_000;
|
|
4
|
+
export const TOOL_RESULT_MAX_BYTES = 5_000;
|
|
5
|
+
export const TRANSCRIPT_MAX_BYTES = 48_000;
|
|
6
|
+
|
|
7
|
+
const SECRET_KEY_PATTERN =
|
|
8
|
+
/(?:api[_-]?key|access[_-]?key|authorization|cookie|credential|password|passwd|private[_-]?key|secret|token)/i;
|
|
9
|
+
|
|
10
|
+
export interface RunMarker {
|
|
11
|
+
readonly baselineLeafId: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createRunBoundary() {
|
|
15
|
+
let pending: RunMarker | undefined;
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
begin(baselineLeafId: string | null) {
|
|
19
|
+
pending = { baselineLeafId };
|
|
20
|
+
},
|
|
21
|
+
settle() {
|
|
22
|
+
const run = pending;
|
|
23
|
+
pending = undefined;
|
|
24
|
+
return run;
|
|
25
|
+
},
|
|
26
|
+
reset() {
|
|
27
|
+
pending = undefined;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getRunEntries(
|
|
33
|
+
branch: readonly SessionEntry[],
|
|
34
|
+
baselineLeafId: string | null,
|
|
35
|
+
) {
|
|
36
|
+
if (baselineLeafId === null) return [...branch];
|
|
37
|
+
const baselineIndex = branch.findIndex(
|
|
38
|
+
(entry) => entry.id === baselineLeafId,
|
|
39
|
+
);
|
|
40
|
+
return baselineIndex === -1 ? [] : branch.slice(baselineIndex + 1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function truncateUtf8(text: string, maxBytes: number) {
|
|
44
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
45
|
+
|
|
46
|
+
let low = 0;
|
|
47
|
+
let high = text.length;
|
|
48
|
+
while (low < high) {
|
|
49
|
+
const midpoint = Math.ceil((low + high) / 2);
|
|
50
|
+
if (Buffer.byteLength(text.slice(0, midpoint), "utf8") <= maxBytes) {
|
|
51
|
+
low = midpoint;
|
|
52
|
+
} else {
|
|
53
|
+
high = midpoint - 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let end = low;
|
|
58
|
+
const last = text.charCodeAt(end - 1);
|
|
59
|
+
if (last >= 0xd800 && last <= 0xdbff) end -= 1;
|
|
60
|
+
return text.slice(0, end);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function capped(text: string, maxBytes: number, notice: string) {
|
|
64
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
65
|
+
const suffix = `\n[${notice}]`;
|
|
66
|
+
return `${truncateUtf8(text, maxBytes - Buffer.byteLength(suffix, "utf8"))}${suffix}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function redactSecrets(text: string) {
|
|
70
|
+
return text
|
|
71
|
+
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
|
|
72
|
+
.replace(
|
|
73
|
+
/\b(sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|eyJ[A-Za-z0-9_-]{12,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,})\b/g,
|
|
74
|
+
"[REDACTED]",
|
|
75
|
+
)
|
|
76
|
+
.replace(
|
|
77
|
+
/(["']?(?:api[_-]?key|access[_-]?key|authorization|cookie|credential|password|passwd|private[_-]?key|secret|token)["']?\s*[:=]\s*)(["']?)[^\s,;}]+\2/gi,
|
|
78
|
+
"$1[REDACTED]",
|
|
79
|
+
)
|
|
80
|
+
.replace(
|
|
81
|
+
/([?&](?:api[_-]?key|access[_-]?token|key|secret|token)=)[^&#\s]+/gi,
|
|
82
|
+
"$1[REDACTED]",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sanitizeValue(value: unknown, key?: string, depth = 0): unknown {
|
|
87
|
+
if (key && SECRET_KEY_PATTERN.test(key)) return "[REDACTED]";
|
|
88
|
+
if (depth >= 6) return "[nested value omitted]";
|
|
89
|
+
if (typeof value === "string") return redactSecrets(value);
|
|
90
|
+
if (typeof value === "bigint") return `${value}n`;
|
|
91
|
+
if (typeof value === "function" || typeof value === "symbol") {
|
|
92
|
+
return `[${typeof value} omitted]`;
|
|
93
|
+
}
|
|
94
|
+
if (Array.isArray(value)) {
|
|
95
|
+
const items = value
|
|
96
|
+
.slice(0, 30)
|
|
97
|
+
.map((item) => sanitizeValue(item, undefined, depth + 1));
|
|
98
|
+
if (value.length > items.length) items.push("[additional items omitted]");
|
|
99
|
+
return items;
|
|
100
|
+
}
|
|
101
|
+
if (typeof value === "object" && value !== null) {
|
|
102
|
+
return Object.fromEntries(
|
|
103
|
+
Object.entries(value).map(([entryKey, entryValue]) => [
|
|
104
|
+
entryKey,
|
|
105
|
+
sanitizeValue(entryValue, entryKey, depth + 1),
|
|
106
|
+
]),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function serializeToolArguments(value: unknown) {
|
|
113
|
+
try {
|
|
114
|
+
return JSON.stringify(sanitizeValue(value), null, 2) ?? "(no arguments)";
|
|
115
|
+
} catch {
|
|
116
|
+
return "[tool arguments could not be serialized]";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function textContent(content: unknown) {
|
|
121
|
+
if (typeof content === "string") return redactSecrets(content);
|
|
122
|
+
if (!Array.isArray(content)) return "";
|
|
123
|
+
return content
|
|
124
|
+
.flatMap((block) => {
|
|
125
|
+
if (
|
|
126
|
+
typeof block === "object" &&
|
|
127
|
+
block !== null &&
|
|
128
|
+
"type" in block &&
|
|
129
|
+
block.type === "text" &&
|
|
130
|
+
"text" in block &&
|
|
131
|
+
typeof block.text === "string"
|
|
132
|
+
) {
|
|
133
|
+
return [redactSecrets(block.text)];
|
|
134
|
+
}
|
|
135
|
+
return [];
|
|
136
|
+
})
|
|
137
|
+
.join("\n");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function serializeMessage(entry: Extract<SessionEntry, { type: "message" }>) {
|
|
141
|
+
const { message } = entry;
|
|
142
|
+
|
|
143
|
+
if (message.role === "user") {
|
|
144
|
+
const text = textContent(message.content);
|
|
145
|
+
return text ? `USER\n${text}` : "";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (message.role === "assistant") {
|
|
149
|
+
const sections: string[] = [];
|
|
150
|
+
const text = message.content
|
|
151
|
+
.filter((block) => block.type === "text")
|
|
152
|
+
.map((block) => redactSecrets(block.text))
|
|
153
|
+
.join("\n");
|
|
154
|
+
if (text) sections.push(`ASSISTANT\n${text}`);
|
|
155
|
+
|
|
156
|
+
for (const block of message.content) {
|
|
157
|
+
if (block.type !== "toolCall") continue;
|
|
158
|
+
const args = capped(
|
|
159
|
+
redactSecrets(serializeToolArguments(block.arguments)),
|
|
160
|
+
TOOL_ARGUMENT_MAX_BYTES,
|
|
161
|
+
"tool arguments capped",
|
|
162
|
+
);
|
|
163
|
+
sections.push(`TOOL CALL ${block.name}\n${args}`);
|
|
164
|
+
}
|
|
165
|
+
return sections.join("\n\n");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (message.role === "toolResult") {
|
|
169
|
+
const text = capped(
|
|
170
|
+
textContent(message.content),
|
|
171
|
+
TOOL_RESULT_MAX_BYTES,
|
|
172
|
+
"tool result capped",
|
|
173
|
+
);
|
|
174
|
+
return `TOOL RESULT ${message.toolName}${message.isError ? " (error)" : ""}\n${text || "(no text output)"}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (message.role === "bashExecution") {
|
|
178
|
+
const command = capped(
|
|
179
|
+
redactSecrets(message.command),
|
|
180
|
+
TOOL_ARGUMENT_MAX_BYTES,
|
|
181
|
+
"command capped",
|
|
182
|
+
);
|
|
183
|
+
const output = capped(
|
|
184
|
+
redactSecrets(message.output),
|
|
185
|
+
TOOL_RESULT_MAX_BYTES,
|
|
186
|
+
"command output capped",
|
|
187
|
+
);
|
|
188
|
+
return `USER SHELL${message.exitCode === undefined ? "" : ` (exit ${message.exitCode})`}\n${command}\n${output}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (message.role === "custom") {
|
|
192
|
+
if (message.customType === "summary-recap") return "";
|
|
193
|
+
const text = textContent(message.content);
|
|
194
|
+
return text ? `EXTENSION ${message.customType}\n${text}` : "";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return "";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function serializeRunTranscript(
|
|
201
|
+
entries: readonly SessionEntry[],
|
|
202
|
+
maxBytes = TRANSCRIPT_MAX_BYTES,
|
|
203
|
+
) {
|
|
204
|
+
const sections = entries.flatMap((entry) => {
|
|
205
|
+
if (entry.type === "message") {
|
|
206
|
+
const section = serializeMessage(entry);
|
|
207
|
+
return section ? [section] : [];
|
|
208
|
+
}
|
|
209
|
+
if (
|
|
210
|
+
entry.type === "custom_message" &&
|
|
211
|
+
entry.customType !== "summary-recap"
|
|
212
|
+
) {
|
|
213
|
+
const text = textContent(entry.content);
|
|
214
|
+
return text ? [`EXTENSION ${entry.customType}\n${text}`] : [];
|
|
215
|
+
}
|
|
216
|
+
return [];
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const transcript = sections.join("\n\n---\n\n") || "(no textual run output)";
|
|
220
|
+
if (Buffer.byteLength(transcript, "utf8") <= maxBytes) return transcript;
|
|
221
|
+
|
|
222
|
+
const marker = "\n\n[... transcript capped; middle omitted ...]\n\n";
|
|
223
|
+
const markerBytes = Buffer.byteLength(marker, "utf8");
|
|
224
|
+
const headBytes = Math.floor((maxBytes - markerBytes) * 0.58);
|
|
225
|
+
const tailBytes = maxBytes - markerBytes - headBytes;
|
|
226
|
+
const head = truncateUtf8(transcript, headBytes);
|
|
227
|
+
const reversedTail = truncateUtf8(
|
|
228
|
+
[...transcript].reverse().join(""),
|
|
229
|
+
tailBytes,
|
|
230
|
+
);
|
|
231
|
+
const tail = [...reversedTail].reverse().join("");
|
|
232
|
+
return `${head}${marker}${tail}`;
|
|
233
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type { KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { EditorComponent } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
CURSOR_MARKER,
|
|
5
|
+
Key,
|
|
6
|
+
matchesKey,
|
|
7
|
+
truncateToWidth,
|
|
8
|
+
visibleWidth,
|
|
9
|
+
} from "@earendil-works/pi-tui";
|
|
10
|
+
import {
|
|
11
|
+
BelowEditorNavigationEditor,
|
|
12
|
+
BelowEditorStripState,
|
|
13
|
+
} from "../../shared/below-editor-navigation.ts";
|
|
14
|
+
|
|
15
|
+
const FAKE_CURSOR_PATTERN = /\u001b\[7m \u001b\[(?:0|27)m/;
|
|
16
|
+
const IME_PREEDIT_MIN_COLUMNS = 12;
|
|
17
|
+
const IME_PREEDIT_MAX_COLUMNS = 32;
|
|
18
|
+
const GHOST_MIN_COLUMNS = 8;
|
|
19
|
+
|
|
20
|
+
export interface SuggestionToken {
|
|
21
|
+
readonly generation: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Ephemeral latest-wins state; suggestions are never written to session history. */
|
|
25
|
+
export class NextActionSuggestionState {
|
|
26
|
+
private generation = 0;
|
|
27
|
+
private active = false;
|
|
28
|
+
private suggestion?: string;
|
|
29
|
+
|
|
30
|
+
begin(): SuggestionToken {
|
|
31
|
+
this.generation += 1;
|
|
32
|
+
this.active = true;
|
|
33
|
+
this.suggestion = undefined;
|
|
34
|
+
return { generation: this.generation };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
offer(
|
|
38
|
+
token: SuggestionToken,
|
|
39
|
+
suggestion: string | undefined,
|
|
40
|
+
editorEmpty: boolean,
|
|
41
|
+
) {
|
|
42
|
+
if (!this.active || token.generation !== this.generation) return false;
|
|
43
|
+
if (!suggestion || !editorEmpty) {
|
|
44
|
+
this.active = false;
|
|
45
|
+
this.suggestion = undefined;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
this.suggestion = suggestion;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
cancel() {
|
|
53
|
+
this.generation += 1;
|
|
54
|
+
this.active = false;
|
|
55
|
+
this.suggestion = undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
isActive() {
|
|
59
|
+
return this.active;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
peek() {
|
|
63
|
+
return this.suggestion;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
revision() {
|
|
67
|
+
return this.generation;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
accept() {
|
|
71
|
+
const suggestion = this.suggestion;
|
|
72
|
+
this.cancel();
|
|
73
|
+
return suggestion;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isStripNavigation(data: string) {
|
|
78
|
+
return (
|
|
79
|
+
matchesKey(data, Key.up) ||
|
|
80
|
+
matchesKey(data, Key.down) ||
|
|
81
|
+
matchesKey(data, Key.left) ||
|
|
82
|
+
matchesKey(data, Key.right) ||
|
|
83
|
+
matchesKey(data, Key.enter) ||
|
|
84
|
+
matchesKey(data, Key.escape)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ghostGeometry(lines: readonly string[], width: number) {
|
|
89
|
+
const index = lines.findIndex((line) => FAKE_CURSOR_PATTERN.test(line));
|
|
90
|
+
if (index === -1) return undefined;
|
|
91
|
+
const line = lines[index]!;
|
|
92
|
+
const cursor = line.match(FAKE_CURSOR_PATTERN);
|
|
93
|
+
if (!cursor || cursor.index === undefined) return undefined;
|
|
94
|
+
const beforeCursor = line
|
|
95
|
+
.slice(0, cursor.index)
|
|
96
|
+
.replaceAll(CURSOR_MARKER, "");
|
|
97
|
+
const prefix = `${beforeCursor}${cursor[0]}`;
|
|
98
|
+
const remaining = Math.max(0, width - visibleWidth(prefix));
|
|
99
|
+
// One terminal cell must remain after the hidden hardware cursor. At
|
|
100
|
+
// narrower widths, suppress the ghost rather than placing the cursor at the
|
|
101
|
+
// terminal's out-of-range column width.
|
|
102
|
+
if (remaining <= GHOST_MIN_COLUMNS) return undefined;
|
|
103
|
+
|
|
104
|
+
const desiredPreedit = Math.min(
|
|
105
|
+
IME_PREEDIT_MAX_COLUMNS,
|
|
106
|
+
Math.max(IME_PREEDIT_MIN_COLUMNS, Math.floor(width * 0.3)),
|
|
107
|
+
);
|
|
108
|
+
const preedit = Math.min(desiredPreedit, remaining - GHOST_MIN_COLUMNS);
|
|
109
|
+
return { index, prefix, available: remaining - preedit, preedit };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function renderGhostSuggestion(
|
|
113
|
+
lines: readonly string[],
|
|
114
|
+
width: number,
|
|
115
|
+
suggestion: string,
|
|
116
|
+
dim: (text: string) => string,
|
|
117
|
+
) {
|
|
118
|
+
const geometry = ghostGeometry(lines, width);
|
|
119
|
+
if (!geometry) return [...lines];
|
|
120
|
+
const { index, prefix, available, preedit } = geometry;
|
|
121
|
+
const ghost = truncateToWidth(suggestion, available, "…");
|
|
122
|
+
const content = `${prefix}${dim(ghost)}`;
|
|
123
|
+
const padding = " ".repeat(
|
|
124
|
+
Math.max(0, width - preedit - visibleWidth(content)),
|
|
125
|
+
);
|
|
126
|
+
const rendered = [...lines];
|
|
127
|
+
// CJK IMEs draw uncommitted preedit at the terminal's hidden hardware
|
|
128
|
+
// cursor before the editor receives an input event. Keep the visible fake
|
|
129
|
+
// cursor and ghost inline, but move that hardware anchor to reserved cells
|
|
130
|
+
// at the row end so preedit cannot overwrite the suggestion.
|
|
131
|
+
rendered[index] =
|
|
132
|
+
`${content}${padding}${CURSOR_MARKER}${" ".repeat(preedit)}`;
|
|
133
|
+
return rendered;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Transparent editor wrapper that shows one dim inline suggestion in an empty
|
|
138
|
+
* editor. Right accepts without submitting; any other editor interaction
|
|
139
|
+
* cancels the pending or visible suggestion. Existing management-strip
|
|
140
|
+
* navigation keeps precedence while a strip is focused.
|
|
141
|
+
*/
|
|
142
|
+
export class NextActionSuggestionEditor extends BelowEditorNavigationEditor {
|
|
143
|
+
private readonly editor: EditorComponent;
|
|
144
|
+
private readonly state: NextActionSuggestionState;
|
|
145
|
+
private readonly onInteraction: () => void;
|
|
146
|
+
private readonly requestSuggestionRender: () => void;
|
|
147
|
+
private readonly dim: (text: string) => string;
|
|
148
|
+
private visibleRevision?: number;
|
|
149
|
+
|
|
150
|
+
constructor(
|
|
151
|
+
base: EditorComponent,
|
|
152
|
+
keybindings: KeybindingsManager,
|
|
153
|
+
state: NextActionSuggestionState,
|
|
154
|
+
onInteraction: () => void,
|
|
155
|
+
requestRender: () => void,
|
|
156
|
+
dim: (text: string) => string,
|
|
157
|
+
) {
|
|
158
|
+
super(
|
|
159
|
+
base,
|
|
160
|
+
keybindings,
|
|
161
|
+
new BelowEditorStripState(),
|
|
162
|
+
() => false,
|
|
163
|
+
() => undefined,
|
|
164
|
+
requestRender,
|
|
165
|
+
);
|
|
166
|
+
this.editor = base;
|
|
167
|
+
this.state = state;
|
|
168
|
+
this.onInteraction = onInteraction;
|
|
169
|
+
this.requestSuggestionRender = requestRender;
|
|
170
|
+
this.dim = dim;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
override handleInput(data: string) {
|
|
174
|
+
if (this.state.isActive()) {
|
|
175
|
+
if (this.hasFocusedStrip() && isStripNavigation(data)) {
|
|
176
|
+
super.handleInput(data);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const suggestion = this.state.peek();
|
|
180
|
+
if (
|
|
181
|
+
suggestion &&
|
|
182
|
+
this.visibleRevision === this.state.revision() &&
|
|
183
|
+
this.editor.getText().length === 0 &&
|
|
184
|
+
matchesKey(data, Key.right)
|
|
185
|
+
) {
|
|
186
|
+
const accepted = this.state.accept();
|
|
187
|
+
this.visibleRevision = undefined;
|
|
188
|
+
if (accepted) super.setText(accepted);
|
|
189
|
+
this.requestSuggestionRender();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
this.visibleRevision = undefined;
|
|
193
|
+
this.onInteraction();
|
|
194
|
+
}
|
|
195
|
+
super.handleInput(data);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
override render(width: number) {
|
|
199
|
+
const lines = super.render(width);
|
|
200
|
+
const suggestion = this.state.peek();
|
|
201
|
+
if (
|
|
202
|
+
suggestion &&
|
|
203
|
+
this.editor.getText().length === 0 &&
|
|
204
|
+
ghostGeometry(lines, width)
|
|
205
|
+
) {
|
|
206
|
+
this.visibleRevision = this.state.revision();
|
|
207
|
+
return renderGhostSuggestion(lines, width, suggestion, this.dim);
|
|
208
|
+
}
|
|
209
|
+
this.visibleRevision = undefined;
|
|
210
|
+
return lines;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
override setText(text: string) {
|
|
214
|
+
this.visibleRevision = undefined;
|
|
215
|
+
if (this.state.isActive()) this.onInteraction();
|
|
216
|
+
super.setText(text);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
override insertTextAtCursor(text: string) {
|
|
220
|
+
this.visibleRevision = undefined;
|
|
221
|
+
if (this.state.isActive()) this.onInteraction();
|
|
222
|
+
super.insertTextAtCursor(text);
|
|
223
|
+
}
|
|
224
|
+
}
|