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"],