pi-openai-toolkit 0.15.1 → 0.17.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 +60 -68
- package/README.zh.md +58 -66
- package/docs/configuration.md +9 -3
- package/docs/internals.md +31 -6
- package/package.json +11 -9
- package/src/auto-mode/classifier.ts +161 -165
- package/src/auto-mode/extension.ts +691 -661
- package/src/auto-mode/model-selection-guard.ts +52 -0
- package/src/auto-mode/reviewer.ts +325 -335
- package/src/auto-mode/transcript.ts +135 -133
- package/src/compaction-projection.ts +35 -31
- package/src/context-management/manual-compact.ts +433 -0
- package/src/context-management/tools.ts +11 -2
- package/src/context-management/window-manager.ts +35 -9
- package/src/extension-runtime.ts +64 -30
- package/src/payload-rewrite.ts +34 -64
- package/src/pi-context-hook.ts +117 -116
- package/src/remote-v2-client.ts +108 -33
|
@@ -1,133 +1,135 @@
|
|
|
1
|
-
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
boundReviewText,
|
|
4
|
-
MAX_TRANSCRIPT_CHARS,
|
|
5
|
-
MAX_TRANSCRIPT_ENTRY_CHARS,
|
|
6
|
-
MAX_TRANSCRIPT_RECENT_ENTRIES,
|
|
7
|
-
MAX_TRANSCRIPT_TOOL_CHARS,
|
|
8
|
-
TRUNCATION_MARKER,
|
|
9
|
-
} from "./types";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* A transcript line already reduced to text, with the role kept so the reviewer can
|
|
13
|
-
* tell whose words these are. `[user]` is the only role that carries authorization.
|
|
14
|
-
*/
|
|
15
|
-
export type TranscriptLine = {
|
|
16
|
-
role: "user" | "assistant" | "tool";
|
|
17
|
-
text: string;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
function textOfContent(content: unknown): string {
|
|
21
|
-
if (typeof content === "string") return content;
|
|
22
|
-
if (!Array.isArray(content)) return "";
|
|
23
|
-
return content
|
|
24
|
-
.map((block) => {
|
|
25
|
-
if (!block || typeof block !== "object") return "";
|
|
26
|
-
const record = block as Record<string, unknown>;
|
|
27
|
-
if (record.type === "text" && typeof record.text === "string") return record.text;
|
|
28
|
-
if (record.type === "image") return "[image omitted]";
|
|
29
|
-
if (record.type === "toolCall" && typeof record.name === "string") {
|
|
30
|
-
const args = record.arguments;
|
|
31
|
-
return `${record.name} ${typeof args === "object" && args ? JSON.stringify(args) : ""}`.trim();
|
|
32
|
-
}
|
|
33
|
-
return "";
|
|
34
|
-
})
|
|
35
|
-
.filter(Boolean)
|
|
36
|
-
.join("\n")
|
|
37
|
-
.trim();
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Flatten session entries into reviewer lines. Non-message entries are skipped,
|
|
42
|
-
* except a compaction summary, which is kept as a `[user]`-adjacent context marker
|
|
43
|
-
* because it is the only surviving view of the older conversation.
|
|
44
|
-
*/
|
|
45
|
-
export function sessionEntriesToLines(entries: readonly SessionEntry[]): TranscriptLine[] {
|
|
46
|
-
const lines: TranscriptLine[] = [];
|
|
47
|
-
for (const entry of entries) {
|
|
48
|
-
if (entry.type === "compaction" && typeof entry.summary === "string") {
|
|
49
|
-
|
|
50
|
-
if (summary) lines.push({ role: "assistant", text: `[earlier conversation summary] ${summary}` });
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
if (entry.type !== "message") continue;
|
|
54
|
-
const message = entry.message as { role?: unknown; content?: unknown };
|
|
55
|
-
const text = textOfContent(message.content);
|
|
56
|
-
if (!text) continue;
|
|
57
|
-
if (message.role === "user") lines.push({ role: "user", text });
|
|
58
|
-
else if (message.role === "assistant") lines.push({ role: "assistant", text });
|
|
59
|
-
else if (message.role === "toolResult") lines.push({ role: "tool", text });
|
|
60
|
-
}
|
|
61
|
-
return lines;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function renderLine(index: number, line: TranscriptLine): string {
|
|
65
|
-
const body = boundReviewText(line.text,
|
|
66
|
-
return `[${index}] [${line.role}]: ${body}`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Build a bounded transcript the way Codex's guardian does: user turns are the
|
|
71
|
-
* anchors and are protected, tool and assistant evidence gets its own smaller
|
|
72
|
-
* budget so verbose command output cannot crowd out the human conversation that
|
|
73
|
-
* actually establishes authorization.
|
|
74
|
-
*
|
|
75
|
-
* Selection order is newest-first, then rendered in chronological order.
|
|
76
|
-
*/
|
|
77
|
-
export function buildReviewTranscript(
|
|
78
|
-
lines: readonly TranscriptLine[],
|
|
79
|
-
budgets: {
|
|
80
|
-
maxTotalChars?: number;
|
|
81
|
-
maxToolChars?: number;
|
|
82
|
-
maxRecentEntries?: number;
|
|
83
|
-
} = {},
|
|
84
|
-
): { text: string; omitted: boolean } {
|
|
85
|
-
const maxTotalChars = budgets.maxTotalChars ?? MAX_TRANSCRIPT_CHARS;
|
|
86
|
-
const maxToolChars = budgets.maxToolChars ?? MAX_TRANSCRIPT_TOOL_CHARS;
|
|
87
|
-
const maxRecentEntries = budgets.maxRecentEntries ?? MAX_TRANSCRIPT_RECENT_ENTRIES;
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
if (omitted)
|
|
122
|
-
return { text:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
1
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
boundReviewText,
|
|
4
|
+
MAX_TRANSCRIPT_CHARS,
|
|
5
|
+
MAX_TRANSCRIPT_ENTRY_CHARS,
|
|
6
|
+
MAX_TRANSCRIPT_RECENT_ENTRIES,
|
|
7
|
+
MAX_TRANSCRIPT_TOOL_CHARS,
|
|
8
|
+
TRUNCATION_MARKER,
|
|
9
|
+
} from "./types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A transcript line already reduced to text, with the role kept so the reviewer can
|
|
13
|
+
* tell whose words these are. `[user]` is the only role that carries authorization.
|
|
14
|
+
*/
|
|
15
|
+
export type TranscriptLine = {
|
|
16
|
+
role: "user" | "assistant" | "tool";
|
|
17
|
+
text: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function textOfContent(content: unknown): string {
|
|
21
|
+
if (typeof content === "string") return content;
|
|
22
|
+
if (!Array.isArray(content)) return "";
|
|
23
|
+
return content
|
|
24
|
+
.map((block) => {
|
|
25
|
+
if (!block || typeof block !== "object") return "";
|
|
26
|
+
const record = block as Record<string, unknown>;
|
|
27
|
+
if (record.type === "text" && typeof record.text === "string") return record.text;
|
|
28
|
+
if (record.type === "image") return "[image omitted]";
|
|
29
|
+
if (record.type === "toolCall" && typeof record.name === "string") {
|
|
30
|
+
const args = record.arguments;
|
|
31
|
+
return `${record.name} ${typeof args === "object" && args ? JSON.stringify(args) : ""}`.trim();
|
|
32
|
+
}
|
|
33
|
+
return "";
|
|
34
|
+
})
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.join("\n")
|
|
37
|
+
.trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Flatten session entries into reviewer lines. Non-message entries are skipped,
|
|
42
|
+
* except a compaction summary, which is kept as a `[user]`-adjacent context marker
|
|
43
|
+
* because it is the only surviving view of the older conversation.
|
|
44
|
+
*/
|
|
45
|
+
export function sessionEntriesToLines(entries: readonly SessionEntry[]): TranscriptLine[] {
|
|
46
|
+
const lines: TranscriptLine[] = [];
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (entry.type === "compaction" && typeof entry.summary === "string") {
|
|
49
|
+
// Defer bounding until selection so the transcript reports truncation.
|
|
50
|
+
if (entry.summary) lines.push({ role: "assistant", text: `[earlier conversation summary] ${entry.summary}` });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (entry.type !== "message") continue;
|
|
54
|
+
const message = entry.message as { role?: unknown; content?: unknown };
|
|
55
|
+
const text = textOfContent(message.content);
|
|
56
|
+
if (!text) continue;
|
|
57
|
+
if (message.role === "user") lines.push({ role: "user", text });
|
|
58
|
+
else if (message.role === "assistant") lines.push({ role: "assistant", text });
|
|
59
|
+
else if (message.role === "toolResult") lines.push({ role: "tool", text });
|
|
60
|
+
}
|
|
61
|
+
return lines;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderLine(index: number, line: TranscriptLine, maxChars = MAX_TRANSCRIPT_ENTRY_CHARS): string {
|
|
65
|
+
const body = boundReviewText(line.text, maxChars);
|
|
66
|
+
return `[${index}] [${line.role}]: ${body}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Build a bounded transcript the way Codex's guardian does: user turns are the
|
|
71
|
+
* anchors and are protected, tool and assistant evidence gets its own smaller
|
|
72
|
+
* budget so verbose command output cannot crowd out the human conversation that
|
|
73
|
+
* actually establishes authorization.
|
|
74
|
+
*
|
|
75
|
+
* Selection order is newest-first, then rendered in chronological order.
|
|
76
|
+
*/
|
|
77
|
+
export function buildReviewTranscript(
|
|
78
|
+
lines: readonly TranscriptLine[],
|
|
79
|
+
budgets: {
|
|
80
|
+
maxTotalChars?: number;
|
|
81
|
+
maxToolChars?: number;
|
|
82
|
+
maxRecentEntries?: number;
|
|
83
|
+
} = {},
|
|
84
|
+
): { text: string; omitted: boolean } {
|
|
85
|
+
const maxTotalChars = Math.max(0, budgets.maxTotalChars ?? MAX_TRANSCRIPT_CHARS);
|
|
86
|
+
const maxToolChars = Math.max(0, budgets.maxToolChars ?? MAX_TRANSCRIPT_TOOL_CHARS);
|
|
87
|
+
const maxRecentEntries = budgets.maxRecentEntries ?? MAX_TRANSCRIPT_RECENT_ENTRIES;
|
|
88
|
+
const recent = lines.slice(-Math.max(1, maxRecentEntries));
|
|
89
|
+
const notice = `${TRUNCATION_MARKER} conversation content was omitted or truncated.`;
|
|
90
|
+
|
|
91
|
+
function select(budget: number): { text: string; omitted: boolean } {
|
|
92
|
+
const selected = new Map<number, string>();
|
|
93
|
+
let totalChars = 0;
|
|
94
|
+
let toolChars = 0;
|
|
95
|
+
let omitted = recent.length < lines.length;
|
|
96
|
+
// Authorization gets the first claim on space, newest instructions first.
|
|
97
|
+
const indices = recent.map((_, index) => index).reverse();
|
|
98
|
+
for (const index of [...indices.filter((i) => recent[i]!.role === "user"), ...indices.filter((i) => recent[i]!.role !== "user")]) {
|
|
99
|
+
const line = recent[index]!;
|
|
100
|
+
const separator = selected.size > 0 ? 1 : 0;
|
|
101
|
+
const available = Math.min(budget - totalChars, line.role === "user" ? Infinity : maxToolChars - toolChars) - separator;
|
|
102
|
+
const prefix = `[${index + 1}] [${line.role}]: `;
|
|
103
|
+
if (available <= prefix.length + 3) {
|
|
104
|
+
omitted = true;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const rendered = renderLine(index + 1, line, Math.min(MAX_TRANSCRIPT_ENTRY_CHARS, available - prefix.length));
|
|
108
|
+
if (rendered.slice(prefix.length) !== line.text) omitted = true;
|
|
109
|
+
selected.set(index, rendered);
|
|
110
|
+
const cost = rendered.length + separator;
|
|
111
|
+
totalChars += cost;
|
|
112
|
+
if (line.role !== "user") toolChars += cost;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
text: [...selected].sort(([a], [b]) => a - b).map(([, text]) => text).join("\n"),
|
|
116
|
+
omitted,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const initial = select(maxTotalChars);
|
|
121
|
+
if (!initial.omitted) return initial;
|
|
122
|
+
if (maxTotalChars <= notice.length) return { text: notice.slice(0, maxTotalChars), omitted: true };
|
|
123
|
+
const bounded = select(maxTotalChars - notice.length - 1);
|
|
124
|
+
return { text: bounded.text ? `${notice}\n${bounded.text}` : notice, omitted: true };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Build the reviewer transcript straight from session entries.
|
|
129
|
+
*/
|
|
130
|
+
export function transcriptFromEntries(
|
|
131
|
+
entries: readonly SessionEntry[],
|
|
132
|
+
budgets?: Parameters<typeof buildReviewTranscript>[1],
|
|
133
|
+
): { text: string; omitted: boolean } {
|
|
134
|
+
return buildReviewTranscript(sessionEntriesToLines(entries), budgets);
|
|
135
|
+
}
|
|
@@ -1,31 +1,35 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
NativeCompactionDetails,
|
|
3
|
-
NativeCompactionInputProvenance,
|
|
4
|
-
} from "./types";
|
|
5
|
-
|
|
6
|
-
/** The configured Pi context-hook projector is unavailable or failed. */
|
|
7
|
-
export const COMPACTION_PROJECTION_UNAVAILABLE = "no-supported-projection-seam" as const;
|
|
8
|
-
/** The current session context could not be read before projection. */
|
|
9
|
-
export const COMPACTION_SESSION_CONTEXT_UNAVAILABLE = "session-context-unavailable" as const;
|
|
10
|
-
/** A projected recursive context did not contain the current compaction summary. */
|
|
11
|
-
export const COMPACTION_PROJECTED_CONTEXT_UNAVAILABLE = "projected-compaction-context-unavailable" as const;
|
|
12
|
-
/** The latest opaque checkpoint was created with no or a different input marker. */
|
|
13
|
-
export const COMPACTION_CHECKPOINT_PROVENANCE_UNAVAILABLE = "unverified-compaction-checkpoint" as const;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
| typeof
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
1
|
+
import type {
|
|
2
|
+
NativeCompactionDetails,
|
|
3
|
+
NativeCompactionInputProvenance,
|
|
4
|
+
} from "./types";
|
|
5
|
+
|
|
6
|
+
/** The configured Pi context-hook projector is unavailable or failed. */
|
|
7
|
+
export const COMPACTION_PROJECTION_UNAVAILABLE = "no-supported-projection-seam" as const;
|
|
8
|
+
/** The current session context could not be read before projection. */
|
|
9
|
+
export const COMPACTION_SESSION_CONTEXT_UNAVAILABLE = "session-context-unavailable" as const;
|
|
10
|
+
/** A projected recursive context did not contain the current compaction summary. */
|
|
11
|
+
export const COMPACTION_PROJECTED_CONTEXT_UNAVAILABLE = "projected-compaction-context-unavailable" as const;
|
|
12
|
+
/** The latest opaque checkpoint was created with no or a different input marker. */
|
|
13
|
+
export const COMPACTION_CHECKPOINT_PROVENANCE_UNAVAILABLE = "unverified-compaction-checkpoint" as const;
|
|
14
|
+
|
|
15
|
+
/** Persisted edits changed input already sealed inside an opaque checkpoint. */
|
|
16
|
+
export const COMPACTION_CHECKPOINT_CONTEXT_EDITED = "checkpoint-context-edited" as const;
|
|
17
|
+
|
|
18
|
+
export type UnprojectedCompactionReason =
|
|
19
|
+
| typeof COMPACTION_CHECKPOINT_CONTEXT_EDITED
|
|
20
|
+
| typeof COMPACTION_PROJECTION_UNAVAILABLE
|
|
21
|
+
| typeof COMPACTION_SESSION_CONTEXT_UNAVAILABLE
|
|
22
|
+
| typeof COMPACTION_CHECKPOINT_PROVENANCE_UNAVAILABLE
|
|
23
|
+
| typeof COMPACTION_PROJECTED_CONTEXT_UNAVAILABLE;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A Remote V2 checkpoint is safe for the current replay chain only when its
|
|
27
|
+
* marker matches the configured input source. Structural persistence validation
|
|
28
|
+
* intentionally remains more permissive for old session files.
|
|
29
|
+
*/
|
|
30
|
+
export function hasVerifiedCompactionInputProvenance(
|
|
31
|
+
details: Pick<NativeCompactionDetails, "inputProvenance"> | undefined,
|
|
32
|
+
expected: NativeCompactionInputProvenance,
|
|
33
|
+
): boolean {
|
|
34
|
+
return details?.inputProvenance === expected;
|
|
35
|
+
}
|