Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ pnpm workspace, Node >= 22.19, ESM only, TypeScript.

| Path | What |
| ------------------- | -------------------------------------------------------------------------------------- |
| `packages/ambion` | The runtime. One file per concern; `session.ts` is the room that composes them |
| `packages/ambion` | The runtime. One file per concern, in layers Biome holds; `session.ts` composes them |
| `packages/cli` | The `ambion` binary |
| `docs/agent.md` | Design contract for the core — read before changing the runtime |
| `docs/exchange.md` | Design contract for the exchange, the room's unit of work — read with `agent.md` |
Expand Down Expand Up @@ -72,6 +72,9 @@ Run `pnpm format` and `pnpm check` before every push. CI runs the same gate.
everything a participant reads — prompts, roster, record, the ask at the end
of a turn — and stays pure and stateless so it does not become one. What the
room says to a developer stays with the mechanism that says it.
- The core is laid out in layers (`docs/toolchain.md` §1), and an import
points down only. Biome refuses the rest; a new file goes in the layer
that may reach what it needs, and never above `session.ts`.
- No `any`, no non-null assertions, no unused imports or variables.
- `packages/ambion/src` must not write to stdout. Hosts pass a logger in.
- Cognitive complexity: max 10 in source, 15 in tests.
Expand Down
208 changes: 206 additions & 2 deletions biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,213 @@
},
{
// The library must not write to stdout on its owner's behalf; hosts
// pass a logger in instead.
// pass a logger in instead. It also runs on any host: nothing in it
// reaches a platform's own modules.
"includes": ["packages/ambion/src/**"],
"linter": { "rules": { "suspicious": { "noConsole": "error" } } }
"linter": {
"rules": {
"suspicious": { "noConsole": "error" },
"style": {
"noRestrictedImports": {
"level": "error",
"options": {
"patterns": [
{
"group": ["cloudflare:*", "node:sqlite"],
"message": "The core runs on any host. A platform's own modules belong to its package."
}
]
}
}
}
}
}
},
// The core is laid out in layers, and an import points down only:
//
// types, wire, define, render the vocabulary: shapes, and what a participant reads
// host/ what a host owns: the runtime value, a SQLite storage
// log/ the log: one serial queue over a Pi session
// room/ every fact and every decision, pure over the log
// tools/ what an agent's tools reach into
// seat/ the seat side of the wire: one activation at a time
// session.ts the room, which composes them all
//
// `docs/toolchain.md` §1 names the layers; each override below refuses
// the imports its layer may not make.
{
"includes": [
"packages/ambion/src/types.ts",
"packages/ambion/src/wire.ts",
"packages/ambion/src/define.ts",
"packages/ambion/src/render.ts"
],
"linter": {
"rules": {
"style": {
"noRestrictedImports": {
"level": "error",
"options": {
"patterns": [
{
"group": [
"./host/*",
"./log/*",
"./room/*",
"./tools/*",
"./seat/*",
"./session.ts",
"./index.ts"
],
"message": "The vocabulary imports nothing that does anything."
}
]
}
}
}
}
}
},
{
"includes": ["packages/ambion/src/host/**"],
"linter": {
"rules": {
"style": {
"noRestrictedImports": {
"level": "error",
"options": {
"patterns": [
{
"group": [
"../log/*",
"../room/*",
"../tools/*",
"../seat/*",
"../session.ts",
"../index.ts"
],
"message": "A host owns values; it reads no log, folds nothing, and runs no seat."
}
]
}
}
}
}
}
},
{
"includes": ["packages/ambion/src/log/**"],
"linter": {
"rules": {
"style": {
"noRestrictedImports": {
"level": "error",
"options": {
"patterns": [
{
"group": [
"../host/*",
"../room/*",
"../tools/*",
"../seat/*",
"../session.ts",
"../index.ts"
],
"message": "The log knows entries and a queue, and nothing that reads them."
}
]
}
}
}
}
}
},
{
"includes": ["packages/ambion/src/room/**"],
"linter": {
"rules": {
"style": {
"noRestrictedImports": {
"level": "error",
"options": {
"patterns": [
{
"group": [
"../host/*",
"../tools/*",
"../seat/*",
"../session.ts",
"../index.ts"
],
"message": "The room's facts and decisions are pure over the log: no host, no tool, no seat."
}
]
}
}
}
}
}
},
{
"includes": ["packages/ambion/src/tools/**"],
"linter": {
"rules": {
"style": {
"noRestrictedImports": {
"level": "error",
"options": {
"patterns": [
{
"group": ["../log/*", "../room/*", "../seat/*", "../session.ts", "../index.ts"],
"message": "A workspace is what a tool reaches into; it knows no room and no seat."
}
]
}
}
}
}
}
},
{
"includes": ["packages/ambion/src/seat/**"],
"linter": {
"rules": {
"style": {
"noRestrictedImports": {
"level": "error",
"options": {
"patterns": [
{
"group": ["../log/*", "../room/*", "../session.ts", "../index.ts"],
"message": "The seat side reaches its room through the wire alone: no log, no fold, no session."
}
]
}
}
}
}
}
},
{
// A host package reaches the core through its published surface.
"includes": ["packages/cloudflare/src/**", "packages/cli/src/**", "examples/**/src/**"],
"linter": {
"rules": {
"style": {
"noRestrictedImports": {
"level": "error",
"options": {
"patterns": [
{
"group": ["@ambionframework/ambion/*", "**/packages/ambion/src/*"],
"message": "Reach the core through `@ambionframework/ambion`, never a file inside it."
}
]
}
}
}
}
}
},
{
// Biome charges a nested function for the nesting it sits in, so
Expand Down
27 changes: 20 additions & 7 deletions docs/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -517,30 +517,43 @@ sooner.
`seats()`, `subscribe()` — and `Session` extends it, so code that only
reads takes the narrower type and cannot start anything by accident.

One file per concern, and `session.ts` is the room that composes them: the
One file per concern, in layers an import points down through, and
`session.ts` is the room that composes them ([`toolchain.md`](toolchain.md)
§1 names the layers, and Biome holds them): the
record in [`record.ts`](../packages/ambion/src/record.ts), who is here in
[`presence.ts`](../packages/ambion/src/presence.ts), a seat and what wakes it
in [`seat.ts`](../packages/ambion/src/seat.ts), one activation in
[`activation.ts`](../packages/ambion/src/activation.ts), the exchange in
[`exchange.ts`](../packages/ambion/src/exchange.ts), what the assistant
writes in [`assistant.ts`](../packages/ambion/src/assistant.ts), what an
agent's tools reach into in
[`workspace.ts`](../packages/ambion/src/workspace.ts), and what any of them
reads in [`render.ts`](../packages/ambion/src/render.ts).
[`workspace.ts`](../packages/ambion/src/tools/workspace.ts), what a host
owns in [`runtime.ts`](../packages/ambion/src/host/runtime.ts), and what
any of them reads in [`render.ts`](../packages/ambion/src/render.ts).

**A seat is seated for the run. An activation lasts seconds.** What an
activation has heard, what landed while it worked, and whether it left a mark
belong to the activation and end with it. Rule 5's `readThrough` is an
activation's fact.

Storage is Pi's. The record lives in a Pi session — each message a custom
entry, replayed in `seq` order on reopen — obtained from Pi's own
`SessionRepo`, which `startSession` and `readSession` accept and default to
an in-process `InMemorySessionRepo`. A name that outlives the process is a
durable `SessionRepo` implementation; the API stays the same.
entry, replayed in `seq` order on reopen — opened through a `SessionOpener`
on the room's `Runtime`. `sessionsOver(repo)` makes an opener from Pi's own
`SessionRepo`; `startSession` and `readSession` still accept a `repo` as
the shorthand for one. The default runtime opens sessions in an in-memory
`InMemorySessionRepo`. A name that outlives the process is a durable
`SessionRepo` implementation; the API stays the same.
[`index.ts`](../packages/ambion/src/index.ts) re-exports Pi's storage
surface, and Ambion adds no storage layer of its own.

**A host owns a `Runtime`.** It holds the clock, the session opener, the
model call, the rooms that are running and the workspace names that are
taken ([`runtime.ts`](../packages/ambion/src/host/runtime.ts)). `startSession`,
`readSession` and `defineWorkspace` take one as an option and default to
`defaultRuntime`, one value per process. Two runtimes in one process share
nothing: one name runs in both, and neither reads the other. "One run per
name" above holds per runtime.

---

## 6. What proves it
Expand Down
5 changes: 4 additions & 1 deletion docs/presence.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,10 @@ Four things beyond the record, each pulling its weight:
arrival matter" answerable at all.
- **The time**, absolute at the top and relative on each line, is what a
persistent ambient room needs and a bare transcript never gives: without
it an agent cannot tell a three-day gap from a three-minute one.
it an agent cannot tell a three-day gap from a three-minute one. The room
reads it off its runtime's `Clock` (`agent.md` §5): the system clock by
default, and a clock a test moves by hand in the scripted suite. Every
`at` on the record is stamped from the same clock.
- **The gap** is derived from the record, because the arrivals are on it;
the runtime stores no separate timer.

Expand Down
36 changes: 34 additions & 2 deletions docs/toolchain.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,28 @@ if the workspace protocol does not resolve.
`@ambionframework/cli` is the `ambion` binary; it currently reports its
version and nothing else.

### The core's layers

`packages/ambion/src` is laid out in layers, and an import points down
only. Biome refuses every other import (`noRestrictedImports`, one
override per layer in `biome.jsonc`), so the layout is a fact the gate
holds, and a reviewer reads a file knowing what it cannot reach.

| Layer | What it holds | May import |
| --------------------------- | ---------------------------------------------------------------- | -------------------------- |
| `types`, `define`, `render` | The vocabulary: the public shapes, and what a participant reads | Nothing that does anything |
| `host/` | What a host owns: the runtime value, a clock, an opener | The vocabulary |
| `tools/` | What an agent's tools reach into: the workspace and its backends | The vocabulary, `host/` |
| `session.ts` | The room, which composes them all | Everything |

The files beside `session.ts` at the root of `src` (the activation, the
assistant, the exchange, presence, the record, the seat) are in no layer
yet, and no override constrains them.

Two rules hold across packages: the core imports no platform module
(`node:sqlite`, `cloudflare:*`), and every other package reaches the core
through `@ambionframework/ambion`, its published surface.

---

## 2. Toolchain choices
Expand Down Expand Up @@ -148,14 +170,15 @@ Notable settings and what they buy:
```
build dependsOn: ^build outputs: dist/**
check:types dependsOn: build, ^build (needs upstream .d.mts)
test dependsOn: build, ^build
test dependsOn: build, ^build inputs: src, test, vitest configs, tsconfig, package.json
dev persistent, never cached
```

`check:types` and `test` wait on upstream builds because the CLI type-checks
against the runtime's _emitted_ declarations. That is the same
resolution a published consumer gets, so a broken `exports` map fails here,
before release.
before release. `test` names its inputs, so a change outside them, a
document or a demo report, reads the cached result.

---

Expand Down Expand Up @@ -288,6 +311,15 @@ roster, every summary covering the range before it, no `error` event, and
every activation ended. Every test ends with one line of what it spent,
read off the seats' downstream sessions.

**One harness, two tiers.** The invariants live in
[`test/support/invariants.ts`](../packages/ambion/test/support/invariants.ts),
and the live support re-exports them. The scripted tier runs the same
scenarios on every storage (`matrix.test.ts`): Pi's in-memory repository,
and Pi's JSONL repository over a temporary directory. It runs them on a
clock it moves by hand (`test/support/clock.ts`), so a test never waits
on real time. The live tier runs the room on a real model and holds it to
the same invariants.

`pnpm test:live` runs the tier. Two configurations keep the tiers apart:
`vitest.config.ts` excludes `test/live` from `pnpm test`, and
`vitest.live.config.ts` includes nothing else. In the live configuration
Expand Down
Loading
Loading