@oh-my-pi/pi-coding-agent 16.2.5 → 16.2.7
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/CHANGELOG.md +41 -1
- package/dist/cli.js +3089 -3104
- package/dist/types/cli/bench-cli.d.ts +6 -1
- package/dist/types/commands/bench.d.ts +8 -0
- package/dist/types/config/model-discovery.d.ts +6 -1
- package/dist/types/config/service-tier.d.ts +39 -24
- package/dist/types/config/settings-schema.d.ts +37 -37
- package/dist/types/edit/index.d.ts +1 -0
- package/dist/types/edit/renderer.d.ts +4 -0
- package/dist/types/edit/snapshot-details.d.ts +33 -0
- package/dist/types/mcp/config-writer.d.ts +48 -0
- package/dist/types/mcp/oauth-flow.d.ts +4 -6
- package/dist/types/mcp/types.d.ts +3 -0
- package/dist/types/modes/controllers/tool-args-reveal.d.ts +9 -2
- package/dist/types/session/agent-session.d.ts +37 -13
- package/dist/types/session/messages.d.ts +1 -1
- package/dist/types/session/session-context.d.ts +2 -2
- package/dist/types/session/session-entries.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +5 -6
- package/dist/types/system-prompt.test.d.ts +1 -0
- package/dist/types/task/executor.d.ts +6 -6
- package/dist/types/task/types.d.ts +6 -0
- package/dist/types/task/worktree.d.ts +32 -6
- package/dist/types/tiny/title-client.d.ts +5 -1
- package/dist/types/tools/index.d.ts +3 -3
- package/dist/types/utils/git.d.ts +17 -0
- package/dist/types/web/search/providers/duckduckgo.d.ts +2 -2
- package/dist/types/web/search/types.d.ts +1 -1
- package/package.json +12 -12
- package/src/cli/args.ts +32 -1
- package/src/cli/bench-cli.ts +97 -22
- package/src/cli/tiny-models-cli.ts +18 -4
- package/src/cli/web-search-cli.ts +6 -1
- package/src/commands/bench.ts +10 -1
- package/src/config/mcp-schema.json +11 -2
- package/src/config/model-discovery.ts +66 -8
- package/src/config/model-registry.ts +13 -6
- package/src/config/service-tier.ts +85 -56
- package/src/config/settings-schema.ts +42 -36
- package/src/config/settings.ts +47 -0
- package/src/edit/hashline/execute.ts +15 -9
- package/src/edit/index.ts +19 -6
- package/src/edit/modes/patch.ts +3 -2
- package/src/edit/modes/replace.ts +3 -2
- package/src/edit/renderer.ts +4 -0
- package/src/edit/snapshot-details.ts +77 -0
- package/src/eval/agent-bridge.ts +4 -2
- package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/main.ts +1 -1
- package/src/mcp/config-writer.ts +121 -0
- package/src/mcp/config.ts +10 -6
- package/src/mcp/oauth-flow.ts +10 -8
- package/src/mcp/transports/stdio.ts +9 -17
- package/src/mcp/types.ts +3 -0
- package/src/modes/components/extensions/extension-dashboard.ts +46 -0
- package/src/modes/components/extensions/state-manager.ts +24 -3
- package/src/modes/controllers/event-controller.ts +24 -8
- package/src/modes/controllers/tool-args-reveal.ts +100 -22
- package/src/modes/utils/transcript-render-helpers.ts +2 -2
- package/src/prompts/bench.md +4 -10
- package/src/prompts/tools/irc.md +19 -29
- package/src/prompts/tools/job.md +8 -14
- package/src/prompts/tools/lsp.md +19 -30
- package/src/prompts/tools/task.md +42 -62
- package/src/sdk.ts +13 -11
- package/src/session/agent-session.ts +196 -76
- package/src/session/messages.ts +1 -1
- package/src/session/session-context.ts +4 -4
- package/src/session/session-entries.ts +2 -2
- package/src/session/session-listing.ts +9 -8
- package/src/session/session-loader.ts +98 -3
- package/src/session/session-manager.ts +43 -6
- package/src/slash-commands/builtin-registry.ts +2 -10
- package/src/subprocess/worker-client.ts +12 -4
- package/src/system-prompt.test.ts +158 -0
- package/src/system-prompt.ts +69 -26
- package/src/task/executor.ts +23 -16
- package/src/task/index.ts +7 -5
- package/src/task/isolation-runner.ts +15 -1
- package/src/task/types.ts +6 -0
- package/src/task/worktree.ts +219 -38
- package/src/tiny/title-client.ts +19 -13
- package/src/tools/index.ts +3 -3
- package/src/tools/irc.ts +9 -3
- package/src/tools/path-utils.ts +4 -2
- package/src/tools/read.ts +28 -28
- package/src/utils/file-mentions.ts +10 -1
- package/src/utils/git.ts +38 -0
- package/src/web/search/index.ts +14 -8
- package/src/web/search/providers/duckduckgo.ts +150 -78
- package/src/web/search/providers/gemini.ts +268 -185
- package/src/web/search/types.ts +1 -1
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as readline from "node:readline";
|
|
1
3
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
2
4
|
import { getBlobsDir, isEnoent, parseJsonlLenient } from "@oh-my-pi/pi-utils";
|
|
3
5
|
import { BlobStore, isBlobRef, resolveImageData, resolveImageDataUrl } from "./blob-store";
|
|
4
6
|
import { buildSessionContext } from "./session-context";
|
|
5
7
|
import {
|
|
8
|
+
type CompactionEntry,
|
|
6
9
|
type FileEntry,
|
|
7
10
|
type RawFileEntry,
|
|
8
11
|
SESSION_TITLE_SLOT_BYTES,
|
|
@@ -20,6 +23,10 @@ import {
|
|
|
20
23
|
titleUpdateFromSlot,
|
|
21
24
|
} from "./session-title-slot";
|
|
22
25
|
|
|
26
|
+
const STREAM_LOAD_THRESHOLD_BYTES = 8 * 1024 * 1024;
|
|
27
|
+
const ELIDED_COMPACTION_SUMMARY = "[Superseded compaction summary elided during session load]";
|
|
28
|
+
const ELIDED_COMPACTION_SHORT_SUMMARY = "Superseded compaction elided";
|
|
29
|
+
|
|
23
30
|
function splitTitleSlot(content: string): { body: string; slot: SessionTitleUpdate | undefined } {
|
|
24
31
|
const slot = titleUpdateFromSlot(parseTitleSlotFromContent(content));
|
|
25
32
|
if (!slot) return { body: content, slot: undefined };
|
|
@@ -54,6 +61,89 @@ export function parseSessionContent(content: string): {
|
|
|
54
61
|
return { entries: foldTitleSlot(entries, slot), titleSlot: slot };
|
|
55
62
|
}
|
|
56
63
|
|
|
64
|
+
function elideCompactionSummary(entry: CompactionEntry | undefined): boolean {
|
|
65
|
+
if (!entry) return false;
|
|
66
|
+
if (
|
|
67
|
+
entry.summary === ELIDED_COMPACTION_SUMMARY &&
|
|
68
|
+
entry.shortSummary === ELIDED_COMPACTION_SHORT_SUMMARY &&
|
|
69
|
+
entry.preserveData === undefined
|
|
70
|
+
) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
entry.summary = ELIDED_COMPACTION_SUMMARY;
|
|
74
|
+
entry.shortSummary = ELIDED_COMPACTION_SHORT_SUMMARY;
|
|
75
|
+
entry.preserveData = undefined;
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function collectActiveBranchIds(entries: FileEntry[]): Set<string> {
|
|
80
|
+
const byId = new Map<string, SessionEntry>();
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
const id = (entry as SessionEntry).id;
|
|
83
|
+
if (typeof id === "string") byId.set(id, entry as SessionEntry);
|
|
84
|
+
}
|
|
85
|
+
const branchIds = new Set<string>();
|
|
86
|
+
let cursor = entries[entries.length - 1] as SessionEntry | undefined;
|
|
87
|
+
while (cursor && typeof cursor.id === "string" && !branchIds.has(cursor.id)) {
|
|
88
|
+
branchIds.add(cursor.id);
|
|
89
|
+
const parentId = cursor.parentId;
|
|
90
|
+
cursor = parentId ? byId.get(parentId) : undefined;
|
|
91
|
+
}
|
|
92
|
+
return branchIds;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function elideSupersededCompactionEntries(entries: FileEntry[]): void {
|
|
96
|
+
const branchIds = collectActiveBranchIds(entries);
|
|
97
|
+
let previousCompaction: CompactionEntry | undefined;
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
if (entry.type !== "compaction") continue;
|
|
100
|
+
if (!branchIds.has(entry.id)) continue;
|
|
101
|
+
elideCompactionSummary(previousCompaction);
|
|
102
|
+
previousCompaction = entry;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function loadEntriesFromFileStream(filePath: string): Promise<{
|
|
107
|
+
entries: FileEntry[];
|
|
108
|
+
titleSlot: SessionTitleUpdate | undefined;
|
|
109
|
+
}> {
|
|
110
|
+
const entries: FileEntry[] = [];
|
|
111
|
+
let titleSlot: SessionTitleUpdate | undefined;
|
|
112
|
+
let sawBodyLine = false;
|
|
113
|
+
const input = fs.createReadStream(filePath, { encoding: "utf8" });
|
|
114
|
+
const lines = readline.createInterface({ input, crlfDelay: Infinity });
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
for await (const rawLine of lines) {
|
|
118
|
+
const line = rawLine.trim();
|
|
119
|
+
if (!line) continue;
|
|
120
|
+
if (!sawBodyLine) {
|
|
121
|
+
const slot = parseTitleSlotLine(line);
|
|
122
|
+
if (slot) {
|
|
123
|
+
titleSlot = titleUpdateFromSlot(slot);
|
|
124
|
+
sawBodyLine = true;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
sawBodyLine = true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let entry: FileEntry;
|
|
131
|
+
try {
|
|
132
|
+
entry = JSON.parse(line) as FileEntry;
|
|
133
|
+
} catch {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
entries.push(entry);
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
input.destroy();
|
|
140
|
+
if (isEnoent(err)) return { entries: [], titleSlot: undefined };
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { entries: foldTitleSlot(entries, titleSlot), titleSlot };
|
|
145
|
+
}
|
|
146
|
+
|
|
57
147
|
/** Read only the fixed-size head window to detect a physical title slot. */
|
|
58
148
|
export async function readTitleSlotFromFile(
|
|
59
149
|
filePath: string,
|
|
@@ -80,14 +170,19 @@ export async function loadEntriesFromFile(
|
|
|
80
170
|
filePath: string,
|
|
81
171
|
storage: SessionStorage = new FileSessionStorage(),
|
|
82
172
|
): Promise<FileEntry[]> {
|
|
83
|
-
let
|
|
173
|
+
let loaded: { entries: FileEntry[]; titleSlot: SessionTitleUpdate | undefined };
|
|
84
174
|
try {
|
|
85
|
-
|
|
175
|
+
const stat = storage.statSync(filePath);
|
|
176
|
+
loaded =
|
|
177
|
+
storage instanceof FileSessionStorage && stat.size >= STREAM_LOAD_THRESHOLD_BYTES
|
|
178
|
+
? await loadEntriesFromFileStream(filePath)
|
|
179
|
+
: parseSessionContent(await storage.readText(filePath));
|
|
86
180
|
} catch (err) {
|
|
87
181
|
if (isEnoent(err)) return [];
|
|
88
182
|
throw err;
|
|
89
183
|
}
|
|
90
|
-
const { entries } =
|
|
184
|
+
const { entries } = loaded;
|
|
185
|
+
elideSupersededCompactionEntries(entries);
|
|
91
186
|
|
|
92
187
|
// Validate session header
|
|
93
188
|
if (entries.length === 0) return entries;
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
ImageContent,
|
|
5
|
+
Message,
|
|
6
|
+
MessageAttribution,
|
|
7
|
+
ServiceTierByFamily,
|
|
8
|
+
TextContent,
|
|
9
|
+
Usage,
|
|
10
|
+
} from "@oh-my-pi/pi-ai";
|
|
4
11
|
import {
|
|
5
12
|
directoryExists,
|
|
6
13
|
getBlobsDir,
|
|
@@ -66,6 +73,8 @@ import {
|
|
|
66
73
|
import { type SessionTitleUpdate, serializeTitleSlot } from "./session-title-slot";
|
|
67
74
|
|
|
68
75
|
const JSONL_SUFFIX_LENGTH = ".jsonl".length;
|
|
76
|
+
const SUPERSEDED_COMPACTION_SUMMARY = "[Superseded compaction summary elided after a newer compaction]";
|
|
77
|
+
const SUPERSEDED_COMPACTION_SHORT_SUMMARY = "Superseded compaction elided";
|
|
69
78
|
|
|
70
79
|
function mintSessionId(): string {
|
|
71
80
|
return Bun.randomUUIDv7();
|
|
@@ -502,6 +511,26 @@ export class SessionManager {
|
|
|
502
511
|
return this.#forceFileCreation || this.#fileIsCurrent || this.#historyContainsAssistantMessage();
|
|
503
512
|
}
|
|
504
513
|
|
|
514
|
+
#elideSupersededCompactionsOnBranch(leafId: string | null): boolean {
|
|
515
|
+
if (!leafId) return false;
|
|
516
|
+
let changed = false;
|
|
517
|
+
for (const entry of this.#index.pathTo(leafId)) {
|
|
518
|
+
if (entry.type !== "compaction") continue;
|
|
519
|
+
if (
|
|
520
|
+
entry.summary === SUPERSEDED_COMPACTION_SUMMARY &&
|
|
521
|
+
entry.shortSummary === SUPERSEDED_COMPACTION_SHORT_SUMMARY &&
|
|
522
|
+
entry.preserveData === undefined
|
|
523
|
+
) {
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
entry.summary = SUPERSEDED_COMPACTION_SUMMARY;
|
|
527
|
+
entry.shortSummary = SUPERSEDED_COMPACTION_SHORT_SUMMARY;
|
|
528
|
+
entry.preserveData = undefined;
|
|
529
|
+
changed = true;
|
|
530
|
+
}
|
|
531
|
+
return changed;
|
|
532
|
+
}
|
|
533
|
+
|
|
505
534
|
/**
|
|
506
535
|
* Synchronously rewrite the whole file (header + entries) and keep no open
|
|
507
536
|
* writer; the next append re-opens one. `writeTextSync` returns with the
|
|
@@ -1024,14 +1053,18 @@ export class SessionManager {
|
|
|
1024
1053
|
}
|
|
1025
1054
|
|
|
1026
1055
|
/**
|
|
1027
|
-
* Synchronously
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1030
|
-
* lands mid-`writeFileSync`.
|
|
1056
|
+
* Synchronously makes the current append-only session durable. Avoid rewriting
|
|
1057
|
+
* an already-current file: large restored sessions can contain GiB of compacted
|
|
1058
|
+
* history, and Ctrl+C must not rebuild the whole JSONL string just to flush.
|
|
1031
1059
|
*/
|
|
1032
1060
|
flushSync(): void {
|
|
1033
1061
|
if (!this.#persist || !this.#sessionFile) return;
|
|
1034
1062
|
if (this.#diskFailure) throw this.#diskFailure;
|
|
1063
|
+
if (this.#fileIsCurrent && !this.#rewriteRequired) {
|
|
1064
|
+
const writerError = this.#writer?.getError();
|
|
1065
|
+
if (writerError) throw writerError;
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1035
1068
|
this.#rewriteSynchronously();
|
|
1036
1069
|
if (this.#diskFailure) throw this.#diskFailure;
|
|
1037
1070
|
}
|
|
@@ -1260,7 +1293,7 @@ export class SessionManager {
|
|
|
1260
1293
|
return entry.id;
|
|
1261
1294
|
}
|
|
1262
1295
|
|
|
1263
|
-
appendServiceTierChange(serviceTier:
|
|
1296
|
+
appendServiceTierChange(serviceTier: ServiceTierByFamily | null): string {
|
|
1264
1297
|
const entry: ServiceTierChangeEntry = { type: "service_tier_change", ...this.#freshEntryFields(), serviceTier };
|
|
1265
1298
|
this.#recordEntry(entry);
|
|
1266
1299
|
return entry.id;
|
|
@@ -1305,6 +1338,7 @@ export class SessionManager {
|
|
|
1305
1338
|
fromExtension?: boolean,
|
|
1306
1339
|
preserveData?: Record<string, unknown>,
|
|
1307
1340
|
): string {
|
|
1341
|
+
const elidedSupersededCompactions = this.#elideSupersededCompactionsOnBranch(this.#index.leafId());
|
|
1308
1342
|
const entry: CompactionEntry<T> = {
|
|
1309
1343
|
type: "compaction",
|
|
1310
1344
|
...this.#freshEntryFields(),
|
|
@@ -1317,6 +1351,9 @@ export class SessionManager {
|
|
|
1317
1351
|
preserveData,
|
|
1318
1352
|
};
|
|
1319
1353
|
this.#recordEntry(entry);
|
|
1354
|
+
if (elidedSupersededCompactions) {
|
|
1355
|
+
void this.#rewriteAtomically().catch(err => this.#noteDiskFailure(err));
|
|
1356
|
+
}
|
|
1320
1357
|
return entry.id;
|
|
1321
1358
|
}
|
|
1322
1359
|
|
|
@@ -73,17 +73,9 @@ function refreshStatusLine(ctx: InteractiveModeContext): void {
|
|
|
73
73
|
ctx.ui.requestRender();
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
/** `/fast status` label: "
|
|
76
|
+
/** `/fast status` label for the active model: "on" when its family is priority, else "off". */
|
|
77
77
|
function formatFastModeStatus(session: AgentSession): string {
|
|
78
|
-
|
|
79
|
-
switch (session.serviceTier) {
|
|
80
|
-
case "openai-only":
|
|
81
|
-
return "on (OpenAI only)";
|
|
82
|
-
case "claude-only":
|
|
83
|
-
return "on (Claude only)";
|
|
84
|
-
default:
|
|
85
|
-
return "on";
|
|
86
|
-
}
|
|
78
|
+
return session.isFastModeEnabled() ? "on" : "off";
|
|
87
79
|
}
|
|
88
80
|
|
|
89
81
|
const AUTOCOMPLETE_DETAIL_LIMIT = 48;
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
$env,
|
|
4
|
+
isBunTestRuntime,
|
|
5
|
+
isCompiledBinary,
|
|
6
|
+
logger,
|
|
7
|
+
stripWindowsExtendedLengthPathPrefix,
|
|
8
|
+
workerHostEntry,
|
|
9
|
+
} from "@oh-my-pi/pi-utils";
|
|
3
10
|
import type { Subprocess } from "bun";
|
|
4
11
|
|
|
5
12
|
/**
|
|
@@ -90,13 +97,14 @@ export const SMOKE_TEST_TIMEOUT_MS = 30_000;
|
|
|
90
97
|
* embedding).
|
|
91
98
|
*/
|
|
92
99
|
export function resolveWorkerSpawnCmd(workerArg: string): WorkerSpawnCommand {
|
|
93
|
-
|
|
100
|
+
const executable = stripWindowsExtendedLengthPathPrefix(process.execPath);
|
|
101
|
+
if (isCompiledBinary()) return { cmd: [executable, workerArg] };
|
|
94
102
|
const hostEntry = workerHostEntry();
|
|
95
103
|
if (hostEntry) {
|
|
96
|
-
return { cmd: [
|
|
104
|
+
return { cmd: [executable, path.basename(hostEntry), workerArg], cwd: path.dirname(hostEntry) };
|
|
97
105
|
}
|
|
98
106
|
const packageRoot = path.resolve(import.meta.dir, "..", "..");
|
|
99
|
-
return { cmd: [
|
|
107
|
+
return { cmd: [executable, "src/cli.ts", workerArg], cwd: packageRoot };
|
|
100
108
|
}
|
|
101
109
|
|
|
102
110
|
/**
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
|
|
6
|
+
interface ProbeRunResult {
|
|
7
|
+
elapsedMs: number;
|
|
8
|
+
childElapsedMs: number;
|
|
9
|
+
cached: unknown;
|
|
10
|
+
count: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function runProbeScenario(options: {
|
|
14
|
+
runs: number;
|
|
15
|
+
sleepSeconds?: number;
|
|
16
|
+
holdStdoutOpen?: boolean;
|
|
17
|
+
descendantHoldsStdout?: boolean;
|
|
18
|
+
validOutput?: string;
|
|
19
|
+
}): Promise<ProbeRunResult> {
|
|
20
|
+
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-gpu-probe-"));
|
|
21
|
+
try {
|
|
22
|
+
const binDir = path.join(tempRoot, "bin");
|
|
23
|
+
const cacheRoot = path.join(tempRoot, "cache");
|
|
24
|
+
const probeCountPath = path.join(tempRoot, "probe-count");
|
|
25
|
+
await fs.mkdir(binDir, { recursive: true });
|
|
26
|
+
await fs.mkdir(path.join(cacheRoot, "omp"), { recursive: true });
|
|
27
|
+
const lspciPath = path.join(binDir, "lspci");
|
|
28
|
+
await Bun.write(
|
|
29
|
+
lspciPath,
|
|
30
|
+
'#!/usr/bin/env sh\nprintf x >> "$OMP_GPU_PROBE_COUNT"\nif [ -n "$OMP_GPU_PROBE_VALID_OUTPUT" ]; then printf "%s\\n" "$OMP_GPU_PROBE_VALID_OUTPUT"; fi\nif [ "$OMP_GPU_PROBE_DESCENDANT_HOLDS_STDOUT" = "true" ]; then sleep "$OMP_GPU_PROBE_SLEEP" & exit 0; fi\nif [ "$OMP_GPU_PROBE_HOLD_STDOUT_OPEN" = "true" ]; then sleep "$OMP_GPU_PROBE_SLEEP" & wait "$!"; fi\nif [ -n "$OMP_GPU_PROBE_SLEEP" ]; then exec sleep "$OMP_GPU_PROBE_SLEEP"; fi\nexit 0\n',
|
|
31
|
+
);
|
|
32
|
+
await fs.chmod(lspciPath, 0o755);
|
|
33
|
+
|
|
34
|
+
const scenarioPath = path.join(tempRoot, "scenario.ts");
|
|
35
|
+
await Bun.write(
|
|
36
|
+
scenarioPath,
|
|
37
|
+
`import { getGpuCachePath, refreshDirsFromEnv } from ${JSON.stringify(path.resolve(import.meta.dir, "../../utils/src/index.ts"))};
|
|
38
|
+
import { buildSystemPrompt } from ${JSON.stringify(path.join(import.meta.dir, "system-prompt.ts"))};
|
|
39
|
+
|
|
40
|
+
refreshDirsFromEnv();
|
|
41
|
+
const buildOptions = {
|
|
42
|
+
contextFiles: [],
|
|
43
|
+
skills: [],
|
|
44
|
+
toolNames: [],
|
|
45
|
+
workspaceTree: {
|
|
46
|
+
rootPath: process.cwd(),
|
|
47
|
+
rendered: "",
|
|
48
|
+
truncated: false,
|
|
49
|
+
totalLines: 0,
|
|
50
|
+
agentsMdFiles: [],
|
|
51
|
+
},
|
|
52
|
+
activeRepoContext: null,
|
|
53
|
+
};
|
|
54
|
+
const startedAt = performance.now();
|
|
55
|
+
for (let index = 0; index < Number(process.env.OMP_GPU_PROBE_RUNS ?? "1"); index += 1) {
|
|
56
|
+
await buildSystemPrompt(buildOptions);
|
|
57
|
+
}
|
|
58
|
+
const cacheFile = Bun.file(getGpuCachePath());
|
|
59
|
+
const cached = await cacheFile.exists() ? await cacheFile.json() : null;
|
|
60
|
+
const countFile = Bun.file(process.env.OMP_GPU_PROBE_COUNT ?? "");
|
|
61
|
+
const count = await countFile.exists() ? (await countFile.text()).length : 0;
|
|
62
|
+
console.log(JSON.stringify({ elapsedMs: Math.round(performance.now() - startedAt), cached, count }));
|
|
63
|
+
`,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const env: Record<string, string | undefined> = {
|
|
67
|
+
...process.env,
|
|
68
|
+
PATH: `${binDir}:${process.env.PATH ?? ""}`,
|
|
69
|
+
XDG_CACHE_HOME: cacheRoot,
|
|
70
|
+
OMP_GPU_PROBE_COUNT: probeCountPath,
|
|
71
|
+
OMP_GPU_PROBE_RUNS: String(options.runs),
|
|
72
|
+
};
|
|
73
|
+
// Strip inherited dirs-resolver overrides so XDG_CACHE_HOME above wins and
|
|
74
|
+
// the test cannot touch the developer/CI profile's real gpu_cache.json.
|
|
75
|
+
for (const key of ["PI_CODING_AGENT_DIR", "OMP_PROFILE", "PI_PROFILE", "PI_CONFIG_DIR"]) {
|
|
76
|
+
delete env[key];
|
|
77
|
+
}
|
|
78
|
+
if (options.sleepSeconds === undefined) {
|
|
79
|
+
delete env.OMP_GPU_PROBE_SLEEP;
|
|
80
|
+
} else {
|
|
81
|
+
env.OMP_GPU_PROBE_SLEEP = String(options.sleepSeconds);
|
|
82
|
+
}
|
|
83
|
+
if (options.holdStdoutOpen) {
|
|
84
|
+
env.OMP_GPU_PROBE_HOLD_STDOUT_OPEN = "true";
|
|
85
|
+
} else {
|
|
86
|
+
delete env.OMP_GPU_PROBE_HOLD_STDOUT_OPEN;
|
|
87
|
+
}
|
|
88
|
+
if (options.descendantHoldsStdout) {
|
|
89
|
+
env.OMP_GPU_PROBE_DESCENDANT_HOLDS_STDOUT = "true";
|
|
90
|
+
} else {
|
|
91
|
+
delete env.OMP_GPU_PROBE_DESCENDANT_HOLDS_STDOUT;
|
|
92
|
+
}
|
|
93
|
+
if (options.validOutput !== undefined) {
|
|
94
|
+
env.OMP_GPU_PROBE_VALID_OUTPUT = options.validOutput;
|
|
95
|
+
} else {
|
|
96
|
+
delete env.OMP_GPU_PROBE_VALID_OUTPUT;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const childStartedAt = performance.now();
|
|
100
|
+
const child = Bun.spawn([process.execPath, scenarioPath], { stdout: "pipe", stderr: "pipe", env });
|
|
101
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
102
|
+
new Response(child.stdout).text(),
|
|
103
|
+
new Response(child.stderr).text(),
|
|
104
|
+
child.exited,
|
|
105
|
+
]);
|
|
106
|
+
const childElapsedMs = Math.round(performance.now() - childStartedAt);
|
|
107
|
+
if (exitCode !== 0) {
|
|
108
|
+
throw new Error(`GPU probe scenario failed with exit ${exitCode}: ${stderr}`);
|
|
109
|
+
}
|
|
110
|
+
return { ...JSON.parse(stdout.trim()), childElapsedMs };
|
|
111
|
+
} finally {
|
|
112
|
+
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe.skipIf(process.platform !== "linux")("system prompt GPU probe", () => {
|
|
117
|
+
it("caches empty GPU probe results", async () => {
|
|
118
|
+
const result = await runProbeScenario({ runs: 2 });
|
|
119
|
+
|
|
120
|
+
expect(result.cached).toEqual({ gpu: null });
|
|
121
|
+
expect(result.count).toBe(1);
|
|
122
|
+
}, 15_000);
|
|
123
|
+
|
|
124
|
+
it("kills the GPU probe at the prep deadline", async () => {
|
|
125
|
+
const result = await runProbeScenario({ runs: 1, sleepSeconds: 7, holdStdoutOpen: true });
|
|
126
|
+
|
|
127
|
+
expect(result.cached).toEqual({ gpu: null });
|
|
128
|
+
expect(result.elapsedMs).toBeLessThan(6500);
|
|
129
|
+
// Codex#3838: the child process MUST exit shortly after the deadline,
|
|
130
|
+
// not linger until a descendant holding stdout (sleep 7) exits on its own.
|
|
131
|
+
expect(result.childElapsedMs).toBeLessThan(6500);
|
|
132
|
+
}, 15_000);
|
|
133
|
+
|
|
134
|
+
it("does not wait on stdout held by a descendant after a successful probe", async () => {
|
|
135
|
+
const result = await runProbeScenario({ runs: 1, sleepSeconds: 3, descendantHoldsStdout: true });
|
|
136
|
+
|
|
137
|
+
expect(result.cached).toEqual({ gpu: null });
|
|
138
|
+
// Probe exits 0 immediately but leaves a backgrounded sleep holding the stdout
|
|
139
|
+
// pipe. The success path MUST bound the drain wait, not block until sleep exits.
|
|
140
|
+
expect(result.elapsedMs).toBeLessThan(2000);
|
|
141
|
+
expect(result.childElapsedMs).toBeLessThan(2000);
|
|
142
|
+
}, 15_000);
|
|
143
|
+
|
|
144
|
+
it("keeps probe output captured before a descendant delays EOF", async () => {
|
|
145
|
+
const result = await runProbeScenario({
|
|
146
|
+
runs: 1,
|
|
147
|
+
sleepSeconds: 3,
|
|
148
|
+
descendantHoldsStdout: true,
|
|
149
|
+
validOutput: "00:02.0 VGA compatible controller: NVIDIA TestGPU",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Probe exited 0 with valid output before bg sleep held stdout open.
|
|
153
|
+
// Captured stdout MUST be cached, not discarded as if the probe failed.
|
|
154
|
+
expect(result.cached).toEqual({ gpu: "02.0 VGA compatible controller: NVIDIA TestGPU" });
|
|
155
|
+
expect(result.elapsedMs).toBeLessThan(2000);
|
|
156
|
+
expect(result.childElapsedMs).toBeLessThan(2000);
|
|
157
|
+
}, 15_000);
|
|
158
|
+
});
|
package/src/system-prompt.ts
CHANGED
|
@@ -7,7 +7,6 @@ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
|
7
7
|
import type { ToolExample, TSchema } from "@oh-my-pi/pi-ai";
|
|
8
8
|
import { renderToolInventory } from "@oh-my-pi/pi-ai/dialect";
|
|
9
9
|
import { $env, getGpuCachePath, getProjectDir, hasFsCode, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
|
|
10
|
-
import { $ } from "bun";
|
|
11
10
|
import { contextFileCapability } from "./capability/context-file";
|
|
12
11
|
import { systemPromptCapability } from "./capability/system-prompt";
|
|
13
12
|
import { findConfigFile } from "./config";
|
|
@@ -112,21 +111,60 @@ function parseWmicTable(output: string, header: string): string | null {
|
|
|
112
111
|
}
|
|
113
112
|
|
|
114
113
|
const SYSTEM_PROMPT_PREP_TIMEOUT_MS = 5000;
|
|
114
|
+
/** Kept below prep timeout so timed-out probes can still write the null cache before fallback. */
|
|
115
|
+
const GPU_PROBE_TIMEOUT_MS = SYSTEM_PROMPT_PREP_TIMEOUT_MS - 500;
|
|
116
|
+
/** Drop stdout from a probe descendant that inherited the pipe after the probe exited. */
|
|
117
|
+
const GPU_PROBE_STDOUT_DRAIN_MS = 250;
|
|
118
|
+
|
|
119
|
+
async function runGpuProbe(cmd: string[]): Promise<string | null> {
|
|
120
|
+
try {
|
|
121
|
+
const proc = Bun.spawn({
|
|
122
|
+
cmd,
|
|
123
|
+
stdout: "pipe",
|
|
124
|
+
stderr: "ignore",
|
|
125
|
+
stdin: "ignore",
|
|
126
|
+
timeout: GPU_PROBE_TIMEOUT_MS,
|
|
127
|
+
// SIGKILL so a probe ignoring SIGTERM (PATH wrapper, wedged WMI) still
|
|
128
|
+
// dies at the deadline and lets getCachedGpu reach the null-cache write.
|
|
129
|
+
killSignal: "SIGKILL",
|
|
130
|
+
});
|
|
131
|
+
const stdoutReader = proc.stdout.getReader();
|
|
132
|
+
let stdout = "";
|
|
133
|
+
const decoder = new TextDecoder();
|
|
134
|
+
const stdoutDone = (async () => {
|
|
135
|
+
while (true) {
|
|
136
|
+
const chunk = await stdoutReader.read();
|
|
137
|
+
if (chunk.done) break;
|
|
138
|
+
stdout += decoder.decode(chunk.value, { stream: true });
|
|
139
|
+
}
|
|
140
|
+
stdout += decoder.decode();
|
|
141
|
+
})();
|
|
142
|
+
const exitCode = await proc.exited;
|
|
143
|
+
// Even on exit 0, a probe wrapper can leave a descendant holding stdout open.
|
|
144
|
+
// Bound the EOF wait so getCachedGpu cannot outlive the probe in either path;
|
|
145
|
+
// keep whatever bytes the reader already captured before cancelling.
|
|
146
|
+
const drained = await Promise.race([
|
|
147
|
+
stdoutDone.then(() => "ok" as const).catch(() => "err" as const),
|
|
148
|
+
Bun.sleep(GPU_PROBE_STDOUT_DRAIN_MS).then(() => "timeout" as const),
|
|
149
|
+
]);
|
|
150
|
+
if (drained !== "ok") {
|
|
151
|
+
await stdoutReader.cancel().catch(() => undefined);
|
|
152
|
+
await stdoutDone.catch(() => undefined);
|
|
153
|
+
}
|
|
154
|
+
return exitCode === 0 ? stdout : null;
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
115
159
|
|
|
116
160
|
async function getGpuModel(): Promise<string | null> {
|
|
117
161
|
switch (process.platform) {
|
|
118
162
|
case "win32": {
|
|
119
|
-
const output = await
|
|
120
|
-
.quiet()
|
|
121
|
-
.text()
|
|
122
|
-
.catch(() => null);
|
|
163
|
+
const output = await runGpuProbe(["wmic", "path", "win32_VideoController", "get", "name"]);
|
|
123
164
|
return output ? parseWmicTable(output, "Name") : null;
|
|
124
165
|
}
|
|
125
166
|
case "linux": {
|
|
126
|
-
const output = await
|
|
127
|
-
.quiet()
|
|
128
|
-
.text()
|
|
129
|
-
.catch(() => null);
|
|
167
|
+
const output = await runGpuProbe(["lspci"]);
|
|
130
168
|
if (!output) return null;
|
|
131
169
|
const gpus: Array<{ name: string; priority: number }> = [];
|
|
132
170
|
for (const line of output.split("\n")) {
|
|
@@ -176,20 +214,20 @@ function getTerminalName(): string | undefined {
|
|
|
176
214
|
return term ?? undefined;
|
|
177
215
|
}
|
|
178
216
|
|
|
179
|
-
/** Cached
|
|
217
|
+
/** Cached GPU probe result. */
|
|
180
218
|
interface GpuCache {
|
|
181
|
-
gpu: string;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function getSystemInfoCachePath(): string {
|
|
185
|
-
return getGpuCachePath();
|
|
219
|
+
gpu: string | null;
|
|
186
220
|
}
|
|
187
221
|
|
|
188
222
|
async function loadGpuCache(): Promise<GpuCache | null> {
|
|
189
223
|
try {
|
|
190
|
-
const cachePath =
|
|
224
|
+
const cachePath = getGpuCachePath();
|
|
191
225
|
const content = await Bun.file(cachePath).json();
|
|
192
|
-
|
|
226
|
+
if (content && typeof content === "object" && "gpu" in content) {
|
|
227
|
+
const gpu = content.gpu;
|
|
228
|
+
return { gpu: typeof gpu === "string" ? gpu : null };
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
193
231
|
} catch {
|
|
194
232
|
return null;
|
|
195
233
|
}
|
|
@@ -197,7 +235,7 @@ async function loadGpuCache(): Promise<GpuCache | null> {
|
|
|
197
235
|
|
|
198
236
|
async function saveGpuCache(info: GpuCache): Promise<void> {
|
|
199
237
|
try {
|
|
200
|
-
const cachePath =
|
|
238
|
+
const cachePath = getGpuCachePath();
|
|
201
239
|
await Bun.write(cachePath, JSON.stringify(info, null, "\t"));
|
|
202
240
|
} catch {
|
|
203
241
|
// Silently ignore cache write failures
|
|
@@ -206,15 +244,12 @@ async function saveGpuCache(info: GpuCache): Promise<void> {
|
|
|
206
244
|
|
|
207
245
|
async function getCachedGpu(): Promise<string | undefined> {
|
|
208
246
|
const cached = await logger.time("getCachedGpu:loadGpuCache", loadGpuCache);
|
|
209
|
-
if (cached) return cached.gpu;
|
|
247
|
+
if (cached) return cached.gpu ?? undefined;
|
|
210
248
|
const gpu = await logger.time("getCachedGpu:getGpuModel", getGpuModel);
|
|
211
|
-
|
|
212
|
-
await logger.time("getCachedGpu:saveGpuCache", saveGpuCache, { gpu });
|
|
213
|
-
}
|
|
249
|
+
await logger.time("getCachedGpu:saveGpuCache", saveGpuCache, { gpu });
|
|
214
250
|
return gpu ?? undefined;
|
|
215
251
|
}
|
|
216
|
-
|
|
217
|
-
const gpu = await getCachedGpu();
|
|
252
|
+
function getEnvironmentInfo(gpu: string | undefined): Array<{ label: string; value: string }> {
|
|
218
253
|
let cpuModel: string | undefined;
|
|
219
254
|
try {
|
|
220
255
|
cpuModel = os.cpus()[0]?.model;
|
|
@@ -500,9 +535,13 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
500
535
|
agentsMdFiles: [],
|
|
501
536
|
} satisfies WorkspaceTree,
|
|
502
537
|
activeRepoContext: null as ActiveRepoContext | null,
|
|
538
|
+
gpu: undefined as string | undefined,
|
|
503
539
|
};
|
|
504
540
|
|
|
505
|
-
const deadline =
|
|
541
|
+
const { promise: deadline, resolve: fireDeadline } = Promise.withResolvers<"__timeout__">();
|
|
542
|
+
const deadlineTimer = setTimeout(() => fireDeadline("__timeout__"), SYSTEM_PROMPT_PREP_TIMEOUT_MS);
|
|
543
|
+
// Unref so a fast prep does not hold a one-shot CLI alive waiting for this timer.
|
|
544
|
+
deadlineTimer.unref();
|
|
506
545
|
const timedOut: string[] = [];
|
|
507
546
|
const failed: Array<{ name: string; error: unknown }> = [];
|
|
508
547
|
|
|
@@ -566,6 +605,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
566
605
|
providedActiveRepoContext !== undefined
|
|
567
606
|
? Promise.resolve(providedActiveRepoContext)
|
|
568
607
|
: logger.time("resolveActiveRepoContext", () => resolveActiveRepoContext(resolvedCwd));
|
|
608
|
+
const gpuPromise = logger.time("getCachedGpu", getCachedGpu);
|
|
569
609
|
|
|
570
610
|
const [
|
|
571
611
|
resolvedCustomPrompt,
|
|
@@ -575,6 +615,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
575
615
|
skills,
|
|
576
616
|
workspaceTree,
|
|
577
617
|
activeRepoContext,
|
|
618
|
+
gpu,
|
|
578
619
|
] = await Promise.all([
|
|
579
620
|
withDeadline(
|
|
580
621
|
"customPrompt",
|
|
@@ -597,7 +638,9 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
597
638
|
withDeadline("loadSkills", skillsPromise, prepDefaults.skills),
|
|
598
639
|
withDeadline("buildWorkspaceTree", workspaceTreePromise, prepDefaults.workspaceTree),
|
|
599
640
|
withDeadline("resolveActiveRepoContext", activeRepoContextPromise, prepDefaults.activeRepoContext),
|
|
641
|
+
withDeadline("getCachedGpu", gpuPromise, prepDefaults.gpu),
|
|
600
642
|
]);
|
|
643
|
+
clearTimeout(deadlineTimer);
|
|
601
644
|
const agentsMdFiles = Array.from(new Set(workspaceTree.agentsMdFiles)).sort().slice(0, AGENTS_MD_LIMIT);
|
|
602
645
|
|
|
603
646
|
if (timedOut.length > 0) {
|
|
@@ -675,7 +718,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
675
718
|
];
|
|
676
719
|
const injectedAlwaysApplyRules = dedupeAlwaysApplyRules(alwaysApplyRules, promptSources);
|
|
677
720
|
|
|
678
|
-
const environment =
|
|
721
|
+
const environment = getEnvironmentInfo(gpu);
|
|
679
722
|
const data = {
|
|
680
723
|
systemPromptCustomization: effectiveSystemPromptCustomization,
|
|
681
724
|
customPrompt: resolvedCustomPrompt,
|