# `@mengine/medeo-tool`

The Node host for the `medeo` LLM tool. It gives the model the script contract
and the document's current version, runs the model's script in a worker against
the document read as entities and Relations, turns everything the script did
into one Loro update on the snapshot it read, and publishes that update. The
host supplies routing, credentials and resource I/O.

The tool translates between the contract and the document in both directions
([RFC 04](../../docs/projects/medeo-dsl/rfc/04-entity-relation-to-loro-mapping.md)):
it **interprets** the DSL document, read through `@mengine/medeo-client`, as the
contract's entities and Relations, and **compiles** each script write back into
document mutations, checked against `@mengine/medeo-dsl`'s constraints as it
lands. Neither the client nor the DSL package knows the sandbox API.

## Using it from a host

```ts
import { createMedeoTool } from '@mengine/medeo-tool';

const medeo = createMedeoTool({
  httpOrigin: (docId) => originFor(docId),
  authToken: () => currentToken(),
  loadEntityAsset: (docId, media) => readStoredFile(docId, media.payload.system, media.payload.key),
});

// Register the tool once with medeo.name, medeo.description and medeo.parameters.
// Before every model call, hand the model the prompt MEngine renders.
const { prompt } = await medeo.getModelContext({ doc_id: docId, context_id: conversationId });

// Pass each tool call through unchanged; the result goes back to the model.
const result = await medeo.handle(toolCall.input);

await medeo.close();
```

`getModelContext` returns the whole prompt: the workflow, the execution rules,
the contract's declarations, and whether the document changed since the previous
call with the same `context_id` (`null` on the first). The host adds nothing to
it.

| Option                         | What it is                                                                                                                                                                                                                        |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `httpOrigin`                   | Mengine origin, a value or `(docId) => origin`. It must not change for a doc.                                                                                                                                                     |
| `authToken`, `userId`          | Bearer token and end-user id, sent with every request. `userId` is also the plan's actor.                                                                                                                                         |
| `peerId`                       | Loro peer for the tool's own follow-up writes. Each run authors its plan on a fresh peer.                                                                                                                                         |
| `loadEntityAsset`              | Reads a medium's own file, or a transcript's transcription file: for `materialize`, and before every plan for the facts of each file it names (see Stored files). Without it, a script's read fails and those facts stay unknown. |
| `uploadText`                   | Stores raw text in Memota as a text file and returns its facts. A plan that writes a PhoneticScript's `phonemeScript` stores it here first; without it, such a plan fails.                                                        |
| `loadGenerationFacts`          | After a commit, links media the plan created to what they were generated from.                                                                                                                                                    |
| `sandbox`                      | Defaults for each run: `timeoutMs` 30000, `memoryLimitMb` 256.                                                                                                                                                                    |
| `maxPlans`, `maxModelContexts` | Cache bounds, 16 and 128.                                                                                                                                                                                                         |
| `fetchImpl`                    | Transport override.                                                                                                                                                                                                               |

`loro-crdt` is a peer dependency: the host provides the one instance both this
package and `@mengine/medeo-client` use.

## Operations

The model calls one tool and picks an `op`:

- `snapshot` returns the first 200 entities and Relations and the document
  version. It never changes the document.
- `run-edit-script` runs `script` and returns `preview`, `logs` and a
  `plan_id`. Nothing is published unless `auto_commit` is true. `timeout_ms`
  counts from when the script starts, not from worker boot.
- `commit-plan` publishes a cached plan. Independent concurrent edits merge.

Every call first pulls what the server has, so a script reads the current
document. A commit ends one of four ways:

- `committed`.
- `conflicted`: the server merged and stored the update, but the merged
  document no longer projects. Take a fresh snapshot and decide what it should
  be.
- `unconfirmed`: the push was not confirmed. Retry the same `plan_id`; until it
  resolves, this tool takes no other snapshot, run or commit on the document.
- `rejected`: the server refused the update. The tool reads the document afresh
  on the next call.

Plans stay in this process, referenced by `plan_id`; the model never carries
them. Calls on one document run one at a time. The tool only opens documents:
Director initializes every project's document before any agent edit, so a 404
means the document is missing.

## The script contract

The contract is [`packages/medeo-dsl/src/sandbox.d.ts`](../medeo-dsl/src/sandbox.d.ts),
and the model reads it verbatim in its prompt.
[`src/export/sandbox-dts.ts`](src/export/sandbox-dts.ts) is that text copied by
hand, because the private DSL package does not ship it;
`src/export/__tests__/prompt-contract.spec.ts` fails when the two differ.

A script runs with `entities`, `relations` and `console` in scope and nothing
else: no network, no storage, no generation.

- `entities.list / get / create / update / declareFields / delete / materialize`
- `relations.list / of / link / move / unlink`

```js
// Turn the background music down.
const bgm = entities.list().find((e) => e.entityKind === 'track' && e.payload.role === 'bgm');
const [held] = relations.of(bgm.entityId, 'contain').filter((r) => r.endpoint0EntityId === bgm.entityId);
entities.update(held.endpoint1EntityId, [{ op: 'set', path: ['volume'], value: -6 }]);
```

What the declarations cannot state, the prompt does
([`src/export/prompt.ts`](src/export/prompt.ts)). The parts a host is most
likely to meet:

- Reads are assembled: a variant reads with its base applied. Changing a
  returned object changes nothing.
- Every write lands whole or not at all and is checked as it lands, so a
  refused write throws at the call that made it. List members are addressed by
  index; `text.splice` counts code points.
- A Clip is assembled from a Clip, a SequenceMarker and content linked with
  `contain` and `marker`; the document stores the marker and the content on the
  Clip's own row, so the plan is refused until each Clip has both.
- A Caption has no length of its own (`durationMs` is `null`); its Clip's marker
  decides how long it shows.
- `delete` never cascades, except that a Clip takes its own SequenceMarker with
  it — the document stores the marker on the Clip's row.
- Once the script finishes, the plan is checked as a whole and applied to the
  run's fork in one audited transaction; a failure is a `runtime` error, and
  nothing is published.

## Stored files

A medium is its stored file: `system` (`memota-speech` for speech results,
`memota` for everything else) and `key`. A document holds one medium per file,
and `entities.create` returns the one it already has.

A transcript — an `audio-script` read off media — may likewise name the Memota
text file its words and times came from (`system: 'memota'`, `key`); the name
is fixed once stated.

`materialize(entityId)` reads a medium's own file, or the transcription file a
transcript names, through `loadEntityAsset` and returns `{ assetId, content }`,
where `assetId` is the file's key and `content` is whatever the store holds.
Nothing of `content` is written on the entity: the script takes what it needs,
such as a take's length, and states it. `storageKey` and `digest` are the
host's facts, and a script that writes them is refused. Before the plan is
built, every medium or transcript the plan names anew or changes is read once
from the store — whether or not the script read it — and the facts the read
reports are recorded where the entity states none; a file the store cannot
read stops the plan. Without `loadEntityAsset` those facts stay unknown, and
only an `audio` or `voiceover-recording` without a `storageKey` still fails,
because sound plays from storage.

A read the script awaited reports its failure to the script; one it left
unhandled stops the plan.

## Host-only hooks

None of these reach the model.

- `loadEntityAsset(docId, entity)` returns `{ content, storageKey?, digest? }`
  for a medium's file or a transcript's transcription file.
- `uploadText(docId, text)` returns the stored text file's
  `{ system, key, storageKey?, digest }`.
- `loadGenerationFacts(docId, fileKeys)` returns
  `{ outputAssetId, inputAssetIds }[]` by file key. After a confirmed commit,
  each record whose ends are both media in the document becomes
  `derived-from(product, source)`; a failure becomes a warning on the result,
  never a failed op.

`runEditScript` and `commitPlan` are the lower-level pieces the tool is built
from.

## Runtime and isolation

Node 24.15 or later. Each run is a `worker_threads` worker, started without the
host's environment and under a memory ceiling and a wall-clock timeout. From
source, the worker loads TypeScript with `--experimental-transform-types` and
the `./esm-register` resolve hook; the packed `./worker` is plain JavaScript.

The script runs in a `vm` context of its own that compiles nothing from
strings, and the three bindings it is given are built inside that context
([`src/sandbox/script-bridge.ts`](src/sandbox/script-bridge.ts)): every call
crosses as JSON text, so no function or object of the worker's carries its
realm — and with it that realm's `Function` — to the script. This is depth, not
a proven jail: the worker still runs inside the host process, so a script is
semi-trusted code, not untrusted code.
