From 5507d76ee84124d907fd1f84ae5fea9ca641d777 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 26 Aug 2026 22:02:31 +0200 Subject: [PATCH] feat(agent,ai): add narrow subpath exports so clients skip the barrel A barrel forces Node to evaluate its whole graph: importing reduceLaneSnapshot (1 file, 6 KB) through pi-agent-core's index evaluated 106 files, and mini's server loaded 112 files to enumerate JSONL headers. Types are free, so this only ever affected value imports. pi-ai gains ./utils/* alongside the existing ./providers/* and ./api/* wildcards. pi-agent-core gains ./harness/context, ./harness/env/nodejs, ./harness/runtime/reducer and ./harness/session, and ./session/testing becomes ./harness/session/testing so every export mirrors its source path. The barrel and ./node keep their batteries-included semantics. The session layer takes uuidv7 from @earendil-works/pi-ai/utils/uuid rather than the pi-ai barrel, which is what let ./harness/session drop from 42 files to 19. Two imports there were written `import { type Usage } from` - not a type-only statement, so it emitted a runtime import of the barrel. scripts/check-entry-graphs.mjs walks each declared entry's value-import graph and enforces per-entry budgets and forbidden paths, so a stray `export *` cannot silently undo this. It caught the two `import { type ... }` regressions above. Measured with mini: server 112 files and 98 MB to 28 files and 69 MB, presentation 204 files and 125 MB to 99 files and 110 MB. The worker still loads the provider graph, correctly. --- package.json | 3 +- packages/agent/package.json | 18 ++- .../src/harness/session/jsonl/legacy-v3.ts | 3 +- .../agent/src/harness/session/jsonl/repo.ts | 2 +- .../src/harness/session/jsonl/storage.ts | 3 +- packages/agent/src/harness/session/memory.ts | 2 +- packages/agent/src/harness/session/session.ts | 2 +- packages/ai/package.json | 4 + .../src/experimental/mini/server/run.ts | 6 +- .../src/experimental/mini/tui/session.ts | 4 +- .../benchmark/session/session-repo.bench.ts | 2 +- .../benchmark/session/storage-targets.ts | 2 +- .../benchmark/session/storage.bench.ts | 2 +- .../sqlite-node/test/repo-conformance.test.ts | 5 +- .../test/storage-conformance.test.ts | 2 +- scripts/check-entry-graphs.mjs | 139 ++++++++++++++++++ tsconfig.json | 8 +- 17 files changed, 190 insertions(+), 17 deletions(-) create mode 100644 scripts/check-entry-graphs.mjs diff --git a/package.json b/package.json index e981d3ab5..f05205167 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,13 @@ "clean": "npm run clean --workspaces", "build": "cd packages/tui && npm run build && cd ../telemetry && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build", "build:offline": "cd packages/tui && npm run build && cd ../telemetry && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build", - "check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:shrinkwrap && npm run check:install-lock:coding-agent && tsgo --noEmit && npm run check:browser-smoke", + "check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:ts-imports && npm run check:entry-graphs && npm run check:shrinkwrap && npm run check:install-lock:coding-agent && tsgo --noEmit && npm run check:browser-smoke", "check:browser-smoke": "node scripts/check-browser-smoke.mjs", "check:pinned-deps": "node scripts/check-pinned-deps.mjs", "check:shrinkwrap": "node scripts/generate-coding-agent-shrinkwrap.mjs --check", "check:install-lock:coding-agent": "node scripts/generate-coding-agent-install-lock.mjs --check", "check:ts-imports": "node scripts/check-ts-relative-imports.mjs", + "check:entry-graphs": "node scripts/check-entry-graphs.mjs", "generate:models": "npm --prefix packages/ai run generate-models && npm --prefix packages/ai run generate-image-models", "hydrate:model-data": "npm --prefix packages/ai run hydrate-model-data", "check:model-data": "npm --prefix packages/ai run check:model-data", diff --git a/packages/agent/package.json b/packages/agent/package.json index 1f8f96da1..297a760d4 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -14,7 +14,23 @@ "types": "./dist/node.d.ts", "import": "./dist/node.js" }, - "./session/testing": { + "./harness/context": { + "types": "./dist/harness/context.d.ts", + "import": "./dist/harness/context.js" + }, + "./harness/env/nodejs": { + "types": "./dist/harness/env/nodejs.d.ts", + "import": "./dist/harness/env/nodejs.js" + }, + "./harness/runtime/reducer": { + "types": "./dist/harness/runtime/reducer.d.ts", + "import": "./dist/harness/runtime/reducer.js" + }, + "./harness/session": { + "types": "./dist/harness/session/index.d.ts", + "import": "./dist/harness/session/index.js" + }, + "./harness/session/testing": { "types": "./dist/harness/session/testing/index.d.ts", "import": "./dist/harness/session/testing/index.js" }, diff --git a/packages/agent/src/harness/session/jsonl/legacy-v3.ts b/packages/agent/src/harness/session/jsonl/legacy-v3.ts index 4dfb0f980..4ff1ea5ab 100644 --- a/packages/agent/src/harness/session/jsonl/legacy-v3.ts +++ b/packages/agent/src/harness/session/jsonl/legacy-v3.ts @@ -1,4 +1,5 @@ -import { type ImageContent, type TextContent, type Usage, uuidv7 } from "@earendil-works/pi-ai"; +import type { ImageContent, TextContent, Usage } from "@earendil-works/pi-ai"; +import { uuidv7 } from "@earendil-works/pi-ai/utils/uuid"; import type { AgentMessage, ThinkingLevel } from "../../../types.ts"; import type { Context } from "../../context.ts"; import { createBranchSummaryMessage, createCompactionSummaryMessage } from "../../messages.ts"; diff --git a/packages/agent/src/harness/session/jsonl/repo.ts b/packages/agent/src/harness/session/jsonl/repo.ts index f32ae4f99..e1006e10f 100644 --- a/packages/agent/src/harness/session/jsonl/repo.ts +++ b/packages/agent/src/harness/session/jsonl/repo.ts @@ -1,4 +1,4 @@ -import { uuidv7 } from "@earendil-works/pi-ai"; +import { uuidv7 } from "@earendil-works/pi-ai/utils/uuid"; import type { Context } from "../../context.ts"; import type { FileError, FileInfo, FileSystem, Result } from "../../types.ts"; import { createForkSnapshot, type ForkSourceSnapshot } from "../fork.ts"; diff --git a/packages/agent/src/harness/session/jsonl/storage.ts b/packages/agent/src/harness/session/jsonl/storage.ts index e79679b71..71ef72b27 100644 --- a/packages/agent/src/harness/session/jsonl/storage.ts +++ b/packages/agent/src/harness/session/jsonl/storage.ts @@ -1,4 +1,5 @@ -import { type Usage, uuidv7 } from "@earendil-works/pi-ai"; +import type { Usage } from "@earendil-works/pi-ai"; +import { uuidv7 } from "@earendil-works/pi-ai/utils/uuid"; import type { Context } from "../../context.ts"; import type { FileError, FileSystem, Result } from "../../types.ts"; import { insertUsage } from "../commit.ts"; diff --git a/packages/agent/src/harness/session/memory.ts b/packages/agent/src/harness/session/memory.ts index 4bca06f41..ec836450a 100644 --- a/packages/agent/src/harness/session/memory.ts +++ b/packages/agent/src/harness/session/memory.ts @@ -1,4 +1,4 @@ -import { uuidv7 } from "@earendil-works/pi-ai"; +import { uuidv7 } from "@earendil-works/pi-ai/utils/uuid"; import type { Context } from "../context.ts"; import { createForkSnapshot, diff --git a/packages/agent/src/harness/session/session.ts b/packages/agent/src/harness/session/session.ts index 54a129f0f..cdc74a3a2 100644 --- a/packages/agent/src/harness/session/session.ts +++ b/packages/agent/src/harness/session/session.ts @@ -1,4 +1,4 @@ -import { uuidv7 } from "@earendil-works/pi-ai"; +import { uuidv7 } from "@earendil-works/pi-ai/utils/uuid"; import type { AgentMessage } from "../../types.ts"; import type { Context } from "../context.ts"; import { insertEntry } from "./commit.ts"; diff --git a/packages/ai/package.json b/packages/ai/package.json index 70c8d4986..69137f244 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -27,6 +27,10 @@ "types": "./dist/api/*.d.ts", "import": "./dist/api/*.js" }, + "./utils/*": { + "types": "./dist/utils/*.d.ts", + "import": "./dist/utils/*.js" + }, "./oauth": { "types": "./dist/oauth.d.ts", "import": "./dist/oauth.js" diff --git a/packages/coding-agent/src/experimental/mini/server/run.ts b/packages/coding-agent/src/experimental/mini/server/run.ts index 10f421f48..474b66f0c 100644 --- a/packages/coding-agent/src/experimental/mini/server/run.ts +++ b/packages/coding-agent/src/experimental/mini/server/run.ts @@ -8,8 +8,10 @@ import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { BACKGROUND_CONTEXT, JsonlSessionRepo } from "@earendil-works/pi-agent-core"; -import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; +// Narrow entries: the server routes and lists sessions, it never runs an agent. +import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core/harness/context"; +import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/harness/env/nodejs"; +import { JsonlSessionRepo } from "@earendil-works/pi-agent-core/harness/session"; import { type SessionSummary, Sessions, type SessionsServiceApi, Worker } from "../shared/protocol.ts"; import { createPeer, type RpcPeer } from "../shared/rpc.ts"; import { childConnection, type Transport } from "../shared/transport.ts"; diff --git a/packages/coding-agent/src/experimental/mini/tui/session.ts b/packages/coding-agent/src/experimental/mini/tui/session.ts index 237e69f64..6487ce1ed 100644 --- a/packages/coding-agent/src/experimental/mini/tui/session.ts +++ b/packages/coding-agent/src/experimental/mini/tui/session.ts @@ -12,7 +12,9 @@ */ import { randomUUID } from "node:crypto"; -import { type HarnessEvent, reduceLaneSnapshot } from "@earendil-works/pi-agent-core"; +import type { HarnessEvent } from "@earendil-works/pi-agent-core"; +// Narrow entry: the fold is one file, so a presentation never evaluates the harness barrel. +import { reduceLaneSnapshot } from "@earendil-works/pi-agent-core/harness/runtime/reducer"; import { type AuthEventPayload, Lane, diff --git a/packages/session-backends/sqlite-node/benchmark/session/session-repo.bench.ts b/packages/session-backends/sqlite-node/benchmark/session/session-repo.bench.ts index 015d7d996..06998323f 100644 --- a/packages/session-backends/sqlite-node/benchmark/session/session-repo.bench.ts +++ b/packages/session-backends/sqlite-node/benchmark/session/session-repo.bench.ts @@ -6,7 +6,7 @@ import { SESSION_REPO_FORK_WRITE_BENCHMARK_SCENARIOS, seedSessionRepoCatalogBenchmark, seedSessionRepoForkBenchmark, -} from "@earendil-works/pi-agent-core/session/testing"; +} from "@earendil-works/pi-agent-core/harness/session/testing"; import { registerReadBenchmarks, registerWriteBenchmarks } from "../../../../agent/benchmark/session/benchmark.ts"; import { sessionRepoBenchmarkTargets } from "./session-repo-targets.ts"; diff --git a/packages/session-backends/sqlite-node/benchmark/session/storage-targets.ts b/packages/session-backends/sqlite-node/benchmark/session/storage-targets.ts index 70e49f9bb..3f0ac52a4 100644 --- a/packages/session-backends/sqlite-node/benchmark/session/storage-targets.ts +++ b/packages/session-backends/sqlite-node/benchmark/session/storage-targets.ts @@ -1,5 +1,5 @@ import { BACKGROUND_CONTEXT } from "@earendil-works/pi-agent-core"; -import type { StorageFixture } from "@earendil-works/pi-agent-core/session/testing"; +import type { StorageFixture } from "@earendil-works/pi-agent-core/harness/session/testing"; import { createNodeSqliteFactory, SQLITE_STORAGE_VERSION, SqliteStorage, sql } from "../../src/index.ts"; import { applyInitialSchema } from "../../src/sqlite/migrations.ts"; import type { BenchmarkTarget } from "../../../../agent/benchmark/session/benchmark.ts"; diff --git a/packages/session-backends/sqlite-node/benchmark/session/storage.bench.ts b/packages/session-backends/sqlite-node/benchmark/session/storage.bench.ts index a5d8bac61..896838be5 100644 --- a/packages/session-backends/sqlite-node/benchmark/session/storage.bench.ts +++ b/packages/session-backends/sqlite-node/benchmark/session/storage.bench.ts @@ -3,7 +3,7 @@ import { STORAGE_READ_BENCHMARK_SCENARIOS, STORAGE_WRITE_BENCHMARK_SCENARIOS, seedStorageBenchmark, -} from "@earendil-works/pi-agent-core/session/testing"; +} from "@earendil-works/pi-agent-core/harness/session/testing"; import { registerReadBenchmarks, registerWriteBenchmarks } from "../../../../agent/benchmark/session/benchmark.ts"; import { storageBenchmarkTargets } from "./storage-targets.ts"; diff --git a/packages/session-backends/sqlite-node/test/repo-conformance.test.ts b/packages/session-backends/sqlite-node/test/repo-conformance.test.ts index 68b4e5f23..f32842284 100644 --- a/packages/session-backends/sqlite-node/test/repo-conformance.test.ts +++ b/packages/session-backends/sqlite-node/test/repo-conformance.test.ts @@ -1,7 +1,10 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { type ConformanceCase, createSessionRepoConformance } from "@earendil-works/pi-agent-core/session/testing"; +import { + type ConformanceCase, + createSessionRepoConformance, +} from "@earendil-works/pi-agent-core/harness/session/testing"; import { describe, it } from "vitest"; import { createNodeSqliteFactory, SqliteSessionRepo } from "../src/index.ts"; diff --git a/packages/session-backends/sqlite-node/test/storage-conformance.test.ts b/packages/session-backends/sqlite-node/test/storage-conformance.test.ts index e1ecbd6fa..707ba4a1e 100644 --- a/packages/session-backends/sqlite-node/test/storage-conformance.test.ts +++ b/packages/session-backends/sqlite-node/test/storage-conformance.test.ts @@ -3,7 +3,7 @@ import { type ConformanceCase, createStorageConformance, type StorageFixture, -} from "@earendil-works/pi-agent-core/session/testing"; +} from "@earendil-works/pi-agent-core/harness/session/testing"; import { describe, it } from "vitest"; import { createNodeSqliteFactory, SQLITE_STORAGE_VERSION, SqliteStorage, sql } from "../src/index.ts"; import { applyInitialSchema } from "../src/sqlite/migrations.ts"; diff --git a/scripts/check-entry-graphs.mjs b/scripts/check-entry-graphs.mjs new file mode 100644 index 000000000..4eb2df09e --- /dev/null +++ b/scripts/check-entry-graphs.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node +/** + * Entry points are cost contracts. + * + * A package's `exports` map is the only place that says which modules are public, and one stray + * `export *` can silently make a narrow entry drag an entire barrel: importing a 1-file pure + * function through a barrel costs ~37 MB of evaluated module graph, and nothing fails until someone + * measures a process. This walks the value-import graph of every declared entry point and enforces a + * budget per entry, so that regression fails at commit time instead. + * + * Only value imports count. `import type` / `export type` are erased before Node sees them. + */ + +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +/** Workspace package name -> its source root, so cross-package imports are followed. */ +const WORKSPACE = { + "@earendil-works/pi-ai": "packages/ai/src", + "@earendil-works/pi-agent-core": "packages/agent/src", + "@earendil-works/pi-telemetry": "packages/telemetry/src", + "@earendil-works/pi-tui": "packages/tui/src", +}; + +/** + * Budgets are deliberate. `.` and `./node` are batteries-included entries and stay unbounded; every + * narrow entry states the graph it is allowed to reach. + */ +const BUDGETS = { + "packages/ai": { + "./utils/*": { maxFiles: 3, forbid: ["providers/", "api/", "index.ts"] }, + }, + "packages/agent": { + "./harness/runtime/reducer": { maxFiles: 1 }, + "./harness/context": { maxFiles: 6, forbid: ["harness/runtime/", "harness/execution/", "packages/ai/"] }, + "./harness/env/nodejs": { maxFiles: 3, forbid: ["packages/ai/", "harness/runtime/"] }, + "./harness/session": { maxFiles: 25, forbid: ["harness/runtime/", "harness/execution/", "packages/ai/src/index.ts"] }, + }, +}; + +const SPEC = /(?:^|\n)\s*(?:import|export)\s+(?!type\s)([^;]*?\sfrom\s*)?["']([^"']+)["']/g; + +function resolveSpec(spec, fromFile) { + if (spec.startsWith("node:")) return null; + if (spec.startsWith(".")) { + const base = resolve(dirname(fromFile), spec); + for (const candidate of [base, `${base}.ts`, `${base}/index.ts`]) { + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; + } + return null; + } + for (const [name, src] of Object.entries(WORKSPACE)) { + if (spec === name) return resolve(ROOT, src, "index.ts"); + if (!spec.startsWith(`${name}/`)) continue; + const tail = spec.slice(name.length + 1); + for (const candidate of [`${tail}.ts`, `${tail}/index.ts`, tail]) { + const file = resolve(ROOT, src, candidate); + if (existsSync(file) && statSync(file).isFile()) return file; + } + } + return null; // external dependency: not part of the workspace graph +} + +function walk(entryFile) { + const seen = new Set(); + const queue = [entryFile]; + while (queue.length > 0) { + const file = queue.pop(); + if (seen.has(file) || file.endsWith(".json")) continue; + seen.add(file); + for (const match of readFileSync(file, "utf8").matchAll(SPEC)) { + const target = resolveSpec(match[2], file); + if (target) queue.push(target); + } + } + return seen; +} + +/** `./dist/harness/context.js` in the exports map is `src/harness/context.ts` on disk. */ +function sourceFor(pkgDir, distPath) { + const rel = distPath.replace(/^\.\/dist\//, "").replace(/\.js$/, ".ts"); + const file = resolve(ROOT, pkgDir, "src", rel); + return existsSync(file) ? file : undefined; +} + +function expand(pkgDir, entry, target) { + if (!entry.includes("*")) return [[entry, target]]; + const dir = resolve(ROOT, pkgDir, "src", dirname(target.replace(/^\.\/dist\//, ""))); + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter((name) => name.endsWith(".ts")) + .map((name) => [entry.replace("*", name.replace(/\.ts$/, "")), target.replace("*", name.replace(/\.ts$/, ""))]); +} + +let failures = 0; +for (const [pkgDir, budgets] of Object.entries(BUDGETS)) { + const manifest = JSON.parse(readFileSync(resolve(ROOT, pkgDir, "package.json"), "utf8")); + for (const [entry, budget] of Object.entries(budgets)) { + const declared = manifest.exports?.[entry]; + if (!declared) { + console.error(`${pkgDir} declares no export "${entry}" but a budget exists for it`); + failures += 1; + continue; + } + const target = typeof declared === "string" ? declared : declared.import; + for (const [name, distPath] of expand(pkgDir, entry, target)) { + const source = sourceFor(pkgDir, distPath); + if (!source) { + console.error(`${pkgDir} export "${name}" points at ${distPath}, which has no source file`); + failures += 1; + continue; + } + const graph = [...walk(source)].map((file) => relative(ROOT, file)); + if (graph.length > budget.maxFiles) { + console.error( + `${pkgDir} export "${name}" reaches ${graph.length} files, budget ${budget.maxFiles}\n` + + graph.map((file) => ` ${file}`).join("\n"), + ); + failures += 1; + } + for (const pattern of budget.forbid ?? []) { + const hit = graph.filter((file) => file.includes(pattern)); + if (hit.length > 0) { + console.error(`${pkgDir} export "${name}" must not reach ${pattern}:\n${hit.map((f) => ` ${f}`).join("\n")}`); + failures += 1; + } + } + } + } +} + +if (failures > 0) { + console.error(`\n${failures} entry-point budget violation(s).`); + process.exit(1); +} +console.log("Entry point graphs are within budget."); diff --git a/tsconfig.json b/tsconfig.json index cc2f9a85f..e110a3bfd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,11 +13,15 @@ "@earendil-works/pi-ai/*": ["./packages/ai/src/*.ts", "./packages/ai/src/providers/*.ts"], "@earendil-works/pi-ai/dist/*": ["./packages/ai/src/*"], "@earendil-works/pi-agent-core": ["./packages/agent/src/index.ts"], - "@earendil-works/pi-agent-core/session/testing": [ + "@earendil-works/pi-agent-core/harness/session/testing": [ "./packages/agent/src/harness/session/testing/index.ts" ], "@earendil-works/pi-agent-core/node": ["./packages/agent/src/node.ts"], - "@earendil-works/pi-agent-core/*": ["./packages/agent/src/*"], + "@earendil-works/pi-agent-core/*": [ + "./packages/agent/src/*.ts", + "./packages/agent/src/*/index.ts", + "./packages/agent/src/*" + ], "@earendil-works/pi-session-backend-sqlite-node": ["./packages/session-backends/sqlite-node/src/index.ts"], "@earendil-works/pi-coding-agent": ["./packages/coding-agent/src/index.ts"], "@earendil-works/pi-coding-agent/hooks": ["./packages/coding-agent/src/core/hooks/index.ts"],