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
47 changes: 46 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# MonkeyLLM agent guide

Knowledge forest navigable by an SLM: markdown + indexes, traversed through
**Vine**'s MCP primitives. `docs/monkeyllm-spec-v0.79.md` is normative
**Vine**'s MCP primitives. `docs/monkeyllm-spec-v0.81.md` is normative
(earlier versions are archived) **the spec is the truth**; any contract
change requires a new spec version before code.

Expand Down Expand Up @@ -77,6 +77,51 @@ Local models (llama.cpp on the 3090): see `docs/local-inference.md`.

## Conventions and pitfalls

- **The door an author could not find (spec L.2/L.3/L.9 r1 + L.16,
v0.81)**: Part L shipped the mechanism and left the person it was built
for with no way in. `docs/extending.md` still cited **v0.20**, so the one
document naming extensions contradicted what had been released; nothing
taught how to write one; and the console's install box asked for "a
path", which is a path on the **host** — through a browser that is the
container's filesystem, so an operator holding an extension they had just
written had **no route at all** to a remote Station short of publishing it
to git first, and the local-file door served only somebody with a shell
there, who would have used the CLI. Now: an **uploaded archive** is a
fifth door into the SAME resolver (`{name, b64}`, the shape ingest has
carried since J.8, so a browser that can send a document sends an
extension with no new mechanism), `unverified` **by construction** (no
forge, no index identity, so nothing could have been verified and the
tier says that rather than implying a check happened), refused **before
it is written** over `MONKEYLLM_STATION_EXT_UPLOAD_MAX_MB` (25) — the
door is for an extension's own code, and vendored wheels belong to the
local-file route. Every seam **declares its handler's contract**
(`extensions/contracts.py`), the kit CHECKS the signature at install, and
the authoring reference is generated from the same declaration — three
texts became one. The rule hinges on **defaults, not `**kwargs`**: the
first cut said kwargs excused a misspelling and was wrong in the exact
case it was written for (given `on_event(event, forrest, **rest)`,
`**rest` absorbs the `forest` the host passes and `forrest` is still
unfilled, a `TypeError` at whatever hour the next ingest runs), so the
SPEC paragraph was corrected to match the code and not the reverse. The
check reads the author's **source** and never imports it: a `heavy`
handler's module imports the very dependency L.5 keeps out of this
process, so importing to catch a typo would break the rule that protects
the deployment — and would run third-party code at kit time, which L.2
r5 is careful to say the kit does not do. L.16: **prose is written,
schemas are derived** — the teaching text lives in `docs/extending.md`
where it is read without running anything, and the manifest schema, seam
contracts and role kinds come from the definitions, because a transcribed
schema lies at the first new seam, silently, to exactly the person with no
other source (F.203 adds a seam at runtime and asserts the document
changes). `GET /v1/extensions/authoring` is deliberately **not** under the
admin gate: writing is not installing, and an extension is written on a
laptop and installed by whoever governs the deployment, so gating the docs
on L.7 r1 withholds them from the only person who needs them (J.5.12's
reasoning about skills). Gotcha worth keeping: the new contract check
immediately failed a v0.80 fixture **of our own** — an `events` handler
declared `echo(value)`, a signature no real `events` call could fill,
which passed a whole release because nothing checked. F.199-F.205 in
`tests/test_v081_authoring.py`.
- **A capability the deployment does not have (spec Part L + J.4.2/J.10/
J.5, v0.80)**: Part G's line "UIs and bots are MCP/library clients, not
plugins" drew the boundary correctly and left a whole class of work with
Expand Down
49 changes: 49 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,55 @@ git rebase --signoff main # a whole branch
git push --force-with-lease
```

## Versions, and what a tag means

Three numbers, and until `1.0.0` only the first two carry a promise.

| Position | Meaning |
|---|---|
| **major** | `0` until this project says otherwise. It is not `1` yet, and that is a statement: see below. |
| **minor** | the spec version this release implements. `0.81.x` implements `docs/monkeyllm-spec-v0.81.md`. A contract change therefore always moves this number, because a contract change always cuts a spec first. |
| **patch** | every release that cuts no spec — a fix, a console, a document, a measurement. |

**While the major is `0`, a patch may break you.** That is the honest reading
of a `0.x` version and it is stated here rather than left to be discovered:
this project reserves the right to change behaviour in a patch release, and
the version number alone is not a compatibility promise. What *is* promised
is that the spec is the truth — if behaviour changed, a spec version says
so, and if it changed without one, that is a defect worth reporting.

The one place this rule has teeth is an extension's `station_compat`
(spec L.1). Because a patch may break, an extension SHOULD pin to the minor
it was written and tested against:

```json
"station_compat": ">=0.81,<0.82"
```

A wider range such as `>=0.81,<1.0` is allowed and means what it says — *I
accept whatever the next minor does to me*. It is the right choice for an
extension with a tiny surface and the wrong one for anything that reads a
seam closely.

### Releasing

The tag is the only thing anybody types, and it must equal `version` in
`pyproject.toml` — the release workflow's first job refuses the pair when
they disagree, in ten seconds, before the suite has run. PyPI never lets a
published version mean anything else and never lets it be replaced, so the
sequence is:

1. bump `version` in `pyproject.toml`, in a commit whose subject is
`Release <version>`;
2. merge to `main`;
3. `git tag v<version> && git push origin v<version>`;
4. approve the `pypi` environment when the run asks — every check has
already run by then, and that approval is the irreversible step.

Do not skip a number. A gap in the PyPI index reads as a release that was
published and withdrawn, which is a different and more alarming thing than
a version that never existed.

## Before you open a pull request

- **The spec is the truth.** `docs/monkeyllm-spec-v0.49.md` is normative. A
Expand Down
58 changes: 56 additions & 2 deletions apps/station/monkeyllm_station/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
from starlette.responses import (
FileResponse,
JSONResponse,
PlainTextResponse,
Response,
StreamingResponse,
)
Expand Down Expand Up @@ -5853,6 +5854,37 @@ async def extension_route(request: Request) -> JSONResponse:
return JSONResponse(result if isinstance(result, dict)
else {"result": result})

async def extension_authoring(request: Request) -> JSONResponse:
"""L.16 — what somebody writing an extension is handed.

Open to anyone signed in, and deliberately NOT gated on the
authority to install. J.5.12 settled the same question for skills —
"the person who may read the forest is the person whose AI may learn
to" — and it holds harder here: an extension is written on a laptop
and installed by whoever governs the deployment, so gating the
documentation on L.7 rule 1 withholds it from the only person who
needs it.

Everything served is DERIVED (L.16 rule 1). There is no second
description of the manifest here that could disagree with the
loader's, and adding a seam to the catalogue changes this response
with no edit to this file.
"""
principal, err = require_principal(request)
if err:
return err
from monkeyllm.extensions import authoring
from monkeyllm_station.mcp_surface import package_version

version = package_version()
want = (request.query_params.get("as") or "json").lower()
if want == "markdown":
which = (request.query_params.get("doc") or "seams").lower()
body = (authoring.manifest_reference(version) if which == "manifest"
else authoring.seam_reference(version))
return PlainTextResponse(body, media_type="text/markdown")
return JSONResponse(authoring.schema(version))

async def admin_extensions(request: Request) -> JSONResponse:
"""What is installed, and what installs one (spec Part L).

Expand Down Expand Up @@ -5908,6 +5940,24 @@ async def admin_extensions(request: Request) -> JSONResponse:

action = str(body.get("action") or "install")
store = _ext_store()

# L.2 (v0.81): an uploaded archive. `{name, b64}` is the shape J.8
# has carried since v0.48, so a browser that can already send a
# document can send an extension with no new mechanism.
upload = None
raw = body.get("upload")
if raw is not None:
if not isinstance(raw, dict) or not raw.get("b64"):
return _envelope(VineError(
E_SCHEMA, "'upload' must be {name, b64}",
hint='the shape ingest uses: {"name": "x.zip", '
'"b64": "…"}'))
try:
data = base64.b64decode(str(raw["b64"]), validate=True)
except Exception:
return _envelope(VineError(
E_SCHEMA, "'upload.b64' is not valid base64"))
upload = (str(raw.get("name") or "extension.zip"), data)
# L.1: `station_compat` is judged against the version this host
# publishes in `forests()` — the same number an author reads when
# they choose their range. Two versions here would let an extension
Expand All @@ -5919,7 +5969,7 @@ async def admin_extensions(request: Request) -> JSONResponse:
from monkeyllm.extensions.installer import plan
import shutil as _shutil
prepared, tmp = plan(str(body.get("source") or ""),
host_version,
host_version, upload=upload,
verify=body.get("verify", True) is not False)
try:
return JSONResponse(prepared.to_dict())
Expand All @@ -5930,7 +5980,7 @@ async def admin_extensions(request: Request) -> JSONResponse:
acknowledged = bool(body.get("acknowledge"))
if action == "install":
result = install(str(body.get("source") or ""),
host_version, store=store,
host_version, store=store, upload=upload,
acknowledge_unverified=acknowledged,
verify=body.get("verify", True) is not False)
else:
Expand Down Expand Up @@ -6867,6 +6917,10 @@ def fresh():
# can never take a name the product needs.
Route("/v1/ext/{ext}/{path:path}", extension_route,
methods=["GET", "POST"]),
# L.16: authoring is not installing, so this one is not under the
# admin gate the rest of /v1/admin/extensions* carries.
Route("/v1/extensions/authoring", extension_authoring,
methods=["GET"]),
Route("/v1/admin/extensions", admin_extensions,
methods=["GET", "POST"]),
Route("/v1/admin/extensions/enablement", admin_extension_enablement,
Expand Down
31 changes: 30 additions & 1 deletion apps/studio/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ function serverTiming(res) {
return Object.keys(out).length ? out : null
}

async function request(path, { method = 'GET', body, timing = false } = {}) {
async function request(path, { method = 'GET', body, timing = false,
raw = false } = {}) {
const res = await fetch(path, {
method,
headers: {
Expand All @@ -70,6 +71,10 @@ async function request(path, { method = 'GET', body, timing = false } = {}) {
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
})
// `raw` is for a route that answers text/markdown (the L.16 authoring
// reference). A failure still carries the envelope, so the error path
// below is shared rather than duplicated for the text case.
if (raw && res.ok) return res.text()
const payload = await res.json().catch(() => ({}))
if (!res.ok) {
const err = payload?.error || {}
Expand All @@ -87,6 +92,23 @@ async function request(path, { method = 'GET', body, timing = false } = {}) {
return timing ? { data: payload, timing: serverTiming(res) } : payload
}

/** Raw bytes as the `b64` half of the `{name, text|b64}` wire shape (J.8).
*
* Exported rather than written twice: ingest and the extension upload send
* the SAME contract, and two encoders agree only where somebody compared
* them. Chunked because `String.fromCharCode.apply` on a whole megabyte
* overflows the argument stack.
*/
export function toBase64(buffer) {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.length; i += 8192) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 8192))
}
return btoa(binary)
}


export const api = {
health: () => request('/v1/health'),
me: () => request('/v1/me'),
Expand Down Expand Up @@ -301,6 +323,13 @@ export const api = {
// authority may change what exists — the route decides, and the console
// reads `may_install` rather than deciding for itself.
extensions: () => request('/v1/admin/extensions'),
// L.16: authoring is NOT under the admin gate — writing an extension is
// not installing one, and the person who writes it is usually not the
// person who governs the deployment.
extensionAuthoring: () => request('/v1/extensions/authoring'),
extensionAuthoringDoc: (doc) =>
request(`/v1/extensions/authoring?as=markdown&doc=${encodeURIComponent(doc)}`,
{ raw: true }),
extensionAction: (body) =>
request('/v1/admin/extensions', { method: 'POST', body }),
extensionEnablement: (forest) =>
Expand Down
2 changes: 2 additions & 0 deletions apps/studio/src/locales/common/en.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
{
"common.body": "Body",
"common.cancel": "Cancel",
"common.clear": "Clear",
"common.close": "Close",
"common.copy": "Copy",
"common.create": "Create",
"common.elapsed": "{ms} ms",
"common.evidence": "Evidence",
"common.hide": "Hide",
"common.loading": "Loading",
"common.no_changes": "Nothing changed yet",
"common.optional": "optional",
Expand Down
2 changes: 2 additions & 0 deletions apps/studio/src/locales/common/es.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
{
"common.body": "Contenido",
"common.cancel": "Cancelar",
"common.clear": "Limpiar",
"common.close": "Cerrar",
"common.copy": "Copiar",
"common.create": "Crear",
"common.elapsed": "{ms} ms",
"common.evidence": "Evidencia",
"common.hide": "Ocultar",
"common.loading": "Cargando",
"common.no_changes": "Nada cambió todavía",
"common.optional": "opcional",
Expand Down
2 changes: 2 additions & 0 deletions apps/studio/src/locales/common/pt.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
{
"common.body": "Conteúdo",
"common.cancel": "Cancelar",
"common.clear": "Limpar",
"common.close": "Fechar",
"common.copy": "Copiar",
"common.create": "Criar",
"common.elapsed": "{ms} ms",
"common.evidence": "Evidências",
"common.hide": "Ocultar",
"common.loading": "Carregando",
"common.no_changes": "Nada mudou ainda",
"common.optional": "opcional",
Expand Down
14 changes: 13 additions & 1 deletion apps/studio/src/locales/ext/en.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
{
"ext.absent": "This forest expects extensions that are not installed: {list}",
"ext.author": "Write an extension",
"ext.author.blurb": "Add a capability this deployment does not have — without a package entering the engine's environment.",
"ext.author.derived": "Read from this Station ({version}), not from a copy — a seam added to the product appears here with nobody remembering to write it down.",
"ext.author.does": "What it does",
"ext.author.download": "Download the authoring folder",
"ext.author.download.hint": "SKILL.md plus the generated references, for your coding agent.",
"ext.author.manifest_only": "declared in the manifest; no handler",
"ext.author.open": "Show me how",
"ext.author.seam": "Seam",
"ext.author.shape": "Your handler",
"ext.config.none": "This extension declares no settings",
"ext.config.saved": "Saved.",
"ext.config.secret.set": "A value is set. Leave empty to keep it.",
Expand Down Expand Up @@ -42,5 +52,7 @@
"ext.source.hint": "An index id, github.com/org/repo@v1.2.0, an https .zip, or a path on the host.",
"ext.tier": "Trust",
"ext.tracking": "tracking {ref}",
"ext.tracking.label": "Tracking"
"ext.tracking.label": "Tracking",
"ext.upload": "Choose a .zip",
"ext.upload.hint": "A path above is a path on the HOST. To install an extension from this machine, upload it."
}
14 changes: 13 additions & 1 deletion apps/studio/src/locales/ext/es.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
{
"ext.absent": "Este bosque espera extensiones que no están instaladas: {list}",
"ext.author": "Escribir una extensión",
"ext.author.blurb": "Añade una capacidad que esta instalación no tiene — sin que ningún paquete entre en el entorno del motor.",
"ext.author.derived": "Leído de esta Station ({version}), no de una copia — un seam nuevo aparece aquí sin que nadie recuerde escribirlo.",
"ext.author.does": "Qué hace",
"ext.author.download": "Descargar la carpeta de autoría",
"ext.author.download.hint": "SKILL.md más las referencias generadas, para tu agente de código.",
"ext.author.manifest_only": "declarado en el manifiesto; sin handler",
"ext.author.open": "Muéstrame cómo",
"ext.author.seam": "Seam",
"ext.author.shape": "Tu handler",
"ext.config.none": "Esta extensión no declara ajustes",
"ext.config.saved": "Guardado.",
"ext.config.secret.set": "Hay un valor guardado. Déjalo vacío para conservarlo.",
Expand Down Expand Up @@ -42,5 +52,7 @@
"ext.source.hint": "Un id del índice, github.com/org/repo@v1.2.0, un .zip https, o una ruta en el host.",
"ext.tier": "Confianza",
"ext.tracking": "siguiendo {ref}",
"ext.tracking.label": "Siguiendo"
"ext.tracking.label": "Siguiendo",
"ext.upload": "Elegir un .zip",
"ext.upload.hint": "La ruta de arriba es una ruta en el HOST. Para instalar una extensión desde esta máquina, súbela."
}
14 changes: 13 additions & 1 deletion apps/studio/src/locales/ext/pt.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
{
"ext.absent": "Esta floresta espera extensões que não estão instaladas: {list}",
"ext.author": "Escrever uma extensão",
"ext.author.blurb": "Acrescente uma capacidade que esta instalação não tem — sem nenhum pacote entrar no ambiente da engine.",
"ext.author.derived": "Lido desta Station ({version}), não de uma cópia — um seam novo no produto aparece aqui sem ninguém lembrar de escrever.",
"ext.author.does": "O que faz",
"ext.author.download": "Baixar a pasta de autoria",
"ext.author.download.hint": "SKILL.md mais as referências geradas, para o seu agente de código.",
"ext.author.manifest_only": "declarado no manifesto; sem handler",
"ext.author.open": "Me mostre como",
"ext.author.seam": "Seam",
"ext.author.shape": "Seu handler",
"ext.config.none": "Esta extensão não declara ajustes",
"ext.config.saved": "Salvo.",
"ext.config.secret.set": "Há um valor guardado. Deixe vazio para mantê-lo.",
Expand Down Expand Up @@ -42,5 +52,7 @@
"ext.source.hint": "Um id do índice, github.com/org/repo@v1.2.0, um .zip https, ou um caminho no host.",
"ext.tier": "Confiança",
"ext.tracking": "seguindo {ref}",
"ext.tracking.label": "Seguindo"
"ext.tracking.label": "Seguindo",
"ext.upload": "Escolher um .zip",
"ext.upload.hint": "O caminho acima é um caminho no HOST. Para instalar uma extensão desta máquina, envie o arquivo."
}
Loading
Loading