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
18 changes: 17 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,15 @@
name: tests

on:
# `main` only. A commit on `develop` is a commit on a pull request's head —
# the ruleset requires one — so listing `develop` here ran the whole suite
# twice for the same SHA, once per event, on a two-version matrix: ~20
# runner-minutes to learn one thing. The pull_request event is the one that
# gates the merge, and a required check is matched by name against the head
# SHA whichever event produced it. The trade named plainly: a push to
# `develop` with no pull request open is no longer tested until one is.
push:
branches: [main, develop]
branches: [main]
pull_request:
branches: [main, develop]
# `release.yml` calls this same file instead of restating the suite. A
Expand All @@ -22,6 +29,15 @@ on:
permissions:
contents: read

# One run per ref at a time: pushing three times in a row used to leave two
# full suites racing to answer a question nobody would read the answer to.
# Never for a tag — that run is the gate in front of an irreversible upload
# (`release.yml` calls this file), and a push landing meanwhile must not be
# able to cancel it.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}

jobs:
tests:
runs-on: ubuntu-latest
Expand Down
28 changes: 27 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.64.md` is normative
**Vine**'s MCP primitives. `docs/monkeyllm-spec-v0.65.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,32 @@ Local models (llama.cpp on the 3090): see `docs/local-inference.md`.

## Conventions and pitfalls

- **The retrieval is done long before the reply is (spec J.5.15, v0.65)**:
a hosted `answer` is two costs three orders of magnitude apart — 19 ms of
sweep on the fixture against 10 s of provider — and since the Station
answers ONCE, the bundle sat in the host from millisecond 19 and left at
second 10, so the Ask console spent the gap on a spinner. The path panel
draws it instead: the console runs the sweep's retrieval ITSELF in
parallel through the ordinary `harvest` (same question, same `k`, same
ranker, so it is what the answer will see, and it forges no heat —
pheromone is the whisper's at the close of an answer, never a read's),
over the J.11 `graph` projection Explore already reads. Its switch is
SEPARATE from `hops` on purpose: a walk costs one model call per hop and
the drawing costs none, and one control over both teaches an operator
that the picture is what made the answer slow. Two rules are where the
honest version and the flattering one part: on a **sweep** `evidence` is
the whole bundle (the reply is prose and names nothing), so there is no
`cited` stage to draw and inventing one shows a selection that never
happened; and on a walk the entry `locate` marks nothing, because J.10.4
keeps only what carries text — the stage reads zero, which is true,
instead of being filled from `sources` to look complete. The trail
follows `parent` (already scope-filtered by J.11) and carries its OWN
colour token: a `discovered-shortcut` is a fact the forest holds, a trail
is what one question did to it. And the panel prints the real millisecond
figure off the Part D trace beside a reveal that takes seconds — on a
console whose subject is how cheap retrieval is, an audience must not
read the animation's own duration as the measurement. Live hops are NOT
here: that needs the host to push, which is a contract.
- **A transport method is not a capability, and 404 is not a refusal (spec
J.1.2 rule 4 + J.1.4, v0.64)**: J.1.2 rule 4 withholds what nothing is
registered behind, and the implementation withheld one name more than the
Expand Down
146 changes: 146 additions & 0 deletions apps/studio/check-trail.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026 Jimmy Wesley

/* J.5.15 acceptance (F.137): what the path panel marks, checked.
*
* Studio has no test runner and this file is not one — it is the console's
* decision layer, asked the criterion's own questions. `trailmap.js` holds
* the rules precisely so they can be asked here rather than inferred from
* a screenshot. `tests/test_trail_panel.py` runs it; a non-zero exit is a
* failed criterion, named on stdout.
*
* What this cannot see is the canvas — colours, motion, camera — and it is
* not supposed to: those are taste, and F.137 is about claims. */
import {
CITED, LOCATE, READ, SNIFF, markNodes, reachedStages, shortName,
stageCounts, stagesFor, trailSegments,
} from './src/trailmap.js'

let failed = 0
const ok = (n, c, extra = '') => {
if (!c) failed++
console.log(`${c ? 'PASS' : 'FAIL'} ${n}${extra ? ' ' + extra : ''}`)
}

/* A projection shaped like J.11's: id -> {parent}. */
const projection = (pairs) => new Map(pairs.map(([id, parent]) => [id, { id, parent }]))

const FOREST = projection([
['_index', null],
['projects/_index', '_index'],
['projects/mixerllm/_index', 'projects/_index'],
['projects/mixerllm/experiment-log', 'projects/mixerllm/_index'],
['projects/mixerllm/benchmarks', 'projects/mixerllm/_index'],
['people/_index', '_index'],
])

/* -- rule 3: a stage is what the material says, never an inference ------- */

const sweep = [
{ id: 'projects/mixerllm/experiment-log', found_by: ['locate', 'sniff'],
content: [{ section: null, body: 'x' }] },
{ id: 'projects/mixerllm/benchmarks', found_by: ['sniff'], content: [] },
{ id: 'people/_index', found_by: ['locate'], content: [] },
]
const m1 = markNodes(sweep, undefined)

ok('F.137 `locate` in found_by marks the entry stage',
m1.get('projects/mixerllm/experiment-log').has(LOCATE)
&& m1.get('people/_index').has(LOCATE))
ok('F.137 no `locate` in found_by does not mark the entry stage',
!m1.get('projects/mixerllm/benchmarks').has(LOCATE),
[...m1.get('projects/mixerllm/benchmarks')].join(','))
ok('F.137 `sniff` marks the literal stage and only where present',
m1.get('projects/mixerllm/benchmarks').has(SNIFF)
&& !m1.get('people/_index').has(SNIFF))
ok('F.137 content handed over marks the read stage, an empty list does not',
m1.get('projects/mixerllm/experiment-log').has(READ)
&& !m1.get('people/_index').has(READ))

const counts1 = stageCounts(m1)
ok('F.137 counts are what the material carries',
counts1[LOCATE] === 2 && counts1[SNIFF] === 2 && counts1[READ] === 1
&& counts1[CITED] === 0, counts1.join('/'))

/* -- rule 4: `cited` is a walk's stage and not a sweep's ------------------ */

ok('F.137 a sweep produces no cited stage at all',
stagesFor(undefined).length === 3
&& !stagesFor(undefined).some((s) => s.key === 'cited'))
ok('F.137 a sweep marks nothing as cited even when ids are known',
stageCounts(markNodes(sweep, undefined))[CITED] === 0)

const walk = [
{ id: 'projects/mixerllm/experiment-log', found_by: ['sniff', 'pick'],
content: [{ section: null, body: 'x' }] },
{ id: 'projects/mixerllm/benchmarks', found_by: ['sniff'], content: [] },
]
const m2 = markNodes(walk, ['projects/mixerllm/experiment-log'])
ok('F.137 a walk with answer_nodes produces a cited stage',
stagesFor(['x']).length === 4
&& m2.get('projects/mixerllm/experiment-log').has(CITED))
ok('F.137 a walk cites only what it names',
!m2.get('projects/mixerllm/benchmarks').has(CITED))
ok('F.137 a walk that cited nothing still HAS the stage',
stagesFor([]).length === 4, 'empty is a statement, absent is a mode')
ok('F.137 a walk `pick` marks the read stage',
m2.get('projects/mixerllm/experiment-log').has(READ))
ok('F.137 an unrecorded entry locate leaves the stage empty, not filled',
stageCounts(m2)[LOCATE] === 0)

/* -- the reveal crosses an empty leading stage, never stalls on it ------- */

ok('F.137 the reveal reaches past an empty entry stage on a walk',
reachedStages(m2) === CITED + 1, String(reachedStages(m2)))
ok('F.137 nothing marked reaches no stage', reachedStages(new Map()) === 0)

/* -- rule 5: a segment needs BOTH ends in the projection ------------------ */

const t1 = trailSegments(m1, FOREST)
const inside = t1.segments.every((s) => FOREST.has(s.a) && FOREST.has(s.b))
ok('F.137 every segment has both ends in the projection', inside,
`${t1.segments.length} segments`)

/* The same marks against a projection that stops at the branch: a scoped
principal receives no root, so the chain must stop there and the missing
ancestor must not be drawn to. */
const SCOPED = projection([
['projects/mixerllm/_index', null],
['projects/mixerllm/experiment-log', 'projects/mixerllm/_index'],
['projects/mixerllm/benchmarks', 'projects/mixerllm/_index'],
])
const t2 = trailSegments(markNodes(sweep, undefined), SCOPED)
ok('F.137 a chain that leaves the scope stops there',
t2.segments.every((s) => SCOPED.has(s.a) && SCOPED.has(s.b))
&& !t2.segments.some((s) => s.a === '_index' || s.b === '_index'),
t2.segments.map((s) => `${shortName(s.a)}>${shortName(s.b)}`).join(' '))
ok('F.137 a marked node absent from the projection draws no segment',
trailSegments(markNodes([{ id: 'ghost/node', found_by: ['locate'] }], undefined),
FOREST).segments.length === 0)

/* -- a shared trunk is one segment, at the earliest stage ---------------- */

const shared = t1.segments.filter((s) => s.a === '_index' && s.b === 'projects/_index')
ok('F.137 a shared ancestor is one segment', shared.length === 1,
`${shared.length} copies`)
ok('F.137 a shared segment lights at the earliest stage that needed it',
shared[0]?.stage === LOCATE, String(shared[0]?.stage))

/* -- naming: `_index` is the one part of an address that says nothing ---- */

ok('F.137 a branch is named by its branch',
shortName('projects/mixerllm/_index') === 'mixerllm/'
&& shortName('projects/mixerllm/experiment-log') === 'experiment-log'
&& shortName('_index') === '/')

/* -- nothing is invented from a malformed payload ------------------------ */

ok('F.137 junk in the material marks nothing',
markNodes([{ found_by: ['locate'] }, { id: 42, found_by: ['locate'] },
{ id: '', found_by: ['locate'] }, null], undefined).size === 0)
ok('F.137 a cycle in `parent` terminates',
trailSegments(markNodes([{ id: 'a', found_by: ['locate'] }], undefined),
projection([['a', 'b'], ['b', 'a']])).segments.length <= 2)

console.log(failed ? `\n${failed} criterion(s) failed` : '\nall criteria hold')
process.exit(failed ? 1 : 0)
7 changes: 5 additions & 2 deletions apps/studio/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { Card, ErrorNote, Field, Note, Spinner, Tabs } from './design/ui.jsx'
import { Forest, Globe, Moon, Sun } from './design/icons.jsx'
import { Shell, consolesFor } from './components/Shell.jsx'
import { Boundary } from './components/Boundary.jsx'
import Overview from './views/Overview.jsx'
import Ask from './views/Ask.jsx'
import Explore from './views/Explore.jsx'
Expand Down Expand Up @@ -133,8 +134,10 @@ export default function App() {
// Resolving in the effect above: one frame, not a screen.
<Spinner label={t('common.loading')} size={18} />
) : (
<View key={`${forest}:${view}`} forest={forest} grant={grant} me={session.me}
node={node} setNode={setNode} goto={goto} />
<Boundary resetKey={`${forest}:${view}:${node}`}>
<View key={`${forest}:${view}`} forest={forest} grant={grant} me={session.me}
node={node} setNode={setNode} goto={goto} />
</Boundary>
)}
</Shell>
)
Expand Down
85 changes: 85 additions & 0 deletions apps/studio/src/components/Boundary.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026 Jimmy Wesley

/* One console may fail; the console may not disappear.
*
* React 18 unmounts the whole tree on an uncaught render throw, so a single
* bad element anywhere below `App` leaves a blank page with no navigation,
* no forest picker and no way back — the operator's only repair is guessing
* that a reload might help. That is what a `<Field>` wrapping a `<Select>`
* did to the reading console: React refused an `<input>` with children
* (#137) and took the entire Studio down with it, every time a document was
* opened.
*
* The boundary is not the fix for that bug — the bug is fixed where it was
* written. It is the answer to the *class*: the shell survives, the address
* still says where the operator is, and the failure is reported as one
* console's failure instead of as the product ending.
*
* Deliberately below the Shell and never above it: a boundary that also
* caught the navigation would leave nothing to navigate WITH.
*/
import { Component } from 'react'
import { useI18n } from '../i18n.jsx'
import { Card } from '../design/ui.jsx'
import { Alert } from '../design/icons.jsx'

export class Boundary extends Component {
constructor(props) {
super(props)
this.state = { error: null }
}

static getDerivedStateFromError(error) {
return { error }
}

componentDidCatch(error, info) {
// The console has no error sink of its own, and inventing one would be a
// second, unaudited channel out of the operator's browser (J.5.13's
// premise). The devtools console is where a front-end failure belongs.
console.error('Studio console failed to render', error, info) // eslint-disable-line no-console
}

componentDidUpdate(prev) {
// `resetKey` is the place: moving console, forest or node must clear a
// failure that belonged to where the operator no longer is. Without
// this, one broken document would poison every later navigation.
if (this.state.error && prev.resetKey !== this.props.resetKey) {
this.setState({ error: null })
}
}

render() {
if (!this.state.error) return this.props.children
return <Crashed error={this.state.error}
onRetry={() => this.setState({ error: null })} />
}
}

/** The report. A function component because the strings are translated and
* a class cannot hold a hook. */
function Crashed({ error, onRetry }) {
const { t } = useI18n()
return (
<Card>
<div className="py-6 text-center">
<span className="mx-auto mb-3 grid h-11 w-11 place-items-center rounded-xl
bg-surface-2 text-danger"><Alert size={20} /></span>
<p className="text-[13.5px] font-medium text-text">{t('app.crashed')}</p>
<p className="mx-auto mt-1 max-w-[46ch] text-[12.5px] text-text-3">
{t('app.crashed_hint')}
</p>
{error?.message && (
<code className="mx-auto mt-3 block max-w-[60ch] truncate rounded-lg bg-surface-2
px-3 py-1.5 text-left text-[11.5px] text-text-3">
{String(error.message)}
</code>
)}
<button className="btn btn-primary btn-sm mt-4" onClick={onRetry}>
{t('app.crashed_retry')}
</button>
</div>
</Card>
)
}
6 changes: 6 additions & 0 deletions apps/studio/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@
--type-other: 120 132 124;
--graph-edge: 122 138 126;
--graph-shortcut: 200 132 40;
/* The path one answer took (J.5.4 + the Ask trail). Its own
token and not `--graph-shortcut`: a shortcut is a fact the
forest holds, a trail is what this question did to it, and
two different facts must not arrive in one colour. */
--graph-trail: 202 146 18;

/* Syntax colour (design/highlight.jsx) — one token palette for every
language the console prints. Aliased to the type-hues above, not
Expand Down Expand Up @@ -114,6 +119,7 @@
--type-other: 150 162 152;
--graph-edge: 110 126 114;
--graph-shortcut: 255 190 100;
--graph-trail: 250 214 96;
color-scheme: dark;
}

Expand Down
5 changes: 4 additions & 1 deletion apps/studio/src/locales/app/en.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"app.name": "MonkeyLLM",
"app.studio": "Studio",
"app.tagline": "Console for a governed forest."
"app.tagline": "Console for a governed forest.",
"app.crashed": "This console stopped rendering",
"app.crashed_hint": "Something in this page failed. The forest is untouched and nothing was lost — try again, or move to another console.",
"app.crashed_retry": "Try again"
}
5 changes: 4 additions & 1 deletion apps/studio/src/locales/app/es.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"app.name": "MonkeyLLM",
"app.studio": "Studio",
"app.tagline": "Consola de un bosque gobernado."
"app.tagline": "Consola de un bosque gobernado.",
"app.crashed": "Esta consola dejó de renderizarse",
"app.crashed_hint": "Algo en esta página falló. El bosque está intacto y no se perdió nada — inténtalo de nuevo o ve a otra consola.",
"app.crashed_retry": "Intentar de nuevo"
}
5 changes: 4 additions & 1 deletion apps/studio/src/locales/app/pt.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"app.name": "MonkeyLLM",
"app.studio": "Studio",
"app.tagline": "Console de uma floresta governada."
"app.tagline": "Console de uma floresta governada.",
"app.crashed": "Este console parou de renderizar",
"app.crashed_hint": "Algo nesta página falhou. A floresta está intacta e nada foi perdido — tente de novo ou vá para outro console.",
"app.crashed_retry": "Tentar de novo"
}
Loading
Loading