feat: implemented provider wire codecs and optimized telemetry and parsing
- Added internal protobuf wire codecs, message builders, and protocol definitions for Cursor and Devin providers. - Deferred loading of OTel SDK and OTLP exporters and added bounded caches to optimize startup and lookup performance. - Added SQLite-backed parse caching for legacy extension source analysis and streaming file chunk parsing for changelogs. - Added support for rendering context usage overflow above 100% in the status line component.
This commit is contained in:
@@ -25,6 +25,7 @@
|
||||
- Compaction method priorities — `compaction.methodOrder` takes an ordered preference list (e.g. `[remote, snap]` uses remote compaction where the provider supports it, such as OpenAI, and snap everywhere else), replacing `compaction.strategy`/`compaction.remoteEnabled`.
|
||||
- Unified inline overlays and selectors (model picker, settings, `/cleanse`) into one titled rounded-box panel style.
|
||||
- Risk badges and warnings on `/settings` rows, starting with External Thinking.
|
||||
- Faster CLI Startup
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { memoryFixtures } from "./memory";
|
||||
import { miscFixtures } from "./misc";
|
||||
import { searchFixtures } from "./search";
|
||||
import { shellFixtures } from "./shell";
|
||||
import { statusLineFixtures } from "./status-line";
|
||||
import { webFixtures } from "./web";
|
||||
|
||||
export * from "./types";
|
||||
@@ -36,5 +37,6 @@ export const galleryFixtures = {
|
||||
...memoryFixtures,
|
||||
...webFixtures,
|
||||
...codeintelFixtures,
|
||||
...statusLineFixtures,
|
||||
...miscFixtures,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Gallery fixture for the status-line context gauge — the box top border that
|
||||
* bridges the segment groups. Renders the real `StatusLineComponent` against a
|
||||
* fake session at four usage levels, mapping the gallery lifecycle states to
|
||||
* fill levels; `error` shows the >100% overflow case (usage anchored to a
|
||||
* larger window than the active model's, e.g. after switching to a smaller
|
||||
* model mid-session), where the gauge clamps to full while the context_pct
|
||||
* segment reports the raw percent.
|
||||
*/
|
||||
import { StatusLineComponent } from "../../modes/components/status-line";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import type { AgentSession } from "../../session/agent-session";
|
||||
import type { GalleryFixture, GalleryFixtureState } from "./types";
|
||||
|
||||
const GAUGE_WINDOW = 200_000;
|
||||
|
||||
/** Simulated usage per gallery lifecycle state, against {@link GAUGE_WINDOW}. */
|
||||
const GAUGE_CASES: Record<GalleryFixtureState, { tokens: number; note: string }> = {
|
||||
streaming: { tokens: 6_000, note: "3% used — fresh session" },
|
||||
progress: { tokens: 124_000, note: "62% used — warning zone" },
|
||||
success: { tokens: 194_000, note: "97% used — past compaction threshold" },
|
||||
error: { tokens: 240_000, note: "120% used — overflow: percent breaks past the window label in red" },
|
||||
};
|
||||
|
||||
/** Minimal session double satisfying every query `getTopBorder` makes. */
|
||||
function fakeGaugeSession(tokens: number): AgentSession {
|
||||
const model = { id: "test-model", contextWindow: GAUGE_WINDOW };
|
||||
const messages = [{ role: "user", content: "hi" }];
|
||||
return {
|
||||
messages,
|
||||
systemPrompt: [],
|
||||
agent: { state: { tools: [] } },
|
||||
skills: [],
|
||||
model,
|
||||
modelRegistry: { isUsingOAuth: () => false },
|
||||
state: { messages, model },
|
||||
settings: undefined,
|
||||
sessionManager: {
|
||||
getUsageStatistics: () => ({
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
orchestrationInput: 0,
|
||||
orchestrationOutput: 0,
|
||||
orchestrationCacheRead: 0,
|
||||
premiumRequests: 0,
|
||||
cost: 0,
|
||||
}),
|
||||
getSessionName: () => "gallery",
|
||||
},
|
||||
getAsyncJobSnapshot: () => ({ running: [] }),
|
||||
isFastModeActive: () => false,
|
||||
getContextUsage: () => ({ tokens, contextWindow: GAUGE_WINDOW, percent: (tokens / GAUGE_WINDOW) * 100 }),
|
||||
contextUsageRevision: 0,
|
||||
} as unknown as AgentSession;
|
||||
}
|
||||
|
||||
/** Render one contextLine variant of the top border for the given usage. */
|
||||
function renderGaugeVariant(tokens: number, contextLine: "annotated" | "embedded", width: number): string {
|
||||
const component = new StatusLineComponent(fakeGaugeSession(tokens));
|
||||
component.updateSettings({
|
||||
preset: "custom",
|
||||
leftSegments: contextLine === "embedded" ? ["model", "context_pct"] : ["model"],
|
||||
rightSegments: contextLine === "embedded" ? ["session_name"] : ["context_pct"],
|
||||
separator: "powerline-thin",
|
||||
sessionAccent: false,
|
||||
contextLine,
|
||||
});
|
||||
try {
|
||||
return component.getTopBorder(width).content;
|
||||
} finally {
|
||||
component.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function renderContextGaugeState(state: GalleryFixtureState, width: number): readonly string[] {
|
||||
const { tokens, note } = GAUGE_CASES[state];
|
||||
return [
|
||||
theme.fg("dim", ` ${note}`),
|
||||
renderGaugeVariant(tokens, "annotated", width),
|
||||
renderGaugeVariant(tokens, "embedded", width),
|
||||
];
|
||||
}
|
||||
|
||||
export const statusLineFixtures: Record<string, GalleryFixture> = {
|
||||
context_gauge: {
|
||||
label: "Context Gauge",
|
||||
renderState: renderContextGaugeState,
|
||||
args: { note: "status-line context gauge preview" },
|
||||
result: { content: [{ type: "text", text: "Rendered annotated and embedded context gauges." }] },
|
||||
},
|
||||
};
|
||||
@@ -164,15 +164,6 @@ export function mergeProviderRemoteCompactionConfig(
|
||||
return mergeRemoteCompactionConfig(providerConfig, modelConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a built model back to spec shape for the model-manager/cache
|
||||
* boundary: sparse compat comes from `compatConfig`, never from the resolved
|
||||
* record.
|
||||
*/
|
||||
export function toModelSpec<TApi extends Api>(model: Model<TApi>): ModelSpec<TApi> {
|
||||
return { ...model, compat: model.compatConfig } as ModelSpec<TApi>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The patchable subset of `Model` fields shared by `modelOverrides` entries,
|
||||
* custom model definitions, and parsed custom-model overlays. `undefined`
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
resolveModelCacheProviderId,
|
||||
resolveOllamaModelCacheProviderId,
|
||||
} from "@oh-my-pi/pi-catalog/provider-models";
|
||||
import { toModelSpec } from "@oh-my-pi/pi-catalog/provider-models/bundled-references";
|
||||
import { collapseBuiltModelVariants } from "@oh-my-pi/pi-catalog/variant-collapse";
|
||||
import { getAgentDir, isBunTestRuntime, logger, wrapFetchForExtraCa } from "@oh-my-pi/pi-utils";
|
||||
import { resolveProviderModelReference } from "../config/model-resolver";
|
||||
@@ -80,7 +81,6 @@ import {
|
||||
mergeRemoteCompactionConfig,
|
||||
type ProviderOverride,
|
||||
providersWithAuthoritativeProjectCatalog,
|
||||
toModelSpec,
|
||||
} from "./model-patch";
|
||||
import {
|
||||
BUILT_IN_DISCOVERY_CACHE_TTL_MS,
|
||||
@@ -646,7 +646,7 @@ export class ModelRegistry {
|
||||
|
||||
return models.map(m => {
|
||||
if (!providerOverride) return m;
|
||||
const withTransportOverride = this.#applyProviderTransportOverride(m, providerOverride);
|
||||
const withTransportOverride = this.#applyProviderTransportOverride(toModelSpec(m), providerOverride);
|
||||
return buildModel({
|
||||
...withTransportOverride,
|
||||
compat: mergeCompat(m.compatConfig, providerOverride.compat),
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
/// <reference path="./legacy-pi-virtual-modules.d.ts" />
|
||||
|
||||
import { Database } from "bun:sqlite";
|
||||
import * as fs from "node:fs";
|
||||
import { createRequire, isBuiltin } from "node:module";
|
||||
import * as path from "node:path";
|
||||
import * as url from "node:url";
|
||||
import type { ParseResult, ParserPlugin } from "@babel/parser";
|
||||
import { parse as parseBabel } from "@babel/parser";
|
||||
import { isCompiledBinary, stripWindowsExtendedLengthPathPrefix } from "@oh-my-pi/pi-utils";
|
||||
import {
|
||||
getLegacyPiExtensionCacheDbPath,
|
||||
isCompiledBinary,
|
||||
logger,
|
||||
stripWindowsExtendedLengthPathPrefix,
|
||||
} from "@oh-my-pi/pi-utils";
|
||||
import { registerPluginCacheInvalidator } from "../../discovery/helpers";
|
||||
|
||||
const IS_COMPILED_BINARY = isCompiledBinary();
|
||||
@@ -537,6 +543,159 @@ function collectExtensionSpecifierReferences(
|
||||
return references;
|
||||
}
|
||||
|
||||
const EXTENSION_PARSE_CACHE_SCHEMA_VERSION = 1;
|
||||
const EXTENSION_PARSE_CACHE_MAX_BYTES = 8 * 1024 * 1024;
|
||||
const EXTENSION_PARSE_CACHE_MAX_ENTRIES = 10_000;
|
||||
|
||||
interface ExtensionSourceAnalysis {
|
||||
readonly sourceType: "script" | "module";
|
||||
readonly references: readonly ExtensionSpecifierReference[];
|
||||
readonly commonJsNamedExports: readonly string[];
|
||||
readonly commonJsReexportSpecifiers: readonly string[];
|
||||
}
|
||||
|
||||
interface ExtensionParseCacheRow {
|
||||
source_type: "script" | "module";
|
||||
references: string;
|
||||
commonjs_named_exports: string;
|
||||
commonjs_reexport_specifiers: string;
|
||||
}
|
||||
|
||||
let extensionParseCacheDb: Database | null | undefined;
|
||||
const extensionSourceAnalysisCache = new Map<string, ExtensionSourceAnalysis>();
|
||||
|
||||
function extensionParseCacheKey(source: string, importerPath: string): string {
|
||||
return `${EXTENSION_PARSE_CACHE_SCHEMA_VERSION}:${path.extname(importerPath).toLowerCase()}:${Bun.hash(source).toString(16)}`;
|
||||
}
|
||||
|
||||
function getExtensionParseCacheDb(): Database | null {
|
||||
if (extensionParseCacheDb !== undefined) return extensionParseCacheDb;
|
||||
try {
|
||||
const cachePath = getLegacyPiExtensionCacheDbPath();
|
||||
try {
|
||||
if (fs.statSync(cachePath).size > EXTENSION_PARSE_CACHE_MAX_BYTES) {
|
||||
fs.rmSync(cachePath, { force: true });
|
||||
}
|
||||
} catch {
|
||||
// A missing or unreadable cache is a cold cache.
|
||||
}
|
||||
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
||||
const db = new Database(cachePath, { create: true });
|
||||
db.run("PRAGMA busy_timeout = 50");
|
||||
db.run(
|
||||
"CREATE TABLE IF NOT EXISTS extension_parse_cache (cache_key TEXT PRIMARY KEY, source_type TEXT NOT NULL, references TEXT NOT NULL, commonjs_named_exports TEXT NOT NULL, commonjs_reexport_specifiers TEXT NOT NULL)",
|
||||
);
|
||||
extensionParseCacheDb = db;
|
||||
return db;
|
||||
} catch (error) {
|
||||
logger.debug("legacy Pi extension parse cache unavailable", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
extensionParseCacheDb = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCachedAnalysis(row: ExtensionParseCacheRow): ExtensionSourceAnalysis | null {
|
||||
try {
|
||||
if (row.source_type !== "script" && row.source_type !== "module") return null;
|
||||
const references = JSON.parse(row.references) as unknown;
|
||||
const commonJsNamedExports = JSON.parse(row.commonjs_named_exports) as unknown;
|
||||
const commonJsReexportSpecifiers = JSON.parse(row.commonjs_reexport_specifiers) as unknown;
|
||||
if (
|
||||
!Array.isArray(references) ||
|
||||
!references.every(
|
||||
reference =>
|
||||
reference &&
|
||||
typeof reference === "object" &&
|
||||
(reference.kind === "import" || reference.kind === "require") &&
|
||||
typeof reference.specifier === "string" &&
|
||||
typeof reference.start === "number" &&
|
||||
typeof reference.end === "number",
|
||||
) ||
|
||||
!Array.isArray(commonJsNamedExports) ||
|
||||
!commonJsNamedExports.every(name => typeof name === "string") ||
|
||||
!Array.isArray(commonJsReexportSpecifiers) ||
|
||||
!commonJsReexportSpecifiers.every(specifier => typeof specifier === "string")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
sourceType: row.source_type,
|
||||
references: references as ExtensionSpecifierReference[],
|
||||
commonJsNamedExports,
|
||||
commonJsReexportSpecifiers,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeExtensionSourceAnalysis(cacheKey: string, analysis: ExtensionSourceAnalysis): void {
|
||||
void Promise.resolve()
|
||||
.then(() => {
|
||||
const db = getExtensionParseCacheDb();
|
||||
if (!db) return;
|
||||
db.run(
|
||||
"INSERT OR REPLACE INTO extension_parse_cache (cache_key, source_type, references, commonjs_named_exports, commonjs_reexport_specifiers) VALUES (?, ?, ?, ?, ?)",
|
||||
[
|
||||
cacheKey,
|
||||
analysis.sourceType,
|
||||
JSON.stringify(analysis.references),
|
||||
JSON.stringify(analysis.commonJsNamedExports),
|
||||
JSON.stringify(analysis.commonJsReexportSpecifiers),
|
||||
],
|
||||
);
|
||||
const count =
|
||||
db.query<{ count: number }, []>("SELECT count(*) AS count FROM extension_parse_cache").get()?.count ?? 0;
|
||||
if (count > EXTENSION_PARSE_CACHE_MAX_ENTRIES) {
|
||||
db.run("DELETE FROM extension_parse_cache");
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
logger.debug("legacy Pi extension parse cache write failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getExtensionSourceAnalysis(source: string, importerPath: string): ExtensionSourceAnalysis {
|
||||
const cacheKey = extensionParseCacheKey(source, importerPath);
|
||||
const memoryCached = extensionSourceAnalysisCache.get(cacheKey);
|
||||
if (memoryCached) return memoryCached;
|
||||
|
||||
const db = getExtensionParseCacheDb();
|
||||
try {
|
||||
const row = db
|
||||
?.query<ExtensionParseCacheRow, [string]>(
|
||||
"SELECT source_type, references, commonjs_named_exports, commonjs_reexport_specifiers FROM extension_parse_cache WHERE cache_key = ?",
|
||||
)
|
||||
.get(cacheKey);
|
||||
if (row) {
|
||||
const cached = parseCachedAnalysis(row);
|
||||
if (cached) {
|
||||
extensionSourceAnalysisCache.set(cacheKey, cached);
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug("legacy Pi extension parse cache read failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
const ast = parseExtensionSource(source, importerPath);
|
||||
const commonJs = collectCommonJsExportAnalysis(ast);
|
||||
const analysis: ExtensionSourceAnalysis = {
|
||||
sourceType: ast.program.sourceType,
|
||||
references: collectExtensionSpecifierReferences(source, importerPath, ast),
|
||||
...commonJs,
|
||||
};
|
||||
extensionSourceAnalysisCache.set(cacheKey, analysis);
|
||||
writeExtensionSourceAnalysis(cacheKey, analysis);
|
||||
return analysis;
|
||||
}
|
||||
|
||||
function applySpecifierReplacements(
|
||||
source: string,
|
||||
replacements: ReadonlyArray<ExtensionSpecifierReference & { readonly replacement: string }>,
|
||||
@@ -1003,7 +1162,7 @@ async function rewriteLegacyExtensionSource(
|
||||
// Compiled mode completes the override map from the build-supplied module
|
||||
// keys on first use; every rewrite path must see the full map.
|
||||
await ensureLegacyPiOverridesReady();
|
||||
const references = collectExtensionSpecifierReferences(source, importerPath);
|
||||
const references = getExtensionSourceAnalysis(source, importerPath).references;
|
||||
const replacements: Array<ExtensionSpecifierReference & { replacement: string }> = [];
|
||||
for (const reference of references) {
|
||||
if (reference.kind !== "import") continue;
|
||||
@@ -1448,7 +1607,7 @@ async function isCommonJsModulePath(
|
||||
return true;
|
||||
}
|
||||
const parsedSourceType =
|
||||
sourceType ?? parseExtensionSource(await Bun.file(modulePath).text(), modulePath).program.sourceType;
|
||||
sourceType ?? getExtensionSourceAnalysis(await Bun.file(modulePath).text(), modulePath).sourceType;
|
||||
if (parsedSourceType === "module") {
|
||||
return false;
|
||||
}
|
||||
@@ -1776,7 +1935,7 @@ async function rewriteExtensionSpecifiers(
|
||||
importerPath: string,
|
||||
rewriteImports = false,
|
||||
): Promise<string> {
|
||||
const references = collectExtensionSpecifierReferences(source, importerPath);
|
||||
const references = getExtensionSourceAnalysis(source, importerPath).references;
|
||||
const resolvedSpecifierTargets = new Map<string, string>();
|
||||
const replacements: Array<ExtensionSpecifierReference & { replacement: string }> = [];
|
||||
for (const reference of references) {
|
||||
@@ -1808,7 +1967,7 @@ function rewriteExtensionSpecifiersFromCache(source: string, importerPath: strin
|
||||
return source;
|
||||
}
|
||||
const replacements: Array<ExtensionSpecifierReference & { replacement: string }> = [];
|
||||
for (const reference of collectExtensionSpecifierReferences(source, importerPath)) {
|
||||
for (const reference of getExtensionSourceAnalysis(source, importerPath).references) {
|
||||
const replacement = resolvedSpecifierTargets.get(`${reference.kind}\0${reference.specifier}`);
|
||||
if (replacement) {
|
||||
replacements.push({ ...reference, replacement });
|
||||
@@ -1839,7 +1998,7 @@ async function moduleRequiresNativeAddonUncached(modulePath: string): Promise<bo
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
for (const reference of collectExtensionSpecifierReferences(source, modulePath)) {
|
||||
for (const reference of getExtensionSourceAnalysis(source, modulePath).references) {
|
||||
if (reference.kind === "require" && (await resolveExtensionNativeAddon(reference.specifier, modulePath))) {
|
||||
return true;
|
||||
}
|
||||
@@ -2040,18 +2199,18 @@ async function collectExtensionModules(entryRealPath: string): Promise<Extension
|
||||
continue;
|
||||
}
|
||||
modules.set(file, source);
|
||||
const ast = parseExtensionSource(source, file);
|
||||
const analysis = getExtensionSourceAnalysis(source, file);
|
||||
const sourceIsCommonJs = await isGraphOwnedCommonJsModule(
|
||||
file,
|
||||
entryRealPath,
|
||||
ast.program.sourceType,
|
||||
analysis.sourceType,
|
||||
inheritedModuleKind,
|
||||
);
|
||||
if (sourceIsCommonJs) {
|
||||
commonJsPaths.add(file);
|
||||
}
|
||||
const dir = path.dirname(file);
|
||||
const references = collectExtensionSpecifierReferences(source, file, ast);
|
||||
const references = analysis.references;
|
||||
for (const reference of references) {
|
||||
const specifier = reference.specifier;
|
||||
try {
|
||||
@@ -2233,22 +2392,11 @@ export async function __collectLegacyPiExtensionSourcesForTests(
|
||||
*/
|
||||
const COMMONJS_NAMED_EXPORT_IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
||||
|
||||
function collectCommonJsNamedExports(source: string, modulePath: string, visited = new Set<string>()): string[] {
|
||||
let realModulePath = modulePath;
|
||||
try {
|
||||
realModulePath = fs.realpathSync(modulePath);
|
||||
} catch {
|
||||
// The caller's path remains the stable cycle key when realpath fails.
|
||||
}
|
||||
if (visited.has(realModulePath)) {
|
||||
return [];
|
||||
}
|
||||
visited.add(realModulePath);
|
||||
|
||||
function collectCommonJsExportAnalysis(
|
||||
ast: ParseResult,
|
||||
): Pick<ExtensionSourceAnalysis, "commonJsNamedExports" | "commonJsReexportSpecifiers"> {
|
||||
const names = new Set<string>();
|
||||
|
||||
const reexportSpecifiers = new Set<string>();
|
||||
const ast = parseExtensionSource(source, modulePath);
|
||||
for (const { node, scope } of collectScopedAstNodes(
|
||||
ast,
|
||||
candidate => candidate.type === "CallExpression" || candidate.type === "AssignmentExpression",
|
||||
@@ -2284,17 +2432,13 @@ function collectCommonJsNamedExports(source: string, modulePath: string, visited
|
||||
if (node.type !== "AssignmentExpression" || node.operator !== "=") continue;
|
||||
const left = asAstNode(node.left);
|
||||
if (left?.type !== "MemberExpression") continue;
|
||||
|
||||
const propertyName = staticMemberPropertyName(left);
|
||||
const object = asAstNode(left.object);
|
||||
if (propertyName !== null && isUnshadowedExportsTarget(object, scope)) {
|
||||
if (propertyName !== "default" && COMMONJS_NAMED_EXPORT_IDENTIFIER.test(propertyName)) {
|
||||
names.add(propertyName);
|
||||
}
|
||||
if (propertyName !== "default" && COMMONJS_NAMED_EXPORT_IDENTIFIER.test(propertyName)) names.add(propertyName);
|
||||
continue;
|
||||
}
|
||||
if (!isUncomputedMember(left, "module", "exports") || scopeHasBinding(scope, MODULE_BINDING)) continue;
|
||||
|
||||
const right = asAstNode(node.right);
|
||||
if (right?.type === "ObjectExpression") {
|
||||
const properties = nodeArray(right, "properties");
|
||||
@@ -2303,28 +2447,37 @@ function collectCommonJsNamedExports(source: string, modulePath: string, visited
|
||||
const property = asAstNode(value);
|
||||
if (!property || (property.type !== "ObjectProperty" && property.type !== "ObjectMethod")) continue;
|
||||
const name = staticObjectPropertyName(property);
|
||||
if (name && name !== "default" && COMMONJS_NAMED_EXPORT_IDENTIFIER.test(name)) {
|
||||
names.add(name);
|
||||
}
|
||||
if (name && name !== "default" && COMMONJS_NAMED_EXPORT_IDENTIFIER.test(name)) names.add(name);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isGlobalRequireCall(right, scope)) {
|
||||
const argument = nodeArgument(right, 0);
|
||||
if (argument?.type === "StringLiteral" && typeof argument.value === "string") {
|
||||
if (argument?.type === "StringLiteral" && typeof argument.value === "string")
|
||||
reexportSpecifiers.add(argument.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { commonJsNamedExports: [...names], commonJsReexportSpecifiers: [...reexportSpecifiers] };
|
||||
}
|
||||
|
||||
function collectCommonJsNamedExports(source: string, modulePath: string, visited = new Set<string>()): string[] {
|
||||
let realModulePath = modulePath;
|
||||
try {
|
||||
realModulePath = fs.realpathSync(modulePath);
|
||||
} catch {
|
||||
// The caller's path remains the stable cycle key when realpath fails.
|
||||
}
|
||||
if (visited.has(realModulePath)) return [];
|
||||
visited.add(realModulePath);
|
||||
const analysis = getExtensionSourceAnalysis(source, modulePath);
|
||||
const names = new Set(analysis.commonJsNamedExports);
|
||||
const nativeRequire = createRequire(modulePath);
|
||||
for (const specifier of reexportSpecifiers) {
|
||||
for (const specifier of analysis.commonJsReexportSpecifiers) {
|
||||
try {
|
||||
const resolved = fs.realpathSync(nativeRequire.resolve(specifier));
|
||||
const reexportedSource = rewriteExtensionSpecifiersFromCache(fs.readFileSync(resolved, "utf8"), resolved);
|
||||
for (const name of collectCommonJsNamedExports(reexportedSource, resolved, visited)) {
|
||||
names.add(name);
|
||||
}
|
||||
for (const name of collectCommonJsNamedExports(reexportedSource, resolved, visited)) names.add(name);
|
||||
} catch {
|
||||
// Native modules and non-source re-exports do not expose analyzable names.
|
||||
}
|
||||
|
||||
@@ -2021,13 +2021,22 @@ export class StatusLineComponent implements Component {
|
||||
let percentStart = -1;
|
||||
let windowStart = -1;
|
||||
let scaleWidth = gapWidth;
|
||||
// >100%: usage anchored past the active window (e.g. model switch to a
|
||||
// smaller window). The bar clamps full, but the embedded label breaks
|
||||
// past the window label — `──200K─120%` with the percent in error color.
|
||||
const percentOverflow = pct > 100;
|
||||
if (embedContext) {
|
||||
const candidatePercent = formatEmbeddedContextPercent(clampedPct);
|
||||
const candidatePercent = formatEmbeddedContextPercent(percentOverflow ? pct : clampedPct);
|
||||
const candidateWindow = formatNumber(ctx.contextWindow);
|
||||
if (gapWidth >= candidatePercent.length + candidateWindow.length + 4) {
|
||||
percentLabel = candidatePercent;
|
||||
windowLabel = candidateWindow;
|
||||
windowStart = gapWidth - windowLabel.length - 1;
|
||||
if (percentOverflow) {
|
||||
percentStart = gapWidth - percentLabel.length;
|
||||
windowStart = percentStart - 1 - windowLabel.length;
|
||||
} else {
|
||||
windowStart = gapWidth - windowLabel.length - 1;
|
||||
}
|
||||
scaleWidth = windowStart;
|
||||
}
|
||||
}
|
||||
@@ -2054,7 +2063,7 @@ export class StatusLineComponent implements Component {
|
||||
}
|
||||
}
|
||||
|
||||
if (percentLabel) {
|
||||
if (percentLabel && percentStart < 0) {
|
||||
const maxStart = scaleWidth - percentLabel.length - 1;
|
||||
const preferredStart = Math.min(maxStart, Math.max(1, usedCount));
|
||||
const overlapsBoundary = (start: number): boolean => {
|
||||
@@ -2079,6 +2088,7 @@ export class StatusLineComponent implements Component {
|
||||
const speculationGlyph = theme.symbol("context.speculation");
|
||||
const thresholdGlyph = theme.symbol("context.compaction");
|
||||
const speculationColor = theme.getFgAnsi("muted");
|
||||
const overflowColor = theme.getFgAnsi("error");
|
||||
const rawAccentHex = accentHex ?? theme.getColorHex("borderAccent");
|
||||
const dimmedAccentHex = adjustHsv(rawAccentHex, { s: 0.7, v: 0.75 });
|
||||
const thresholdColor = getSessionAccentAnsi(dimmedAccentHex) ?? usedColor;
|
||||
@@ -2089,7 +2099,7 @@ export class StatusLineComponent implements Component {
|
||||
let color = i < usedCount ? usedColor : unusedColor;
|
||||
let glyph = horizontal;
|
||||
if (percentStart >= 0 && i >= percentStart && i < percentStart + percentLabel.length) {
|
||||
color = usedColor;
|
||||
color = percentOverflow ? overflowColor : usedColor;
|
||||
glyph = percentLabel.charAt(i - percentStart);
|
||||
} else if (i === thresholdIdx) {
|
||||
color = thresholdColor;
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* OTLP provider registration and signal recording for telemetry export.
|
||||
*
|
||||
* Loaded on demand by `./telemetry-export` only when an `OTEL_*` endpoint is
|
||||
* configured — the OTel SDK + OTLP exporter graph costs ~100ms of module
|
||||
* evaluation, so it must stay out of default CLI startup.
|
||||
*
|
||||
* Only the `http/protobuf` transport is supported — an
|
||||
* `OTEL_EXPORTER_OTLP*_PROTOCOL` of `grpc` or `http/json` declines rather than
|
||||
* misrouting protobuf payloads. The exporter line is pinned to the 0.218/2.7
|
||||
* family validated under Bun; the 1.x OTLP line deadlocks when its
|
||||
* `req.on("close")` handler fires after a successful export.
|
||||
*/
|
||||
import type {
|
||||
AgentRunCoverage,
|
||||
AgentRunSummary,
|
||||
AgentTelemetryConfig,
|
||||
AgentTelemetryWarning,
|
||||
ChatUsageEvent,
|
||||
ToolStatus,
|
||||
} from "@oh-my-pi/pi-agent-core";
|
||||
import { logger, postmortem } from "@oh-my-pi/pi-utils";
|
||||
import {
|
||||
type Attributes,
|
||||
type AttributeValue,
|
||||
type Counter,
|
||||
context,
|
||||
type Histogram,
|
||||
type Meter,
|
||||
metrics,
|
||||
} from "@opentelemetry/api";
|
||||
import { type LogAttributes, logs, type Logger as OtelLogger, SeverityNumber } from "@opentelemetry/api-logs";
|
||||
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
|
||||
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
|
||||
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto";
|
||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
|
||||
import { detectResources, envDetector, resourceFromAttributes } from "@opentelemetry/resources";
|
||||
import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
|
||||
import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
||||
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
||||
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
|
||||
import type { TelemetrySignalConfig } from "./telemetry-export";
|
||||
|
||||
/**
|
||||
* Periodic flush interval. A long-lived `omp` process (the ACP server is
|
||||
* spawned once and reused across many turns) would otherwise hold finished
|
||||
* telemetry until a batch window elapses or the process exits.
|
||||
*/
|
||||
const FLUSH_INTERVAL_MS = 30_000;
|
||||
|
||||
const SERVICE_NAME = "oh-my-pi";
|
||||
|
||||
type OtelLogLevel = "none" | logger.LogLevel;
|
||||
|
||||
const LOG_SEVERITY: Record<logger.LogLevel, SeverityNumber> = {
|
||||
error: SeverityNumber.ERROR,
|
||||
warn: SeverityNumber.WARN,
|
||||
info: SeverityNumber.INFO,
|
||||
debug: SeverityNumber.DEBUG,
|
||||
};
|
||||
|
||||
const LOG_LEVEL_WEIGHT: Record<logger.LogLevel, number> = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
debug: 3,
|
||||
};
|
||||
|
||||
const TOOL_STATUSES = ["ok", "error", "skipped", "blocked", "timeout", "aborted"] satisfies readonly ToolStatus[];
|
||||
|
||||
let traceProvider: NodeTracerProvider | undefined;
|
||||
let logProvider: LoggerProvider | undefined;
|
||||
let meterProvider: MeterProvider | undefined;
|
||||
let metricRecorder: AgentMetricRecorder | undefined;
|
||||
let otelLogger: OtelLogger | undefined;
|
||||
let unregisterLogSink: (() => void) | undefined;
|
||||
|
||||
/** Whether {@link registerProviders} registered any real OTLP signal provider. */
|
||||
export function isTelemetryExportEnabled(): boolean {
|
||||
if (traceProvider) return true;
|
||||
if (logProvider) return true;
|
||||
if (meterProvider) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge OTLP metrics/log hooks into an existing agent telemetry config.
|
||||
*
|
||||
* The caller still owns content-capture policy, cost estimation, and custom
|
||||
* attributes. This only appends host-level metrics/log forwarding for the
|
||||
* providers registered by {@link registerProviders}.
|
||||
*/
|
||||
export function createTelemetryExportConfig(
|
||||
config: AgentTelemetryConfig | undefined,
|
||||
): AgentTelemetryConfig | undefined {
|
||||
if (!isTelemetryExportEnabled()) return config;
|
||||
return {
|
||||
...config,
|
||||
onChatUsage: async event => {
|
||||
await config?.onChatUsage?.(event);
|
||||
metricRecorder?.recordChatUsage(event);
|
||||
},
|
||||
onRunEnd: (summary, coverage) => {
|
||||
config?.onRunEnd?.(summary, coverage);
|
||||
metricRecorder?.recordRun(summary, coverage);
|
||||
emitRunSummaryLog(summary, coverage);
|
||||
},
|
||||
onTelemetryWarning: warning => {
|
||||
config?.onTelemetryWarning?.(warning);
|
||||
emitTelemetryWarningLog(warning);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Register global trace/log/meter providers for the enabled signals. */
|
||||
export async function registerProviders(signalConfig: TelemetrySignalConfig): Promise<void> {
|
||||
// `envDetector` parses OTEL_RESOURCE_ATTRIBUTES (percent-decoded, per spec) and
|
||||
// OTEL_SERVICE_NAME; merged last so both take precedence over the fallback
|
||||
// service.name — with OTEL_SERVICE_NAME still winning service.name inside the
|
||||
// detector itself.
|
||||
const resource = resourceFromAttributes({ "service.name": SERVICE_NAME }).merge(
|
||||
detectResources({ detectors: [envDetector] }),
|
||||
);
|
||||
|
||||
if (signalConfig.trace) {
|
||||
const exporter = new OTLPTraceExporter();
|
||||
traceProvider = new NodeTracerProvider({
|
||||
resource,
|
||||
spanProcessors: [new BatchSpanProcessor(exporter)],
|
||||
});
|
||||
traceProvider.register({ contextManager: new AsyncLocalStorageContextManager().enable() });
|
||||
}
|
||||
|
||||
if (signalConfig.metric) {
|
||||
const exporter = new OTLPMetricExporter();
|
||||
meterProvider = new MeterProvider({
|
||||
resource,
|
||||
readers: [new PeriodicExportingMetricReader({ exporter })],
|
||||
});
|
||||
metrics.setGlobalMeterProvider(meterProvider);
|
||||
metricRecorder = new AgentMetricRecorder(metrics.getMeter("@oh-my-pi/pi-coding-agent"));
|
||||
}
|
||||
|
||||
if (signalConfig.log) {
|
||||
const exporter = new OTLPLogExporter();
|
||||
logProvider = new LoggerProvider({
|
||||
resource,
|
||||
processors: [new BatchLogRecordProcessor({ exporter })],
|
||||
});
|
||||
logs.setGlobalLoggerProvider(logProvider);
|
||||
otelLogger = logProvider.getLogger("@oh-my-pi/pi-coding-agent");
|
||||
unregisterLogSink = logger.registerLogSink(event => {
|
||||
emitOtelLog(
|
||||
event.level,
|
||||
event.message,
|
||||
logAttributesFromContext(event.context),
|
||||
"pi.omp.log",
|
||||
event.timestamp,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const flushTimer = setInterval(() => {
|
||||
flushTelemetryExport().catch(() => {});
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
flushTimer.unref();
|
||||
|
||||
postmortem.register("otel-export", async () => {
|
||||
clearInterval(flushTimer);
|
||||
unregisterLogSink?.();
|
||||
unregisterLogSink = undefined;
|
||||
const shutdowns: Promise<void>[] = [];
|
||||
if (traceProvider) shutdowns.push(traceProvider.shutdown());
|
||||
if (logProvider) shutdowns.push(logProvider.shutdown());
|
||||
if (meterProvider) shutdowns.push(meterProvider.shutdown());
|
||||
await Promise.all(shutdowns);
|
||||
});
|
||||
}
|
||||
|
||||
class AgentMetricRecorder {
|
||||
readonly #tokenUsage: Histogram<Attributes>;
|
||||
readonly #chatCostUsd: Counter<Attributes>;
|
||||
readonly #runs: Counter<Attributes>;
|
||||
readonly #steps: Counter<Attributes>;
|
||||
readonly #chatCalls: Counter<Attributes>;
|
||||
readonly #chatDurationMs: Histogram<Attributes>;
|
||||
readonly #toolCalls: Counter<Attributes>;
|
||||
readonly #toolDurationMs: Histogram<Attributes>;
|
||||
readonly #errors: Counter<Attributes>;
|
||||
|
||||
constructor(meter: Meter) {
|
||||
this.#tokenUsage = meter.createHistogram("gen_ai.client.token.usage", {
|
||||
description: "Token usage reported by GenAI chat calls.",
|
||||
unit: "{token}",
|
||||
});
|
||||
this.#chatCostUsd = meter.createCounter("pi.omp.agent.chat.cost.estimated_usd", {
|
||||
description: "Estimated USD cost for completed chat calls.",
|
||||
unit: "USD",
|
||||
});
|
||||
this.#runs = meter.createCounter("pi.omp.agent.runs", {
|
||||
description: "Completed agent runs.",
|
||||
unit: "{run}",
|
||||
});
|
||||
this.#steps = meter.createCounter("pi.omp.agent.steps", {
|
||||
description: "Agent loop steps completed inside a run.",
|
||||
unit: "{step}",
|
||||
});
|
||||
this.#chatCalls = meter.createCounter("pi.omp.agent.chat.calls", {
|
||||
description: "Chat calls completed inside agent runs.",
|
||||
unit: "{call}",
|
||||
});
|
||||
this.#chatDurationMs = meter.createHistogram("pi.omp.agent.chat.duration", {
|
||||
description: "Total chat latency observed in an agent run.",
|
||||
unit: "ms",
|
||||
});
|
||||
this.#toolCalls = meter.createCounter("pi.omp.agent.tool.calls", {
|
||||
description: "Tool calls completed inside agent runs.",
|
||||
unit: "{call}",
|
||||
});
|
||||
this.#toolDurationMs = meter.createHistogram("pi.omp.agent.tool.duration", {
|
||||
description: "Total tool latency observed in an agent run.",
|
||||
unit: "ms",
|
||||
});
|
||||
this.#errors = meter.createCounter("pi.omp.agent.errors", {
|
||||
description: "Errors observed in chat and tool execution.",
|
||||
unit: "{error}",
|
||||
});
|
||||
}
|
||||
|
||||
recordChatUsage(event: ChatUsageEvent): void {
|
||||
const baseAttrs = metricAttributes({
|
||||
"gen_ai.operation.name": "chat",
|
||||
"gen_ai.provider.name": event.provider,
|
||||
"gen_ai.request.model": event.model,
|
||||
"gen_ai.response.service_tier": event.serviceTier,
|
||||
"pi.gen_ai.agent.id": event.agent?.id,
|
||||
"pi.gen_ai.agent.name": event.agent?.name,
|
||||
});
|
||||
|
||||
this.#recordToken(event.usage.inputTokens, baseAttrs, "input");
|
||||
this.#recordToken(event.usage.outputTokens, baseAttrs, "output");
|
||||
this.#recordToken(event.usage.totalTokens, baseAttrs, "total");
|
||||
this.#recordToken(event.usage.cachedInputTokens, baseAttrs, "cache_read_input");
|
||||
this.#recordToken(event.usage.cacheWriteTokens, baseAttrs, "cache_write_input");
|
||||
this.#recordToken(event.usage.reasoningOutputTokens, baseAttrs, "reasoning_output");
|
||||
|
||||
if (event.cost && "usd" in event.cost && event.cost.usd > 0) {
|
||||
this.#chatCostUsd.add(event.cost.usd, baseAttrs);
|
||||
}
|
||||
}
|
||||
|
||||
recordRun(summary: AgentRunSummary, coverage: AgentRunCoverage): void {
|
||||
const runAttrs = metricAttributes({
|
||||
"pi.omp.agent.models_used.count": coverage.modelsUsed.length,
|
||||
"pi.omp.agent.providers_used.count": coverage.providersUsed.length,
|
||||
"pi.omp.agent.tools_available.count": coverage.toolsAvailable.length,
|
||||
"pi.omp.agent.tools_invoked.count": coverage.toolsInvoked.length,
|
||||
"pi.omp.agent.tools_unused.count": coverage.toolsUnused.length,
|
||||
});
|
||||
|
||||
this.#runs.add(1, runAttrs);
|
||||
if (summary.stepCount > 0) this.#steps.add(summary.stepCount, runAttrs);
|
||||
if (summary.chats.totalLatencyMs > 0) this.#chatDurationMs.record(summary.chats.totalLatencyMs, runAttrs);
|
||||
|
||||
for (const reason in summary.chats.byStopReason) {
|
||||
const count = summary.chats.byStopReason[reason];
|
||||
if (count > 0)
|
||||
this.#chatCalls.add(count, metricAttributes({ ...runAttrs, "gen_ai.response.finish_reason": reason }));
|
||||
}
|
||||
for (const toolName in summary.tools.byName) {
|
||||
const counters = summary.tools.byName[toolName];
|
||||
const toolAttrs = metricAttributes({ ...runAttrs, "gen_ai.tool.name": toolName });
|
||||
if (counters.totalLatencyMs > 0) this.#toolDurationMs.record(counters.totalLatencyMs, toolAttrs);
|
||||
for (const status of TOOL_STATUSES) {
|
||||
const count = counters[status];
|
||||
if (count > 0) this.#toolCalls.add(count, metricAttributes({ ...toolAttrs, "pi.omp.tool.status": status }));
|
||||
}
|
||||
}
|
||||
for (const errorType in summary.errors.byType) {
|
||||
const count = summary.errors.byType[errorType];
|
||||
if (count > 0) this.#errors.add(count, metricAttributes({ ...runAttrs, "error.type": errorType }));
|
||||
}
|
||||
}
|
||||
|
||||
#recordToken(value: number | undefined, baseAttrs: Attributes, tokenType: string): void {
|
||||
if (!value || value <= 0) return;
|
||||
this.#tokenUsage.record(value, metricAttributes({ ...baseAttrs, "gen_ai.token.type": tokenType }));
|
||||
}
|
||||
}
|
||||
|
||||
function metricAttributes(fields: Readonly<Record<string, unknown>>): Attributes {
|
||||
const out: Attributes = {};
|
||||
for (const key in fields) {
|
||||
const value = fields[key];
|
||||
if (value === undefined || value === null) continue;
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
out[key] = value;
|
||||
continue;
|
||||
}
|
||||
const text = String(value);
|
||||
if (text.length > 0) out[key] = text;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function emitRunSummaryLog(summary: AgentRunSummary, coverage: AgentRunCoverage): void {
|
||||
emitOtelLog(
|
||||
"info",
|
||||
"agent run completed",
|
||||
{
|
||||
"pi.omp.agent.step_count": summary.stepCount,
|
||||
"pi.omp.agent.chats.total": summary.chats.total,
|
||||
"pi.omp.agent.chats.total_latency_ms": summary.chats.totalLatencyMs,
|
||||
"pi.omp.agent.tools.total": summary.tools.total,
|
||||
"pi.omp.agent.tools.ok": summary.tools.ok,
|
||||
"pi.omp.agent.tools.error": summary.tools.error,
|
||||
"pi.omp.agent.tools.skipped": summary.tools.skipped,
|
||||
"pi.omp.agent.tools.blocked": summary.tools.blocked,
|
||||
"pi.omp.agent.tools.timeout": summary.tools.timeout,
|
||||
"pi.omp.agent.tools.aborted": summary.tools.aborted,
|
||||
"pi.omp.agent.tools.total_latency_ms": summary.tools.totalLatencyMs,
|
||||
"pi.omp.agent.usage.input_tokens": summary.usage.inputTokens,
|
||||
"pi.omp.agent.usage.output_tokens": summary.usage.outputTokens,
|
||||
"pi.omp.agent.usage.cached_input_tokens": summary.usage.cachedInputTokens,
|
||||
"pi.omp.agent.usage.cache_write_tokens": summary.usage.cacheWriteTokens,
|
||||
"pi.omp.agent.usage.reasoning_output_tokens": summary.usage.reasoningOutputTokens,
|
||||
"pi.omp.agent.usage.total_tokens": summary.usage.totalTokens,
|
||||
"pi.omp.agent.cost.estimated_usd": summary.cost.estimatedUsd,
|
||||
"pi.omp.agent.cost.unavailable_reasons": summary.cost.unavailableReasons.join(","),
|
||||
"pi.omp.agent.errors.total": summary.errors.total,
|
||||
"pi.omp.agent.coverage.tools_available": coverage.toolsAvailable.join(","),
|
||||
"pi.omp.agent.coverage.tools_invoked": coverage.toolsInvoked.join(","),
|
||||
"pi.omp.agent.coverage.tools_unused": coverage.toolsUnused.join(","),
|
||||
"pi.omp.agent.coverage.models_used": coverage.modelsUsed.join(","),
|
||||
"pi.omp.agent.coverage.providers_used": coverage.providersUsed.join(","),
|
||||
},
|
||||
"pi.omp.agent.run.completed",
|
||||
);
|
||||
}
|
||||
|
||||
function emitTelemetryWarningLog(warning: AgentTelemetryWarning): void {
|
||||
const attrs = logAttributesFromContext({
|
||||
code: warning.code,
|
||||
error: warning.error,
|
||||
});
|
||||
emitOtelLog("warn", warning.message, attrs, "pi.omp.telemetry.warning");
|
||||
}
|
||||
|
||||
function emitOtelLog(
|
||||
level: logger.LogLevel,
|
||||
body: string,
|
||||
attributes: LogAttributes,
|
||||
eventName: string,
|
||||
timestamp = new Date(),
|
||||
): void {
|
||||
if (!otelLogger) return;
|
||||
const minLevel = parseOtelLogLevel(process.env.OTEL_LOG_LEVEL);
|
||||
if (minLevel === "none") return;
|
||||
if (LOG_LEVEL_WEIGHT[level] > LOG_LEVEL_WEIGHT[minLevel]) return;
|
||||
otelLogger.emit({
|
||||
eventName,
|
||||
timestamp,
|
||||
observedTimestamp: new Date(),
|
||||
severityNumber: LOG_SEVERITY[level],
|
||||
severityText: level.toUpperCase(),
|
||||
body,
|
||||
attributes,
|
||||
context: context.active(),
|
||||
});
|
||||
}
|
||||
|
||||
function parseOtelLogLevel(raw: string | undefined): OtelLogLevel {
|
||||
if (!raw) return "info";
|
||||
switch (raw.trim().toLowerCase()) {
|
||||
case "none":
|
||||
return "none";
|
||||
case "error":
|
||||
return "error";
|
||||
case "warn":
|
||||
case "warning":
|
||||
return "warn";
|
||||
case "debug":
|
||||
return "debug";
|
||||
default:
|
||||
return "info";
|
||||
}
|
||||
}
|
||||
|
||||
function logAttributesFromContext(input: Record<string, unknown> | undefined): LogAttributes {
|
||||
const out: LogAttributes = { "process.pid": process.pid };
|
||||
if (!input) return out;
|
||||
for (const key in input) {
|
||||
const attr = logAttributeValue(input[key]);
|
||||
if (attr !== undefined) out[key] = attr;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function logAttributeValue(value: unknown): AttributeValue | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
||||
if (value instanceof Error) {
|
||||
return `${value.name}: ${value.message}`;
|
||||
}
|
||||
try {
|
||||
const text = JSON.stringify(value);
|
||||
if (text && text.length > 0) return text;
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** Flush buffered spans, log records, and metrics across all registered providers. */
|
||||
export async function flushTelemetryExport(): Promise<void> {
|
||||
const flushes: Promise<void>[] = [];
|
||||
if (traceProvider) flushes.push(traceProvider.forceFlush());
|
||||
if (logProvider) flushes.push(logProvider.forceFlush());
|
||||
if (meterProvider) flushes.push(meterProvider.forceFlush());
|
||||
await Promise.all(flushes);
|
||||
}
|
||||
@@ -3,86 +3,37 @@
|
||||
*
|
||||
* oh-my-pi's agent core (`@oh-my-pi/pi-agent-core`) emits OpenTelemetry GenAI
|
||||
* spans through the global `@opentelemetry/api` tracer, and exposes run-level
|
||||
* callbacks for metrics/log pipelines. This module registers the OTLP/proto
|
||||
* trace, log, and metric SDK providers when the standard `OTEL_*` endpoint env
|
||||
* vars are set so `omp` can be observed by any OTLP collector without vendor
|
||||
* coupling.
|
||||
* callbacks for metrics/log pipelines. This module resolves the standard
|
||||
* `OTEL_*` env contract (endpoint, exporter selection, protocol,
|
||||
* `OTEL_SDK_DISABLED`) and, only when at least one signal has an OTLP endpoint,
|
||||
* loads `./telemetry-export-otlp` to register the trace/log/metric providers —
|
||||
* keeping the OTel SDK + exporter module graph (~100ms) out of default startup.
|
||||
*
|
||||
* Only the `http/protobuf` transport is supported — an
|
||||
* `OTEL_EXPORTER_OTLP*_PROTOCOL` of `grpc` or `http/json` declines rather than
|
||||
* misrouting protobuf payloads. The exporter line is pinned to the 0.218/2.7
|
||||
* family validated under Bun; the 1.x OTLP line deadlocks when its
|
||||
* `req.on("close")` handler fires after a successful export.
|
||||
* misrouting protobuf payloads.
|
||||
*/
|
||||
import type {
|
||||
AgentRunCoverage,
|
||||
AgentRunSummary,
|
||||
AgentTelemetryConfig,
|
||||
AgentTelemetryWarning,
|
||||
ChatUsageEvent,
|
||||
ToolStatus,
|
||||
} from "@oh-my-pi/pi-agent-core";
|
||||
import { logger, postmortem } from "@oh-my-pi/pi-utils";
|
||||
import {
|
||||
type Attributes,
|
||||
type AttributeValue,
|
||||
type Counter,
|
||||
context,
|
||||
type Histogram,
|
||||
type Meter,
|
||||
metrics,
|
||||
} from "@opentelemetry/api";
|
||||
import { type LogAttributes, logs, type Logger as OtelLogger, SeverityNumber } from "@opentelemetry/api-logs";
|
||||
import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
|
||||
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
|
||||
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto";
|
||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
|
||||
import { detectResources, envDetector, resourceFromAttributes } from "@opentelemetry/resources";
|
||||
import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
|
||||
import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
||||
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
|
||||
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
|
||||
import type { AgentTelemetryConfig } from "@oh-my-pi/pi-agent-core";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
|
||||
/**
|
||||
* Periodic flush interval. A long-lived `omp` process (the ACP server is
|
||||
* spawned once and reused across many turns) would otherwise hold finished
|
||||
* telemetry until a batch window elapses or the process exits.
|
||||
*/
|
||||
const FLUSH_INTERVAL_MS = 30_000;
|
||||
|
||||
const SERVICE_NAME = "oh-my-pi";
|
||||
|
||||
type TelemetrySignal = "trace" | "log" | "metric";
|
||||
type OtelLogLevel = "none" | logger.LogLevel;
|
||||
|
||||
interface SignalConfig {
|
||||
/** Per-signal OTLP export toggles resolved from the `OTEL_*` env contract. */
|
||||
export interface TelemetrySignalConfig {
|
||||
readonly trace: boolean;
|
||||
readonly log: boolean;
|
||||
readonly metric: boolean;
|
||||
}
|
||||
|
||||
const LOG_SEVERITY: Record<logger.LogLevel, SeverityNumber> = {
|
||||
error: SeverityNumber.ERROR,
|
||||
warn: SeverityNumber.WARN,
|
||||
info: SeverityNumber.INFO,
|
||||
debug: SeverityNumber.DEBUG,
|
||||
};
|
||||
type TelemetrySignal = "trace" | "log" | "metric";
|
||||
|
||||
const LOG_LEVEL_WEIGHT: Record<logger.LogLevel, number> = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
debug: 3,
|
||||
};
|
||||
/** Loaded OTLP implementation module; `undefined` until a signal registers. */
|
||||
interface OtlpExportModule {
|
||||
registerProviders(signalConfig: TelemetrySignalConfig): Promise<void>;
|
||||
isTelemetryExportEnabled(): boolean;
|
||||
createTelemetryExportConfig(config: AgentTelemetryConfig | undefined): AgentTelemetryConfig | undefined;
|
||||
flushTelemetryExport(): Promise<void>;
|
||||
}
|
||||
|
||||
const TOOL_STATUSES = ["ok", "error", "skipped", "blocked", "timeout", "aborted"] satisfies readonly ToolStatus[];
|
||||
|
||||
let traceProvider: NodeTracerProvider | undefined;
|
||||
let logProvider: LoggerProvider | undefined;
|
||||
let meterProvider: MeterProvider | undefined;
|
||||
let metricRecorder: AgentMetricRecorder | undefined;
|
||||
let otelLogger: OtelLogger | undefined;
|
||||
let unregisterLogSink: (() => void) | undefined;
|
||||
let otlp: OtlpExportModule | undefined;
|
||||
let initPromise: Promise<void> | undefined;
|
||||
|
||||
/**
|
||||
@@ -92,10 +43,7 @@ let initPromise: Promise<void> | undefined;
|
||||
* disabled.
|
||||
*/
|
||||
export function isTelemetryExportEnabled(): boolean {
|
||||
if (traceProvider) return true;
|
||||
if (logProvider) return true;
|
||||
if (meterProvider) return true;
|
||||
return false;
|
||||
return otlp?.isTelemetryExportEnabled() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,28 +51,13 @@ export function isTelemetryExportEnabled(): boolean {
|
||||
*
|
||||
* The caller still owns content-capture policy, cost estimation, and custom
|
||||
* attributes. This only appends host-level metrics/log forwarding for the
|
||||
* providers registered by {@link initTelemetryExport}.
|
||||
* providers registered by {@link initTelemetryExport}; a passthrough when
|
||||
* export is disabled.
|
||||
*/
|
||||
export function createTelemetryExportConfig(
|
||||
config: AgentTelemetryConfig | undefined,
|
||||
): AgentTelemetryConfig | undefined {
|
||||
if (!isTelemetryExportEnabled()) return config;
|
||||
return {
|
||||
...config,
|
||||
onChatUsage: async event => {
|
||||
await config?.onChatUsage?.(event);
|
||||
metricRecorder?.recordChatUsage(event);
|
||||
},
|
||||
onRunEnd: (summary, coverage) => {
|
||||
config?.onRunEnd?.(summary, coverage);
|
||||
metricRecorder?.recordRun(summary, coverage);
|
||||
emitRunSummaryLog(summary, coverage);
|
||||
},
|
||||
onTelemetryWarning: warning => {
|
||||
config?.onTelemetryWarning?.(warning);
|
||||
emitTelemetryWarningLog(warning);
|
||||
},
|
||||
};
|
||||
return otlp ? otlp.createTelemetryExportConfig(config) : config;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,7 +66,6 @@ export function createTelemetryExportConfig(
|
||||
* the OTEL kill-switches are engaged), so startup can call it unconditionally.
|
||||
*/
|
||||
export async function initTelemetryExport(): Promise<void> {
|
||||
if (isTelemetryExportEnabled()) return;
|
||||
if (initPromise) return initPromise;
|
||||
|
||||
if (process.env.OTEL_SDK_DISABLED?.trim().toLowerCase() === "true") return;
|
||||
@@ -141,76 +73,26 @@ export async function initTelemetryExport(): Promise<void> {
|
||||
const signalConfig = resolveSignalConfig();
|
||||
if (!signalConfig.trace && !signalConfig.log && !signalConfig.metric) return;
|
||||
|
||||
initPromise = registerProviders(signalConfig);
|
||||
initPromise = (async () => {
|
||||
// Branch-only: the OTel SDK + OTLP exporter graph loads only when an endpoint is configured.
|
||||
const impl: OtlpExportModule = await import("./telemetry-export-otlp");
|
||||
await impl.registerProviders(signalConfig);
|
||||
otlp = impl;
|
||||
})();
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
async function registerProviders(signalConfig: SignalConfig): Promise<void> {
|
||||
// `envDetector` parses OTEL_RESOURCE_ATTRIBUTES (percent-decoded, per spec) and
|
||||
// OTEL_SERVICE_NAME; merged last so both take precedence over the fallback
|
||||
// service.name — with OTEL_SERVICE_NAME still winning service.name inside the
|
||||
// detector itself.
|
||||
const resource = resourceFromAttributes({ "service.name": SERVICE_NAME }).merge(
|
||||
detectResources({ detectors: [envDetector] }),
|
||||
);
|
||||
|
||||
if (signalConfig.trace) {
|
||||
const exporter = new OTLPTraceExporter();
|
||||
traceProvider = new NodeTracerProvider({
|
||||
resource,
|
||||
spanProcessors: [new BatchSpanProcessor(exporter)],
|
||||
});
|
||||
traceProvider.register({ contextManager: new AsyncLocalStorageContextManager().enable() });
|
||||
}
|
||||
|
||||
if (signalConfig.metric) {
|
||||
const exporter = new OTLPMetricExporter();
|
||||
meterProvider = new MeterProvider({
|
||||
resource,
|
||||
readers: [new PeriodicExportingMetricReader({ exporter })],
|
||||
});
|
||||
metrics.setGlobalMeterProvider(meterProvider);
|
||||
metricRecorder = new AgentMetricRecorder(metrics.getMeter("@oh-my-pi/pi-coding-agent"));
|
||||
}
|
||||
|
||||
if (signalConfig.log) {
|
||||
const exporter = new OTLPLogExporter();
|
||||
logProvider = new LoggerProvider({
|
||||
resource,
|
||||
processors: [new BatchLogRecordProcessor({ exporter })],
|
||||
});
|
||||
logs.setGlobalLoggerProvider(logProvider);
|
||||
otelLogger = logProvider.getLogger("@oh-my-pi/pi-coding-agent");
|
||||
unregisterLogSink = logger.registerLogSink(event => {
|
||||
emitOtelLog(
|
||||
event.level,
|
||||
event.message,
|
||||
logAttributesFromContext(event.context),
|
||||
"pi.omp.log",
|
||||
event.timestamp,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const flushTimer = setInterval(() => {
|
||||
flushTelemetryExport().catch(() => {});
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
flushTimer.unref();
|
||||
|
||||
postmortem.register("otel-export", async () => {
|
||||
clearInterval(flushTimer);
|
||||
unregisterLogSink?.();
|
||||
unregisterLogSink = undefined;
|
||||
const shutdowns: Promise<void>[] = [];
|
||||
if (traceProvider) shutdowns.push(traceProvider.shutdown());
|
||||
if (logProvider) shutdowns.push(logProvider.shutdown());
|
||||
if (meterProvider) shutdowns.push(meterProvider.shutdown());
|
||||
await Promise.all(shutdowns);
|
||||
});
|
||||
/**
|
||||
* Flush buffered spans, log records, and metrics. No-op when export is disabled.
|
||||
* Hosts embedding the agent can call this at natural boundaries (e.g. the end
|
||||
* of a turn) so telemetry surfaces promptly rather than on the batch interval.
|
||||
*/
|
||||
export async function flushTelemetryExport(): Promise<void> {
|
||||
if (otlp) await otlp.flushTelemetryExport();
|
||||
}
|
||||
|
||||
function resolveSignalConfig(): SignalConfig {
|
||||
const signalConfig: SignalConfig = {
|
||||
function resolveSignalConfig(): TelemetrySignalConfig {
|
||||
return {
|
||||
trace: signalEnabled(
|
||||
"trace",
|
||||
process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||
@@ -230,7 +112,6 @@ function resolveSignalConfig(): SignalConfig {
|
||||
process.env.OTEL_EXPORTER_OTLP_METRICS_PROTOCOL ?? process.env.OTEL_EXPORTER_OTLP_PROTOCOL,
|
||||
),
|
||||
};
|
||||
return signalConfig;
|
||||
}
|
||||
|
||||
function signalEnabled(
|
||||
@@ -255,250 +136,3 @@ function signalEnabled(
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
class AgentMetricRecorder {
|
||||
readonly #tokenUsage: Histogram<Attributes>;
|
||||
readonly #chatCostUsd: Counter<Attributes>;
|
||||
readonly #runs: Counter<Attributes>;
|
||||
readonly #steps: Counter<Attributes>;
|
||||
readonly #chatCalls: Counter<Attributes>;
|
||||
readonly #chatDurationMs: Histogram<Attributes>;
|
||||
readonly #toolCalls: Counter<Attributes>;
|
||||
readonly #toolDurationMs: Histogram<Attributes>;
|
||||
readonly #errors: Counter<Attributes>;
|
||||
|
||||
constructor(meter: Meter) {
|
||||
this.#tokenUsage = meter.createHistogram("gen_ai.client.token.usage", {
|
||||
description: "Token usage reported by GenAI chat calls.",
|
||||
unit: "{token}",
|
||||
});
|
||||
this.#chatCostUsd = meter.createCounter("pi.omp.agent.chat.cost.estimated_usd", {
|
||||
description: "Estimated USD cost for completed chat calls.",
|
||||
unit: "USD",
|
||||
});
|
||||
this.#runs = meter.createCounter("pi.omp.agent.runs", {
|
||||
description: "Completed agent runs.",
|
||||
unit: "{run}",
|
||||
});
|
||||
this.#steps = meter.createCounter("pi.omp.agent.steps", {
|
||||
description: "Agent loop steps completed inside a run.",
|
||||
unit: "{step}",
|
||||
});
|
||||
this.#chatCalls = meter.createCounter("pi.omp.agent.chat.calls", {
|
||||
description: "Chat calls completed inside agent runs.",
|
||||
unit: "{call}",
|
||||
});
|
||||
this.#chatDurationMs = meter.createHistogram("pi.omp.agent.chat.duration", {
|
||||
description: "Total chat latency observed in an agent run.",
|
||||
unit: "ms",
|
||||
});
|
||||
this.#toolCalls = meter.createCounter("pi.omp.agent.tool.calls", {
|
||||
description: "Tool calls completed inside agent runs.",
|
||||
unit: "{call}",
|
||||
});
|
||||
this.#toolDurationMs = meter.createHistogram("pi.omp.agent.tool.duration", {
|
||||
description: "Total tool latency observed in an agent run.",
|
||||
unit: "ms",
|
||||
});
|
||||
this.#errors = meter.createCounter("pi.omp.agent.errors", {
|
||||
description: "Errors observed in chat and tool execution.",
|
||||
unit: "{error}",
|
||||
});
|
||||
}
|
||||
|
||||
recordChatUsage(event: ChatUsageEvent): void {
|
||||
const baseAttrs = metricAttributes({
|
||||
"gen_ai.operation.name": "chat",
|
||||
"gen_ai.provider.name": event.provider,
|
||||
"gen_ai.request.model": event.model,
|
||||
"gen_ai.response.service_tier": event.serviceTier,
|
||||
"pi.gen_ai.agent.id": event.agent?.id,
|
||||
"pi.gen_ai.agent.name": event.agent?.name,
|
||||
});
|
||||
|
||||
this.#recordToken(event.usage.inputTokens, baseAttrs, "input");
|
||||
this.#recordToken(event.usage.outputTokens, baseAttrs, "output");
|
||||
this.#recordToken(event.usage.totalTokens, baseAttrs, "total");
|
||||
this.#recordToken(event.usage.cachedInputTokens, baseAttrs, "cache_read_input");
|
||||
this.#recordToken(event.usage.cacheWriteTokens, baseAttrs, "cache_write_input");
|
||||
this.#recordToken(event.usage.reasoningOutputTokens, baseAttrs, "reasoning_output");
|
||||
|
||||
if (event.cost && "usd" in event.cost && event.cost.usd > 0) {
|
||||
this.#chatCostUsd.add(event.cost.usd, baseAttrs);
|
||||
}
|
||||
}
|
||||
|
||||
recordRun(summary: AgentRunSummary, coverage: AgentRunCoverage): void {
|
||||
const runAttrs = metricAttributes({
|
||||
"pi.omp.agent.models_used.count": coverage.modelsUsed.length,
|
||||
"pi.omp.agent.providers_used.count": coverage.providersUsed.length,
|
||||
"pi.omp.agent.tools_available.count": coverage.toolsAvailable.length,
|
||||
"pi.omp.agent.tools_invoked.count": coverage.toolsInvoked.length,
|
||||
"pi.omp.agent.tools_unused.count": coverage.toolsUnused.length,
|
||||
});
|
||||
|
||||
this.#runs.add(1, runAttrs);
|
||||
if (summary.stepCount > 0) this.#steps.add(summary.stepCount, runAttrs);
|
||||
if (summary.chats.totalLatencyMs > 0) this.#chatDurationMs.record(summary.chats.totalLatencyMs, runAttrs);
|
||||
|
||||
for (const reason in summary.chats.byStopReason) {
|
||||
const count = summary.chats.byStopReason[reason];
|
||||
if (count > 0)
|
||||
this.#chatCalls.add(count, metricAttributes({ ...runAttrs, "gen_ai.response.finish_reason": reason }));
|
||||
}
|
||||
for (const toolName in summary.tools.byName) {
|
||||
const counters = summary.tools.byName[toolName];
|
||||
const toolAttrs = metricAttributes({ ...runAttrs, "gen_ai.tool.name": toolName });
|
||||
if (counters.totalLatencyMs > 0) this.#toolDurationMs.record(counters.totalLatencyMs, toolAttrs);
|
||||
for (const status of TOOL_STATUSES) {
|
||||
const count = counters[status];
|
||||
if (count > 0) this.#toolCalls.add(count, metricAttributes({ ...toolAttrs, "pi.omp.tool.status": status }));
|
||||
}
|
||||
}
|
||||
for (const errorType in summary.errors.byType) {
|
||||
const count = summary.errors.byType[errorType];
|
||||
if (count > 0) this.#errors.add(count, metricAttributes({ ...runAttrs, "error.type": errorType }));
|
||||
}
|
||||
}
|
||||
|
||||
#recordToken(value: number | undefined, baseAttrs: Attributes, tokenType: string): void {
|
||||
if (!value || value <= 0) return;
|
||||
this.#tokenUsage.record(value, metricAttributes({ ...baseAttrs, "gen_ai.token.type": tokenType }));
|
||||
}
|
||||
}
|
||||
|
||||
function metricAttributes(fields: Readonly<Record<string, unknown>>): Attributes {
|
||||
const out: Attributes = {};
|
||||
for (const key in fields) {
|
||||
const value = fields[key];
|
||||
if (value === undefined || value === null) continue;
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
out[key] = value;
|
||||
continue;
|
||||
}
|
||||
const text = String(value);
|
||||
if (text.length > 0) out[key] = text;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function emitRunSummaryLog(summary: AgentRunSummary, coverage: AgentRunCoverage): void {
|
||||
emitOtelLog(
|
||||
"info",
|
||||
"agent run completed",
|
||||
{
|
||||
"pi.omp.agent.step_count": summary.stepCount,
|
||||
"pi.omp.agent.chats.total": summary.chats.total,
|
||||
"pi.omp.agent.chats.total_latency_ms": summary.chats.totalLatencyMs,
|
||||
"pi.omp.agent.tools.total": summary.tools.total,
|
||||
"pi.omp.agent.tools.ok": summary.tools.ok,
|
||||
"pi.omp.agent.tools.error": summary.tools.error,
|
||||
"pi.omp.agent.tools.skipped": summary.tools.skipped,
|
||||
"pi.omp.agent.tools.blocked": summary.tools.blocked,
|
||||
"pi.omp.agent.tools.timeout": summary.tools.timeout,
|
||||
"pi.omp.agent.tools.aborted": summary.tools.aborted,
|
||||
"pi.omp.agent.tools.total_latency_ms": summary.tools.totalLatencyMs,
|
||||
"pi.omp.agent.usage.input_tokens": summary.usage.inputTokens,
|
||||
"pi.omp.agent.usage.output_tokens": summary.usage.outputTokens,
|
||||
"pi.omp.agent.usage.cached_input_tokens": summary.usage.cachedInputTokens,
|
||||
"pi.omp.agent.usage.cache_write_tokens": summary.usage.cacheWriteTokens,
|
||||
"pi.omp.agent.usage.reasoning_output_tokens": summary.usage.reasoningOutputTokens,
|
||||
"pi.omp.agent.usage.total_tokens": summary.usage.totalTokens,
|
||||
"pi.omp.agent.cost.estimated_usd": summary.cost.estimatedUsd,
|
||||
"pi.omp.agent.cost.unavailable_reasons": summary.cost.unavailableReasons.join(","),
|
||||
"pi.omp.agent.errors.total": summary.errors.total,
|
||||
"pi.omp.agent.coverage.tools_available": coverage.toolsAvailable.join(","),
|
||||
"pi.omp.agent.coverage.tools_invoked": coverage.toolsInvoked.join(","),
|
||||
"pi.omp.agent.coverage.tools_unused": coverage.toolsUnused.join(","),
|
||||
"pi.omp.agent.coverage.models_used": coverage.modelsUsed.join(","),
|
||||
"pi.omp.agent.coverage.providers_used": coverage.providersUsed.join(","),
|
||||
},
|
||||
"pi.omp.agent.run.completed",
|
||||
);
|
||||
}
|
||||
|
||||
function emitTelemetryWarningLog(warning: AgentTelemetryWarning): void {
|
||||
const attrs = logAttributesFromContext({
|
||||
code: warning.code,
|
||||
error: warning.error,
|
||||
});
|
||||
emitOtelLog("warn", warning.message, attrs, "pi.omp.telemetry.warning");
|
||||
}
|
||||
|
||||
function emitOtelLog(
|
||||
level: logger.LogLevel,
|
||||
body: string,
|
||||
attributes: LogAttributes,
|
||||
eventName: string,
|
||||
timestamp = new Date(),
|
||||
): void {
|
||||
if (!otelLogger) return;
|
||||
const minLevel = parseOtelLogLevel(process.env.OTEL_LOG_LEVEL);
|
||||
if (minLevel === "none") return;
|
||||
if (LOG_LEVEL_WEIGHT[level] > LOG_LEVEL_WEIGHT[minLevel]) return;
|
||||
otelLogger.emit({
|
||||
eventName,
|
||||
timestamp,
|
||||
observedTimestamp: new Date(),
|
||||
severityNumber: LOG_SEVERITY[level],
|
||||
severityText: level.toUpperCase(),
|
||||
body,
|
||||
attributes,
|
||||
context: context.active(),
|
||||
});
|
||||
}
|
||||
|
||||
function parseOtelLogLevel(raw: string | undefined): OtelLogLevel {
|
||||
if (!raw) return "info";
|
||||
switch (raw.trim().toLowerCase()) {
|
||||
case "none":
|
||||
return "none";
|
||||
case "error":
|
||||
return "error";
|
||||
case "warn":
|
||||
case "warning":
|
||||
return "warn";
|
||||
case "debug":
|
||||
return "debug";
|
||||
default:
|
||||
return "info";
|
||||
}
|
||||
}
|
||||
|
||||
function logAttributesFromContext(input: Record<string, unknown> | undefined): LogAttributes {
|
||||
const out: LogAttributes = { "process.pid": process.pid };
|
||||
if (!input) return out;
|
||||
for (const key in input) {
|
||||
const attr = logAttributeValue(input[key]);
|
||||
if (attr !== undefined) out[key] = attr;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function logAttributeValue(value: unknown): AttributeValue | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
||||
if (value instanceof Error) {
|
||||
return `${value.name}: ${value.message}`;
|
||||
}
|
||||
try {
|
||||
const text = JSON.stringify(value);
|
||||
if (text && text.length > 0) return text;
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush buffered spans, log records, and metrics. No-op when export is disabled.
|
||||
* Hosts embedding the agent can call this at natural boundaries (e.g. the end
|
||||
* of a turn) so telemetry surfaces promptly rather than on the batch interval.
|
||||
*/
|
||||
export async function flushTelemetryExport(): Promise<void> {
|
||||
const flushes: Promise<void>[] = [];
|
||||
if (traceProvider) flushes.push(traceProvider.forceFlush());
|
||||
if (logProvider) flushes.push(logProvider.forceFlush());
|
||||
if (meterProvider) flushes.push(meterProvider.forceFlush());
|
||||
await Promise.all(flushes);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as path from "node:path";
|
||||
import { getLastChangelogVersionPath, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
||||
import type { BunFile } from "bun";
|
||||
import bundledChangelogPath from "../../CHANGELOG.md" with { type: "file" };
|
||||
import type { SettingValue } from "../config/settings";
|
||||
|
||||
@@ -197,6 +198,88 @@ function parseChangelogContent(content: string): ChangelogEntry[] {
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function parseStartupChangelog(
|
||||
changelogPath: string | undefined,
|
||||
lastVersion: ChangelogEntry,
|
||||
): Promise<{ entries: ChangelogEntry[]; totalUnseenEntries: number }> {
|
||||
if (changelogPath) {
|
||||
try {
|
||||
return await parseStartupChangelogFile(Bun.file(changelogPath), lastVersion);
|
||||
} catch (error) {
|
||||
if (!isEnoent(error)) {
|
||||
logger.error(`Warning: Could not parse changelog: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parseStartupChangelogFile(
|
||||
Bun.file(resolveBundledChangelogPath(bundledChangelogPath, import.meta.url)),
|
||||
lastVersion,
|
||||
);
|
||||
}
|
||||
|
||||
async function parseStartupChangelogFile(
|
||||
file: BunFile,
|
||||
lastVersion: ChangelogEntry,
|
||||
): Promise<{ entries: ChangelogEntry[]; totalUnseenEntries: number }> {
|
||||
const entries: ChangelogEntry[] = [];
|
||||
let currentVersion: ChangelogEntry | undefined;
|
||||
let currentLines: string[] | undefined;
|
||||
let totalUnseenEntries = 0;
|
||||
|
||||
const finishCurrentEntry = () => {
|
||||
if (currentVersion && currentLines) {
|
||||
entries.push({ ...currentVersion, content: currentLines.join("\n").trim() });
|
||||
}
|
||||
currentVersion = undefined;
|
||||
currentLines = undefined;
|
||||
};
|
||||
const processLine = (line: string): boolean => {
|
||||
if (!line.startsWith("## ")) {
|
||||
currentLines?.push(line);
|
||||
return false;
|
||||
}
|
||||
|
||||
finishCurrentEntry();
|
||||
const versionMatch = line.match(/##\s+\[?(\d+)\.(\d+)\.(\d+)\]?/);
|
||||
if (!versionMatch) return false;
|
||||
|
||||
const version = {
|
||||
major: Number.parseInt(versionMatch[1], 10),
|
||||
minor: Number.parseInt(versionMatch[2], 10),
|
||||
patch: Number.parseInt(versionMatch[3], 10),
|
||||
content: "",
|
||||
};
|
||||
if (compareChangelogEntries(version, lastVersion) <= 0) return true;
|
||||
|
||||
totalUnseenEntries++;
|
||||
if (entries.length < RECENT_CHANGELOG_ENTRY_LIMIT) {
|
||||
currentVersion = version;
|
||||
currentLines = [line];
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let pending = "";
|
||||
const chunkSize = 32 * 1024;
|
||||
for (let start = 0; start < file.size; start += chunkSize) {
|
||||
const end = Math.min(start + chunkSize, file.size);
|
||||
pending += decoder.decode(await file.slice(start, end).arrayBuffer(), { stream: end < file.size });
|
||||
let newlineIndex = pending.indexOf("\n");
|
||||
while (newlineIndex !== -1) {
|
||||
if (processLine(pending.slice(0, newlineIndex))) {
|
||||
return { entries, totalUnseenEntries };
|
||||
}
|
||||
pending = pending.slice(newlineIndex + 1);
|
||||
newlineIndex = pending.indexOf("\n");
|
||||
}
|
||||
}
|
||||
if (pending && !processLine(pending + decoder.decode())) {
|
||||
finishCurrentEntry();
|
||||
}
|
||||
return { entries, totalUnseenEntries };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare changelog entries by their parsed version parts.
|
||||
* Returns: -1 if v1 < v2, 0 if v1 === v2, 1 if v1 > v2
|
||||
@@ -264,6 +347,32 @@ export function renderChangelogEntries(
|
||||
return { markdown: markdown.slice(0, low) + suffix, truncated: true };
|
||||
}
|
||||
|
||||
function selectStartupChangelogEntries(
|
||||
newEntries: ChangelogEntry[],
|
||||
totalUnseenEntries: number,
|
||||
): StartupChangelogSelection {
|
||||
if (newEntries.length === 0) {
|
||||
return emptyStartupSelection(false);
|
||||
}
|
||||
|
||||
const rendered = renderChangelogEntries(newEntries, {
|
||||
maxBytes: STARTUP_CHANGELOG_MAX_BYTES,
|
||||
truncationHint: STARTUP_CHANGELOG_FULL_HINT,
|
||||
oldestFirst: false,
|
||||
});
|
||||
const summary = summarizeChangelogEntries(newEntries);
|
||||
const latestEntry = newEntries[0];
|
||||
return {
|
||||
markdown: rendered.markdown,
|
||||
persistCurrentVersion: true,
|
||||
truncated: rendered.truncated,
|
||||
selectedEntries: newEntries.length,
|
||||
totalUnseenEntries,
|
||||
latestVersion: latestEntry ? `${latestEntry.major}.${latestEntry.minor}.${latestEntry.patch}` : undefined,
|
||||
...summary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Select bounded release notes for interactive startup.
|
||||
*/
|
||||
@@ -282,27 +391,7 @@ export function selectStartupChangelog(
|
||||
}
|
||||
|
||||
const allNewEntries = getNewEntries(entries, markerVersion);
|
||||
const newEntries = allNewEntries.slice(0, RECENT_CHANGELOG_ENTRY_LIMIT);
|
||||
if (newEntries.length === 0) {
|
||||
return emptyStartupSelection(false);
|
||||
}
|
||||
|
||||
const rendered = renderChangelogEntries(newEntries, {
|
||||
maxBytes: STARTUP_CHANGELOG_MAX_BYTES,
|
||||
truncationHint: STARTUP_CHANGELOG_FULL_HINT,
|
||||
oldestFirst: false,
|
||||
});
|
||||
const summary = summarizeChangelogEntries(newEntries);
|
||||
const latestEntry = newEntries[0];
|
||||
return {
|
||||
markdown: rendered.markdown,
|
||||
persistCurrentVersion: true,
|
||||
truncated: rendered.truncated,
|
||||
selectedEntries: newEntries.length,
|
||||
totalUnseenEntries: allNewEntries.length,
|
||||
latestVersion: latestEntry ? `${latestEntry.major}.${latestEntry.minor}.${latestEntry.patch}` : undefined,
|
||||
...summary,
|
||||
};
|
||||
return selectStartupChangelogEntries(allNewEntries.slice(0, RECENT_CHANGELOG_ENTRY_LIMIT), allNewEntries.length);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -334,9 +423,8 @@ export async function resolveStartupChangelogForDisplay(options: {
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entries = await parseChangelog(options.changelogPath);
|
||||
const startupChangelog = selectStartupChangelog(entries, lastVersion, options.currentVersion);
|
||||
const { entries, totalUnseenEntries } = await parseStartupChangelog(options.changelogPath, parsedLastVersion);
|
||||
const startupChangelog = selectStartupChangelogEntries(entries, totalUnseenEntries);
|
||||
if (startupChangelog.persistCurrentVersion) {
|
||||
await writeLastChangelogVersion(options.currentVersion, options.agentDir);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { create, fromBinary } from "@bufbuild/protobuf";
|
||||
import { create, fromBinary } from "@oh-my-pi/pi-catalog/discovery/protobuf";
|
||||
import { type } from "@oh-my-pi/omptype";
|
||||
import type { AgentEvent, AgentTool, AgentToolContext } from "@oh-my-pi/pi-agent-core";
|
||||
import { type BlockState, handleServerMessage, type ToolCallState } from "@oh-my-pi/pi-ai/providers/cursor";
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
McpArgsSchema,
|
||||
ReadArgsSchema,
|
||||
ShellArgsSchema,
|
||||
} from "@oh-my-pi/pi-catalog/discovery/cursor-gen/agent_pb";
|
||||
} from "@oh-my-pi/pi-catalog/discovery/cursor-proto";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { CursorExecHandlers } from "@oh-my-pi/pi-coding-agent/cursor";
|
||||
import {
|
||||
|
||||
@@ -401,6 +401,32 @@ describe("legacy Pi Babel AST behavior baseline", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps mixed import and require rewrites byte-identical after a cached analysis", async () => {
|
||||
const source = [
|
||||
'import value from "tracked-dep";',
|
||||
'export { value as named } from "tracked-dep";',
|
||||
'const required = require("tracked-dep");',
|
||||
].join("\n");
|
||||
const expected = [
|
||||
`import value from ${JSON.stringify(importTarget)};`,
|
||||
`export { value as named } from ${JSON.stringify(importTarget)};`,
|
||||
`const required = require(${JSON.stringify(requireTarget)});`,
|
||||
].join("\n");
|
||||
expect(await __rewriteLegacyExtensionSourceForTests(source, rewriteImporter)).toBe(expected);
|
||||
expect(await __rewriteLegacyExtensionSourceForTests(source, rewriteImporter)).toBe(expected);
|
||||
});
|
||||
|
||||
test("uses a fresh analysis when extension source content changes", async () => {
|
||||
const original = 'import value from "tracked-dep";';
|
||||
const changed = 'const value = require("tracked-dep");';
|
||||
expect(await __rewriteLegacyExtensionSourceForTests(original, rewriteImporter)).toBe(
|
||||
`import value from ${JSON.stringify(importTarget)};`,
|
||||
);
|
||||
expect(await __rewriteLegacyExtensionSourceForTests(changed, rewriteImporter)).toBe(
|
||||
`const value = require(${JSON.stringify(requireTarget)});`,
|
||||
);
|
||||
});
|
||||
|
||||
test("discovers exact CommonJS named exports with Babel binding semantics", async () => {
|
||||
for (const testCase of commonJsCases) {
|
||||
const actual = await loadCommonJsCase(testCase);
|
||||
|
||||
@@ -364,6 +364,36 @@ describe("StatusLineComponent context breakdown", () => {
|
||||
settings.clearOverride("statusLine.preset");
|
||||
}
|
||||
});
|
||||
it("embedded overflow (>100%) breaks the raw percent past the window label in error color", () => {
|
||||
const { session } = makeSession({
|
||||
messages: [userMessage("hi"), assistantMessage("done")],
|
||||
contextWindow: 200_000,
|
||||
usage: { tokens: 240_000, contextWindow: 200_000, percent: 120 },
|
||||
});
|
||||
settings.override("statusLine.preset", "custom");
|
||||
settings.override("statusLine.leftSegments", ["pi", "context_pct"]);
|
||||
settings.override("statusLine.rightSegments", ["context_total", "session_name"]);
|
||||
settings.override("statusLine.contextLine", "embedded");
|
||||
|
||||
try {
|
||||
const comp = new StatusLineComponent(session);
|
||||
const border = comp.getTopBorder(120);
|
||||
const plain = border.content.replaceAll(/\x1b\[[0-9;]*m/g, "");
|
||||
const windowIndex = plain.indexOf("200K");
|
||||
const percentIndex = plain.indexOf("120%");
|
||||
expect(border.width).toBe(120);
|
||||
expect(windowIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(percentIndex).toBeGreaterThan(windowIndex);
|
||||
// The clamped label must not render alongside the overflow one.
|
||||
expect(plain).not.toContain("100%");
|
||||
expect(border.content).toContain(`${theme.getFgAnsi("error")}120%`);
|
||||
} finally {
|
||||
settings.clearOverride("statusLine.contextLine");
|
||||
settings.clearOverride("statusLine.rightSegments");
|
||||
settings.clearOverride("statusLine.leftSegments");
|
||||
settings.clearOverride("statusLine.preset");
|
||||
}
|
||||
});
|
||||
it("uses semantic Nerd Font markers for async speculation and compaction boundaries", async () => {
|
||||
const { session } = makeSession({
|
||||
messages: [userMessage("hi"), assistantMessage("done")],
|
||||
|
||||
@@ -11,6 +11,11 @@ let tempDir: string;
|
||||
let testSettings: Settings;
|
||||
const GENERATED_TYPESCRIPT = "// Code generated by sqlc. DO NOT EDIT.\n\nexport const foo = 1;";
|
||||
|
||||
const CATALOG_PROTOCOL_DECLARATIONS = [
|
||||
path.resolve(import.meta.dir, "../../../catalog/src/discovery/cursor-proto.ts"),
|
||||
path.resolve(import.meta.dir, "../../../catalog/src/discovery/devin-proto.ts"),
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
resetSettingsForTest();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "auto-gen-guard-"));
|
||||
@@ -80,6 +85,12 @@ describe("assertEditableFile", () => {
|
||||
await expect(assertEditableFile(filePath, undefined, testSettings)).rejects.toBeInstanceOf(ToolError);
|
||||
});
|
||||
|
||||
it("blocks generated catalog protocol declarations", async () => {
|
||||
for (const filePath of CATALOG_PROTOCOL_DECLARATIONS) {
|
||||
await expect(assertEditableFile(filePath, undefined, testSettings)).rejects.toBeInstanceOf(ToolError);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows normal files", async () => {
|
||||
const filePath = path.join(tempDir, "normal.ts");
|
||||
await Bun.write(filePath, "// Regular source file\nexport const foo = 1;");
|
||||
|
||||
Reference in New Issue
Block a user