@oh-my-pi/pi-coding-agent 17.1.6 → 17.1.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 +23 -0
- package/dist/{CHANGELOG-x9zt79k8.md → CHANGELOG-5k4dq4g6.md} +23 -0
- package/dist/cli.js +3149 -3092
- package/dist/types/config/settings-schema.d.ts +16 -1
- package/dist/types/cursor.d.ts +2 -1
- package/dist/types/extensibility/extensions/runner.d.ts +4 -0
- package/dist/types/extensibility/shared-events.d.ts +18 -1
- package/dist/types/modes/components/custom-editor.d.ts +5 -0
- package/dist/types/session/agent-session-types.d.ts +5 -5
- package/dist/types/session/agent-session.d.ts +26 -1
- package/dist/types/session/model-controls.d.ts +1 -1
- package/dist/types/session/session-tools.d.ts +44 -6
- package/dist/types/session/streaming-output.d.ts +7 -2
- package/dist/types/session/turn-recovery.d.ts +1 -1
- package/dist/types/tools/index.d.ts +8 -3
- package/dist/types/tools/output-meta.d.ts +5 -0
- package/dist/types/tools/read.d.ts +9 -1
- package/dist/types/tools/xdev.d.ts +53 -67
- package/dist/types/utils/cpuprofile.d.ts +51 -0
- package/dist/types/utils/inspect-image-mode.d.ts +29 -0
- package/dist/types/utils/profile-tree.d.ts +47 -0
- package/dist/types/utils/sample-profile.d.ts +67 -0
- package/package.json +12 -12
- package/src/config/settings-schema.ts +15 -1
- package/src/config/settings.ts +35 -0
- package/src/cursor.ts +4 -3
- package/src/discovery/builtin-rules/index.ts +2 -0
- package/src/discovery/builtin-rules/ts-no-local-is-record.md +48 -0
- package/src/extensibility/extensions/runner.ts +26 -0
- package/src/extensibility/extensions/wrapper.ts +74 -42
- package/src/extensibility/hooks/tool-wrapper.ts +11 -4
- package/src/extensibility/shared-events.ts +18 -1
- package/src/modes/components/custom-editor.ts +39 -16
- package/src/modes/components/tips.txt +2 -1
- package/src/modes/components/tool-execution.ts +8 -7
- package/src/modes/controllers/extension-ui-controller.ts +4 -4
- package/src/modes/controllers/selector-controller.ts +7 -2
- package/src/sdk.ts +47 -50
- package/src/session/agent-session-types.ts +5 -5
- package/src/session/agent-session.ts +123 -7
- package/src/session/model-controls.ts +5 -5
- package/src/session/session-listing.ts +66 -4
- package/src/session/session-tools.ts +158 -52
- package/src/session/streaming-output.ts +18 -6
- package/src/session/turn-recovery.ts +4 -4
- package/src/slash-commands/builtin-registry.ts +69 -0
- package/src/tools/bash.ts +16 -9
- package/src/tools/index.ts +36 -16
- package/src/tools/output-meta.ts +20 -0
- package/src/tools/read.ts +87 -13
- package/src/tools/write.ts +16 -7
- package/src/tools/xdev.ts +198 -210
- package/src/utils/cpuprofile.ts +235 -0
- package/src/utils/inspect-image-mode.ts +39 -0
- package/src/utils/profile-tree.ts +111 -0
- package/src/utils/sample-profile.ts +437 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser and renderer for macOS `/usr/bin/sample` call-tree reports
|
|
3
|
+
* (conventionally saved as `*.sample.txt`).
|
|
4
|
+
*
|
|
5
|
+
* The raw report is a 10k+ line ASCII call tree with mangled symbols —
|
|
6
|
+
* expensive for an agent to digest. `renderSampleProfile` converts it into a
|
|
7
|
+
* compact bottleneck summary:
|
|
8
|
+
*
|
|
9
|
+
* - per-thread hot paths, pruned to frames with meaningful on-CPU samples,
|
|
10
|
+
* with pass-through chains collapsed and direct recursion flattened
|
|
11
|
+
* - blocked/idle threads reduced to a one-line classification
|
|
12
|
+
* - a process-wide "top functions by self samples" table
|
|
13
|
+
* - Rust v0 and legacy symbols demangled (best-effort, path extraction)
|
|
14
|
+
*
|
|
15
|
+
* Consumed by the read tool: `*.sample.txt` reads show the summary, `:raw`
|
|
16
|
+
* returns the original bytes.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { formatPct, mergeInto, type ProfileNode, type RenderTreeContext, renderProfileNode } from "./profile-tree";
|
|
20
|
+
|
|
21
|
+
/** Matches paths the read tool should treat as macOS sample reports. */
|
|
22
|
+
export function isSampleProfilePath(filePath: string): boolean {
|
|
23
|
+
return /\.sample\.txt$/i.test(filePath);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Leaf symbols that mean "thread is parked in the kernel", not burning CPU. */
|
|
27
|
+
const WAIT_SYMBOLS: Record<string, true> = {
|
|
28
|
+
__accept: true,
|
|
29
|
+
__psynch_cvwait: true,
|
|
30
|
+
__psynch_mutexwait: true,
|
|
31
|
+
__psynch_rw_rdlock: true,
|
|
32
|
+
__psynch_rw_wrlock: true,
|
|
33
|
+
__recvfrom: true,
|
|
34
|
+
__select: true,
|
|
35
|
+
__semwait_signal: true,
|
|
36
|
+
__sigwait: true,
|
|
37
|
+
__ulock_wait: true,
|
|
38
|
+
__ulock_wait2: true,
|
|
39
|
+
__wait4: true,
|
|
40
|
+
__workq_kernreturn: true,
|
|
41
|
+
kevent: true,
|
|
42
|
+
kevent64: true,
|
|
43
|
+
kevent_qos: true,
|
|
44
|
+
mach_msg2_trap: true,
|
|
45
|
+
mach_msg_trap: true,
|
|
46
|
+
poll: true,
|
|
47
|
+
semaphore_timedwait_trap: true,
|
|
48
|
+
semaphore_wait_trap: true,
|
|
49
|
+
start_wqthread: true,
|
|
50
|
+
swtch_pri: true,
|
|
51
|
+
thread_suspend: true,
|
|
52
|
+
usleep: true,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** One frame in the sampled call tree. Counts are sample hits (subtree total). */
|
|
56
|
+
export interface SampleFrame {
|
|
57
|
+
count: number;
|
|
58
|
+
symbol: string;
|
|
59
|
+
module?: string;
|
|
60
|
+
children: SampleFrame[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One sampled thread: `Thread_<id>` root plus its call tree. */
|
|
64
|
+
export interface SampleThread {
|
|
65
|
+
id: string;
|
|
66
|
+
name?: string;
|
|
67
|
+
total: number;
|
|
68
|
+
roots: SampleFrame[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Metadata from the report preamble (everything before `Call graph:`). */
|
|
72
|
+
export interface SampleProfileHeader {
|
|
73
|
+
process: string;
|
|
74
|
+
pid: number;
|
|
75
|
+
intervalMs: number;
|
|
76
|
+
path?: string;
|
|
77
|
+
codeType?: string;
|
|
78
|
+
osVersion?: string;
|
|
79
|
+
footprint?: string;
|
|
80
|
+
footprintPeak?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Parsed macOS sample report. */
|
|
84
|
+
export interface SampleProfile {
|
|
85
|
+
header: SampleProfileHeader;
|
|
86
|
+
threads: SampleThread[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ANALYSIS_RE = /^Analysis of sampling (.+?) \(pid (\d+)\) every (\d+) milliseconds?/;
|
|
90
|
+
const THREAD_RE = /^Thread_([^\s:]+):?\s*(.*)$/;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Parse a macOS `sample` report. Returns null when the text does not look
|
|
94
|
+
* like sample output (missing analysis preamble or `Call graph:` section).
|
|
95
|
+
*/
|
|
96
|
+
export function parseSampleProfile(text: string): SampleProfile | null {
|
|
97
|
+
const lines = text.split("\n");
|
|
98
|
+
const analysis = ANALYSIS_RE.exec(lines[0] ?? "");
|
|
99
|
+
if (!analysis) return null;
|
|
100
|
+
const callGraphIx = lines.indexOf("Call graph:");
|
|
101
|
+
if (callGraphIx === -1) return null;
|
|
102
|
+
|
|
103
|
+
const header: SampleProfileHeader = {
|
|
104
|
+
process: analysis[1],
|
|
105
|
+
pid: Number(analysis[2]),
|
|
106
|
+
intervalMs: Number(analysis[3]),
|
|
107
|
+
};
|
|
108
|
+
for (const line of lines.slice(1, callGraphIx)) {
|
|
109
|
+
const kv = /^([A-Za-z/ ()]+?):\s+(.*)$/.exec(line);
|
|
110
|
+
if (!kv) continue;
|
|
111
|
+
const value = kv[2].trim();
|
|
112
|
+
switch (kv[1]) {
|
|
113
|
+
case "Path":
|
|
114
|
+
header.path = value;
|
|
115
|
+
break;
|
|
116
|
+
case "Code Type":
|
|
117
|
+
header.codeType = value;
|
|
118
|
+
break;
|
|
119
|
+
case "OS Version":
|
|
120
|
+
header.osVersion = value;
|
|
121
|
+
break;
|
|
122
|
+
case "Physical footprint":
|
|
123
|
+
header.footprint = value;
|
|
124
|
+
break;
|
|
125
|
+
case "Physical footprint (peak)":
|
|
126
|
+
header.footprintPeak = value;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const threads: SampleThread[] = [];
|
|
132
|
+
let thread: SampleThread | undefined;
|
|
133
|
+
// Stack of (depth, frame) for the current thread; depth 1 = thread root frame.
|
|
134
|
+
const stack: Array<{ depth: number; frame: SampleFrame }> = [];
|
|
135
|
+
|
|
136
|
+
for (let ix = callGraphIx + 1; ix < lines.length; ix++) {
|
|
137
|
+
const line = lines[ix];
|
|
138
|
+
if (/^(Total number in stack|Sort by top of stack|Binary Images:)/.test(line)) break;
|
|
139
|
+
if (!line.startsWith(" ")) continue;
|
|
140
|
+
const body = line.slice(4);
|
|
141
|
+
// Decorators are one char + one space per tree level (`+ ! : | `), so the
|
|
142
|
+
// sample count starts at an even offset; scan pairs until the first digit.
|
|
143
|
+
let p = 0;
|
|
144
|
+
while (p < body.length && (body[p] < "0" || body[p] > "9")) p += 2;
|
|
145
|
+
if (p >= body.length) continue;
|
|
146
|
+
const depth = p / 2;
|
|
147
|
+
const rest = /^(\d+)\s+(.*)$/.exec(body.slice(p));
|
|
148
|
+
if (!rest) continue;
|
|
149
|
+
const count = Number(rest[1]);
|
|
150
|
+
const frameText = rest[2];
|
|
151
|
+
|
|
152
|
+
if (depth === 0) {
|
|
153
|
+
const tm = THREAD_RE.exec(frameText);
|
|
154
|
+
if (!tm) continue;
|
|
155
|
+
const name = tm[2].trim().replace(/\s+/g, " ");
|
|
156
|
+
thread = { id: tm[1], name: name || undefined, total: count, roots: [] };
|
|
157
|
+
threads.push(thread);
|
|
158
|
+
stack.length = 0;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (!thread) continue;
|
|
162
|
+
const frame: SampleFrame = { count, ...parseFrameText(frameText), children: [] };
|
|
163
|
+
while (stack.length > 0 && stack[stack.length - 1].depth >= depth) stack.pop();
|
|
164
|
+
const siblings = stack.length > 0 ? stack[stack.length - 1].frame.children : thread.roots;
|
|
165
|
+
siblings.push(frame);
|
|
166
|
+
stack.push({ depth, frame });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (threads.length === 0) return null;
|
|
170
|
+
return { header, threads };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Split `symbol (in module) + offsets [addrs]` into symbol + module. */
|
|
174
|
+
function parseFrameText(text: string): { symbol: string; module?: string } {
|
|
175
|
+
let s = text;
|
|
176
|
+
const addrIx = s.lastIndexOf(" [");
|
|
177
|
+
if (addrIx !== -1 && s.endsWith("]")) s = s.slice(0, addrIx);
|
|
178
|
+
s = s.replace(/ \+ [0-9][0-9,.]*$/, "");
|
|
179
|
+
const modIx = s.lastIndexOf(" (in ");
|
|
180
|
+
if (modIx !== -1 && s.endsWith(")")) {
|
|
181
|
+
return { symbol: s.slice(0, modIx).trim(), module: s.slice(modIx + 6, -1) };
|
|
182
|
+
}
|
|
183
|
+
return { symbol: s.trim() };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Demangling
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
const LEGACY_ESCAPES: ReadonlyArray<[string, string]> = [
|
|
191
|
+
["$LT$", "<"],
|
|
192
|
+
["$GT$", ">"],
|
|
193
|
+
["$RF$", "&"],
|
|
194
|
+
["$BP$", "*"],
|
|
195
|
+
["$C$", ","],
|
|
196
|
+
["$u20$", " "],
|
|
197
|
+
["$u27$", "'"],
|
|
198
|
+
["$u7b$", "{"],
|
|
199
|
+
["$u7d$", "}"],
|
|
200
|
+
["..", "::"],
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
const IDENT_RE = /^[A-Za-z0-9_.$]+$/;
|
|
204
|
+
const LEGACY_HASH_RE = /^h[0-9a-f]{16}$/;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Best-effort demangle of Rust symbols (v0 `_R…` and legacy `_ZN…E`).
|
|
208
|
+
* For v0 this is a path extractor, not a full demangler: identifiers are
|
|
209
|
+
* pulled out in order and joined with `::`, so generic arguments appear as
|
|
210
|
+
* extra path segments. Non-Rust symbols pass through unchanged.
|
|
211
|
+
*/
|
|
212
|
+
export function demangleSymbol(raw: string): string {
|
|
213
|
+
if (raw.startsWith("_R")) return demangleV0(raw) ?? raw;
|
|
214
|
+
const legacy = /^_?_ZN(.*)$/.exec(raw);
|
|
215
|
+
if (legacy) return demangleLegacy(legacy[1]) ?? raw;
|
|
216
|
+
return raw;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function demangleV0(raw: string): string | null {
|
|
220
|
+
const s = raw.slice(2);
|
|
221
|
+
const parts: string[] = [];
|
|
222
|
+
let i = 0;
|
|
223
|
+
while (i < s.length) {
|
|
224
|
+
const ch = s[i];
|
|
225
|
+
if (ch >= "1" && ch <= "9") {
|
|
226
|
+
let j = i;
|
|
227
|
+
while (j < s.length && s[j] >= "0" && s[j] <= "9") j++;
|
|
228
|
+
const len = Number(s.slice(i, j));
|
|
229
|
+
// v0 inserts a `_` separator when the identifier starts with a digit or `_`.
|
|
230
|
+
let k = j;
|
|
231
|
+
if (s[k] === "_") k++;
|
|
232
|
+
const ident = s.slice(k, k + len);
|
|
233
|
+
if (ident.length === len && IDENT_RE.test(ident)) {
|
|
234
|
+
parts.push(ident);
|
|
235
|
+
i = k + len;
|
|
236
|
+
} else {
|
|
237
|
+
i = j;
|
|
238
|
+
}
|
|
239
|
+
} else if (ch === "s" || ch === "B") {
|
|
240
|
+
// Disambiguator (`s<base62>_`) or backref (`B<base62>_`): skip.
|
|
241
|
+
const m = /^[sB][0-9a-zA-Z]*_/.exec(s.slice(i));
|
|
242
|
+
i += m ? m[0].length : 1;
|
|
243
|
+
} else {
|
|
244
|
+
i++;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return parts.length > 0 ? parts.join("::") : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function demangleLegacy(s: string): string | null {
|
|
251
|
+
const parts: string[] = [];
|
|
252
|
+
let i = 0;
|
|
253
|
+
while (i < s.length && s[i] >= "0" && s[i] <= "9") {
|
|
254
|
+
let j = i;
|
|
255
|
+
while (j < s.length && s[j] >= "0" && s[j] <= "9") j++;
|
|
256
|
+
const len = Number(s.slice(i, j));
|
|
257
|
+
const ident = s.slice(j, j + len);
|
|
258
|
+
if (ident.length !== len) break;
|
|
259
|
+
if (!LEGACY_HASH_RE.test(ident)) parts.push(unescapeLegacy(ident));
|
|
260
|
+
i = j + len;
|
|
261
|
+
}
|
|
262
|
+
return parts.length > 0 ? parts.join("::") : null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function unescapeLegacy(ident: string): string {
|
|
266
|
+
let out = ident;
|
|
267
|
+
for (const [from, to] of LEGACY_ESCAPES) out = out.replaceAll(from, to);
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Rendering
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
/** Fraction of a thread's on-CPU samples a subtree needs to stay visible. */
|
|
276
|
+
const PRUNE_FRACTION = 0.02;
|
|
277
|
+
/** Threads with fewer on-CPU samples than this share of their total are "idle". */
|
|
278
|
+
const IDLE_FRACTION = 0.002;
|
|
279
|
+
const IDLE_MIN_SAMPLES = 10;
|
|
280
|
+
const TOP_FUNCTIONS = 20;
|
|
281
|
+
|
|
282
|
+
function selfOf(frame: SampleFrame): number {
|
|
283
|
+
let children = 0;
|
|
284
|
+
for (const child of frame.children) children += child.count;
|
|
285
|
+
return Math.max(0, frame.count - children);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Build a display node for a frame subtree: demangled label, on-CPU value
|
|
290
|
+
* (self samples of non-wait frames), same-symbol siblings merged (`sample`
|
|
291
|
+
* splits them by call-site offset, which only fragments hot totals).
|
|
292
|
+
*/
|
|
293
|
+
function buildProfileNode(frame: SampleFrame, demangleCache: Map<string, string>, mainModule: string): ProfileNode {
|
|
294
|
+
let symbol = demangleCache.get(frame.symbol);
|
|
295
|
+
if (symbol === undefined) {
|
|
296
|
+
symbol = demangleSymbol(frame.symbol);
|
|
297
|
+
demangleCache.set(frame.symbol, symbol);
|
|
298
|
+
}
|
|
299
|
+
const children: ProfileNode[] = [];
|
|
300
|
+
for (const rawChild of frame.children) {
|
|
301
|
+
const child = buildProfileNode(rawChild, demangleCache, mainModule);
|
|
302
|
+
const existing = children.find(c => c.key === child.key);
|
|
303
|
+
if (existing) mergeInto(existing, child);
|
|
304
|
+
else children.push(child);
|
|
305
|
+
}
|
|
306
|
+
let cpu = WAIT_SYMBOLS[frame.symbol] ? 0 : selfOf(frame);
|
|
307
|
+
for (const child of children) cpu += child.value;
|
|
308
|
+
const label = frame.module && frame.module !== mainModule ? `${symbol} (${frame.module})` : symbol;
|
|
309
|
+
return { key: symbol, label, value: cpu, recursion: 0, children };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Aggregate on-CPU self samples per demangled symbol across all threads. */
|
|
313
|
+
function aggregateSelf(
|
|
314
|
+
profile: SampleProfile,
|
|
315
|
+
demangleCache: Map<string, string>,
|
|
316
|
+
): Map<string, { cpu: number; module?: string }> {
|
|
317
|
+
const totals = new Map<string, { cpu: number; module?: string }>();
|
|
318
|
+
const visit = (frame: SampleFrame): void => {
|
|
319
|
+
if (!WAIT_SYMBOLS[frame.symbol]) {
|
|
320
|
+
const self = selfOf(frame);
|
|
321
|
+
if (self > 0) {
|
|
322
|
+
let symbol = demangleCache.get(frame.symbol);
|
|
323
|
+
if (symbol === undefined) {
|
|
324
|
+
symbol = demangleSymbol(frame.symbol);
|
|
325
|
+
demangleCache.set(frame.symbol, symbol);
|
|
326
|
+
}
|
|
327
|
+
const entry = totals.get(symbol);
|
|
328
|
+
if (entry) entry.cpu += self;
|
|
329
|
+
else totals.set(symbol, { cpu: self, module: frame.module });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
for (const child of frame.children) visit(child);
|
|
333
|
+
};
|
|
334
|
+
for (const thread of profile.threads) for (const root of thread.roots) visit(root);
|
|
335
|
+
return totals;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Dominant wait leaf of an idle thread ("what is it blocked on"). */
|
|
339
|
+
function dominantWait(thread: SampleThread): string | undefined {
|
|
340
|
+
let best: { symbol: string; count: number } | undefined;
|
|
341
|
+
const visit = (frame: SampleFrame): void => {
|
|
342
|
+
if (frame.children.length === 0 && WAIT_SYMBOLS[frame.symbol]) {
|
|
343
|
+
if (!best || frame.count > best.count) best = { symbol: frame.symbol, count: frame.count };
|
|
344
|
+
}
|
|
345
|
+
for (const child of frame.children) visit(child);
|
|
346
|
+
};
|
|
347
|
+
for (const root of thread.roots) visit(root);
|
|
348
|
+
return best?.symbol;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Render a macOS sample report as an agent-friendly bottleneck summary.
|
|
353
|
+
* Returns null when `text` is not sample output (caller falls back to the
|
|
354
|
+
* plain-text path).
|
|
355
|
+
*/
|
|
356
|
+
export function renderSampleProfile(text: string): string | null {
|
|
357
|
+
const profile = parseSampleProfile(text);
|
|
358
|
+
if (!profile) return null;
|
|
359
|
+
const { header, threads } = profile;
|
|
360
|
+
const demangleCache = new Map<string, string>();
|
|
361
|
+
|
|
362
|
+
const annotated = threads.map(thread => {
|
|
363
|
+
const roots = thread.roots.map(root => buildProfileNode(root, demangleCache, header.process));
|
|
364
|
+
let cpu = 0;
|
|
365
|
+
for (const root of roots) cpu += root.value;
|
|
366
|
+
return { thread, roots, cpu };
|
|
367
|
+
});
|
|
368
|
+
const processCpu = annotated.reduce((sum, t) => sum + t.cpu, 0);
|
|
369
|
+
const maxSamples = threads.reduce((max, t) => Math.max(max, t.total), 0);
|
|
370
|
+
|
|
371
|
+
const out: string[] = [];
|
|
372
|
+
out.push(`macOS sample profile: ${header.process} (pid ${header.pid}), sampled every ${header.intervalMs} ms`);
|
|
373
|
+
const meta: string[] = [];
|
|
374
|
+
if (header.path) meta.push(header.path);
|
|
375
|
+
if (header.codeType) meta.push(header.codeType);
|
|
376
|
+
if (header.osVersion) meta.push(`macOS ${header.osVersion.replace(/^macOS\s+/, "")}`);
|
|
377
|
+
if (meta.length > 0) out.push(meta.join(" | "));
|
|
378
|
+
const durationSec = (maxSamples * header.intervalMs) / 1000;
|
|
379
|
+
let statLine = `Duration: ~${durationSec.toFixed(1)} s (${maxSamples} samples/thread)`;
|
|
380
|
+
if (header.footprint) {
|
|
381
|
+
statLine += ` | Footprint: ${header.footprint}${header.footprintPeak ? ` (peak ${header.footprintPeak})` : ""}`;
|
|
382
|
+
}
|
|
383
|
+
out.push(statLine);
|
|
384
|
+
out.push("");
|
|
385
|
+
out.push(
|
|
386
|
+
`Process total: ${processCpu} on-CPU samples across ${threads.length} threads. Counts and percentages below are on-CPU samples (blocked time excluded).`,
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
const active: typeof annotated = [];
|
|
390
|
+
const idle: typeof annotated = [];
|
|
391
|
+
for (const entry of annotated) {
|
|
392
|
+
const threshold = Math.max(IDLE_MIN_SAMPLES, entry.thread.total * IDLE_FRACTION);
|
|
393
|
+
(entry.cpu >= threshold ? active : idle).push(entry);
|
|
394
|
+
}
|
|
395
|
+
active.sort((a, b) => b.cpu - a.cpu);
|
|
396
|
+
|
|
397
|
+
for (const { thread, roots, cpu } of active) {
|
|
398
|
+
out.push("");
|
|
399
|
+
const title = thread.name ? `${thread.name} (Thread_${thread.id})` : `Thread_${thread.id}`;
|
|
400
|
+
out.push(`## ${title} — ${thread.total} samples, ${cpu} on-CPU (${formatPct(cpu, thread.total)})`);
|
|
401
|
+
const ctx: RenderTreeContext = {
|
|
402
|
+
out,
|
|
403
|
+
total: cpu,
|
|
404
|
+
minValue: Math.max(3, Math.round(cpu * PRUNE_FRACTION)),
|
|
405
|
+
formatValue: String,
|
|
406
|
+
valueWidth: 6,
|
|
407
|
+
};
|
|
408
|
+
const kept = roots.filter(root => root.value >= ctx.minValue).sort((a, b) => b.value - a.value);
|
|
409
|
+
for (const root of kept) renderProfileNode(root, 0, ctx);
|
|
410
|
+
if (kept.length === 0) out.push(` (no call path above ${ctx.minValue} on-CPU samples)`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (idle.length > 0) {
|
|
414
|
+
out.push("");
|
|
415
|
+
out.push(`## Idle / negligible threads (${idle.length})`);
|
|
416
|
+
for (const { thread, cpu } of idle) {
|
|
417
|
+
const title = thread.name ? `${thread.name} (Thread_${thread.id})` : `Thread_${thread.id}`;
|
|
418
|
+
const wait = dominantWait(thread);
|
|
419
|
+
const state = wait ? `blocked in ${wait} (${cpu} on-CPU)` : `${cpu}/${thread.total} samples on-CPU`;
|
|
420
|
+
out.push(`- ${title}: ${state}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const totals = [...aggregateSelf(profile, demangleCache).entries()].sort((a, b) => b[1].cpu - a[1].cpu);
|
|
425
|
+
if (totals.length > 0) {
|
|
426
|
+
out.push("");
|
|
427
|
+
out.push("## Top functions by self samples (process-wide, blocked time excluded)");
|
|
428
|
+
for (const [symbol, { cpu, module }] of totals.slice(0, TOP_FUNCTIONS)) {
|
|
429
|
+
const suffix = module && module !== header.process ? ` (${module})` : "";
|
|
430
|
+
out.push(`${String(cpu).padStart(6)} ${formatPct(cpu, processCpu).padStart(6)} ${symbol}${suffix}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
out.push("");
|
|
435
|
+
out.push("[Summarized view of a macOS `sample` call-tree report. Use ':raw' to read the original file.]");
|
|
436
|
+
return out.join("\n");
|
|
437
|
+
}
|