Registry of every Logos module available in the latest Logos Basecamp release — cards, canonical LIDL contracts, annotated interfaces, stack and integration…

An AI-readable registry of the official Logos modules: everything the latest Logos Basecamp release ships with or can install from its default catalog. For each module it has a card, the canonical LIDL contract and the annotated interface source, all pinned to the exact commit that was released. On top of that are hand-written docs on how the modules fit together.
It exists so a coding agent can answer "what can I call, and how?" in minutes, and not by spelunking through 50 repos. AI agents should start with AGENTS.md.
Pick one:
/plugin marketplace add corpetty/logos-module-atlas
/plugin install logos-module-atlas@logos-module-atlas
This installs the logos-module-atlas skill, which Claude loads whenever a task involves Logos modules. The registry refreshes daily, but Claude Code doesn't auto-update plugins from third-party marketplaces unless you turn it on: in /plugin, open the Marketplaces tab, select logos-module-atlas and choose Enable auto-update. To update by hand, run claude plugin marketplace update logos-module-atlas, then claude plugin update logos-module-atlas@logos-module-atlas.
On Claude Code 2.1.287 or later, the plugin is also a mod (hooks/), which adds:
module, method and search (as mcp__logos-module-atlas__*) answer from the registry and contracts in one call.metadata.json mistake that breaks a module (a string in uses, a version range no release satisfies). The same edit made again goes through, so a wrong check can't block Claude. Risks such as an unversioned dependency are passed to Claude as a note instead./atlas: a pane to browse and search the modules, with no Claude turn. /atlas <module> or /atlas <words> answers in the transcript, and works while Claude is busy.A mod is code that runs with your permissions. This one reads the plugin's own files and any metadata.json Claude edits, and makes no network or process calls: claude plugin validate .claude-plugin/plugin.json lists every call it makes. Tests run with claude plugin test. To turn the mod off and keep the skill, set "disableAllHooks": true in your settings, or disable the plugin in /plugin.
CLAUDE.md, given a local clone (e.g. Muster): Logos module reference: ~/src/logos-module-atlas — read its AGENTS.md
before calling or depending on any official Logos module.
Start the session with claude --add-dir ~/src/logos-module-atlas so it can read the files without prompting.
llms.txt indexes everything by raw URL, and registry.json is the whole registry in one file.| Source | What it gives |
|---|---|
Latest logos-co/logos-basecamp release | The modules bundled in the app (installedDistributed in its flake.nix, pinned by its flake.lock) |
kDefaultRepositoryUrl in that release's package downloader | The default catalog. Today: logos-co/logos-modules-release |
The catalog's index.json | Every installable package, with versions, platforms and dependencies |
publisherRef tag → catalog commit → submodule gitlink | The exact source commit each package was built from (same method as logos-release-set) |
| Each source repo at that commit | metadata.json, the interface source, the docs, and the .lidl contract via nix build #lidl |
AGENTS.md how an AI agent should use this repo (start here)
registry.json every module, machine-readable
llms.txt index of everything by raw URL
modules/<name>/ README.md card · <name>.lidl contract · interface.* source · NOTES.md
stacks/ EVM wallet, Monero wallet, blockchain & LEZ, messaging, storage, platform, key custody
guides/ calling official modules from your own module; SDK/variant compatibility
gaps.md roadmap items not in the release (e.g. Bitcoin, Zcash)
scripts/ build_registry.py, fetch_contracts.py, refresh.sh · check_mod.mjs, check_drift.py, issue.sh (CI checks and reports)
hooks/ the Claude Code mod: register.js (events, tools, /atlas pane) · atlas.js (logic)
tests/ the mod's tests, for `claude plugin test`
scripts/refresh.sh
You need python3, gh (or GITHUB_TOKEN), and nix with flakes for the contracts. A GitHub Action runs this daily. Before it commits, it checks that the refresh left the hand-written files alone and that the mod still validates, passes its tests and reads the new data (node scripts/check_mod.mjs). The Mod workflow runs the same checks on pull requests.
What the automation can't fix, it reports as GitHub issues that open and close themselves, one per label:
| Label | Opened when | Closed when |
|---|---|---|
atlas:drift | A stack doc, guide or gaps.md is behind the release, or a module has no stack of its own in data/stacks.json (scripts/check_drift.py) | A refresh finds nothing behind |
atlas:refresh-failed | The daily refresh fails | A refresh passes |
atlas:canary | The weekly canary finds the newest Claude Code breaks the mod | The canary passes |
atlas:raise-pin | A newer Claude Code passes, so CI's pin in .github/claude-code-version can move | The pin is the newest |
Both scheduled workflows also re-enable each other through the GitHub API, so the daily refresh keeps running through quiet stretches upstream when it has nothing to commit.
The atlas's own scripts and docs are MIT (LICENSE). Vendored modules/*/interface.* and modules/*/*.lidl files come from the upstream module repos and remain under their terms. Each one names its source repo and commit.
<!-- registry:start --> _Generated 2026-10-09 from Basecamp 0.3.2 and its default catalog Logos Official (index generated 2026-10-09T12:42)._
| Module | Type | Version | Where | Contract | Description |
|---|---|---|---|---|---|
amm_module | core | 0.1.0 | catalog | — | Core module for the Logos DEX (AMM) — on-chain pool resolution, swaps, and liquidity. |
amm_ui | ui_qml | 0.1.0 | catalog | — | Trade tokens and provide liquidity on the Logos DEX. |
blockchain_module | core | 0.3.1 | catalog | lidl | Logos blockchain node for logos-core |
blockchain_ui | ui_qml | 0.3.2 | catalog | — | Blockchain UI module for the Logos application |
lez_core | core | 0.5.0 | catalog | lidl | Logos Execution Zone Core Module for Logos Core |
lez_explorer_ui | ui_qml | 1.2.0 | catalog | — | Logos Execution Zone Block Explorer |
lez_indexer_module | core | 1.2.0 | catalog | lidl | Logos Execution Zone Indexer Module for Logos Core |
lez_wallet_ui | ui_qml | 1.2.1 | catalog | — | Execution Zone Wallet UI module for the Logos application |
logos_execution_zone | core | 1.0.0 | catalog | lidl | Logos Execution Zone Module for Logos Core |
| Module | Type | Version | Where | Contract | Description |
|---|---|---|---|---|---|
eth_rpc_module | core | 0.1.0 | catalog | lidl | Proxyable, fail-closed Ethereum JSON-RPC client. Per-chain config (endpoint + proxy); RPC calls keyed by chainId. socks5h/Tor-ready. |
eth_rpc_ui | ui_qml | 0.1.0 | catalog | — | Device-wide JSON-RPC endpoints and light-client verified routing, shared by every Logos wallet on this device. |
eth_wallet_backend | core | 0.1.0 | catalog | lidl | EVM wallet composer over reusable chain, asset, token, account, fee, and transaction-sender modules, with multi-chain balances and activity. |
eth_wallet_ui | ui_qml | 0.1.0 | catalog | — | View EVM assets and activity across enabled networks, and send on an explicitly selected chain. Holds no key material. |
evm_assets_module | core | 0.1.0 | catalog | lidl | Reusable native and ERC-20 asset rows, balances, transfer building, and transaction-history decoration. |
evm_keystore_cli | core | 0.1.0 | catalog | lidl | Headless custodian for keystore_module: creates, imports, exports and deletes accounts over logosctl call. Every other headless surface… |
evm_keystore_ui | ui_qml | 0.1.0 | catalog | — | The one place accounts are created, imported, exported and deleted. Every other surface only reads which accounts exist. |
evm_signer_cli | core | 0.1.0 | catalog | lidl | Headless approver for keystore_module: shows what a module asked to sign, in the keystore's own words, over logosctl watch, and takes t… |
evm_signer_ui | ui_qml | 0.1.0 | catalog | — | The signing approval surface: shows what a module asked to sign, in the keystore's own words, and takes the vault password. |
fee_module | core | 0.1.0 | catalog | lidl | EIP-1559 fee suggestion for EVM chains: slow/normal/fast tiers derived from eth_feeHistory, with custom overrides. |
keystore_module | core | 0.1.0 | catalog | lidl | Keystore: scrypt vaults, BIP39/BIP32 HD derivation, secp256k1 signing. No network; private keys never leave the module. |
token_list_module | core | 0.1.0 | catalog | lidl | Reusable EVM token catalogue, enabled snapshots, pinned assets and paged picker. Proxyable, fail-closed. |
token_list_ui | ui_qml | 0.1.0 | catalog | — | Device-wide token catalogue and enabled set: built-in defaults, extra list URLs, custom tokens, and the ERC-20s shared by every Logos wal… |
tx_sender_module | core | 0.1.0 | catalog | lidl | The one EVM transaction sender on the device: one nonce ledger, call bundles approved as one decision in the keystore, ordered broadcast,… |
uniswap_backend | core | 0.1.0 | catalog | lidl | The Uniswap app's backend: quotes, swaps and swap history composed from reusable EVM chain, token, asset, account, fee, quote and sender … |
uniswap_module | core | 0.1.0 | catalog | lidl | Uniswap price oracle + swap router: V2/V3/V4 token prices (best-rate, Multicall3-batched) and V2/V3 swap building. Multi-chain, configura… |
uniswap_ui | ui_qml | 0.1.0 | catalog | — | Swap EVM assets on Uniswap. A view over uniswap_backend, which composes the reusable EVM modules. |
verified_proxy_module | core | 0.1.3 | catalog | lidl | Light-client-verified Ethereum JSON-RPC, wrapping nimbus libverifproxy |
verified_proxy_ui | ui_qml | 0.1.0 | catalog | — | Configure and operate the light-client-verified Ethereum proxy |
| Module | Type | Version | Where | Contract | Description |
|---|---|---|---|---|---|
chat_module | core | 0.3.0 | catalog | lidl | Chat module for Logos |
chat_ui | ui_qml | 0.3.0 | catalog | — | Chat App for Logos - Private messaging interface |
delivery_demo | ui_qml | 0.3.0 | catalog | — | Educational UI demo for logos-delivery-module: subscribe to content topics, send and receive messages, see which delivery_module API call… |
delivery_module | core | 0.3.2 | catalog | lidl | Logos Delivery Module - High-level message-delivery API |
liblogos_lez_rln_module | core | 4.2.1 | catalog | lidl | RLN registry provider (LEZ chain reads + registration/funding txs) |
liblogos_rln_module | core | 0.10.0 | catalog | lidl | Registry-agnostic RLN membership management (RLN-MEMBERSHIP-MANAGEMENT) |
libp2p_module | core | 1.1.0 | catalog | lidl | Libp2p network protocol module for Logos |
| Module | Type | Version | Where | Contract | Description |
|---|---|---|---|---|---|
monero_node_module | core | 0.1.1 | catalog | lidl | Proxyable, fail-closed monerod JSON-RPC client. Per-network config (endpoint + proxy); socks5h/Tor-ready. Never runs a node itself; local… |
monero_wallet_backend | core | 0.1.1 | catalog | lidl | Monero wallet coordinator: wallet registry, sync poller, balances and history, send orchestration (build → review → broadcast). Holds no … |
monero_wallet_cli | core | 0.1.1 | catalog | lidl | Headless Monero wallet for monero_wallet_backend: open or create a wallet, read the balance and addresses, build a transfer, review it an… |
monero_wallet_core_module | core | 0.1.1 | catalog | lidl | In-process Monero wallet engine: wraps monero_c (wallet2 C ABI, LGPL-3.0, dynamically linked, built from source). Keys and the wallet pas… |
monero_wallet_ui | ui_qml | 0.1.1 | catalog | — | The Monero wallet: balances, a reviewed send, receive addresses with a QR, activity, and the wallet management (create, restore, open, cl… |
monerod_module | core | 0.1.1 | catalog | lidl | Runs a Monero node in-process: per-network config, start, stop, status and log tail. |
monerod_ui | ui_qml | 0.1.1 | catalog | — | Run and manage a local Monero node: sync progress, peers, settings and the node log. |
| Module | Type | Version | Where | Contract | Description |
|---|---|---|---|---|---|
accounts_ui | ui_qml | 0.3.0 | catalog | — | Create and manage Logos accounts and their keys. |
capability_module | core | 1.0.0 | bundled | lidl | Coordinates permissions between modules |
modules_state | core | 0.1.0 | bundled | lidl | Read-only registry of module lifecycle state |
openmetrics | core | 0.1.2 | catalog | lidl | Serves an OpenMetrics /metrics endpoint by scraping modules that implement collectMetrics() |
package_downloader | core | 1.0.0 | bundled | lidl | Online package catalog and download service |
package_manager | core | 1.0.0 | bundled | lidl | Plugin manager for the Logos system |
package_manager_ui | ui_qml | 1.0.0 | bundled | — | Package Manager UI plugin for managing plugins and packages |
| Module | Type | Version | Where | Contract | Description |
|---|---|---|---|---|---|
storage_module | core | 3.0.2 | bundled + catalog | lidl | Storage module |
storage_ui | ui_qml | 3.0.2 | catalog | — | Storage interface for the Logos application |
<!-- registry:end -->
hooks/register.js 341 lines1// The Logos Module Atlas as a Claude Code mod (Claude Code 2.1.287 or later):
2// - tools Claude calls to look up modules, contracts and methods
3// - context on any prompt that names a module
4// - checks on edits that call a module or declare dependencies on one
5// - /atlas, a command and a pane for browsing the atlas
6// The logic lives in ./atlas.js; this file reads the atlas, handles events and draws.
7import {
8 TOOL_PREFIX,
9 applyEdit,
10 checkCalls,
11 checkMetadata,
12 describeCallProblems,
13 findCalls,
14 findMentions,
15 groupByStack,
16 isStale,
17 mentionContext,
18 methodDetail,
19 moduleSummary,
20 overviewText,
21 parseLidl,
22 searchAtlas,
23 searchText,
24 title,
25 whereLine,
26} from './atlas.js'
27
28const PANE = 'atlas'
29// Files whose module calls the edit check reads: C++, Rust, Nim, QML and JavaScript.
30const CODE_FILE = /\.(c|cc|cpp|cxx|h|hh|hpp|hxx|rs|nim|qml|js|mjs|ts)$/
31
32let registry = null // registry.json, read at session start
33let byName = new Map()
34let root = ''
35let surface = null // 'terminal' or 'desktop', or null where nothing draws
36const contracts = new Map() // module name → parsed contract, read on first use
37const described = new Set() // modules whose context Claude already has in this conversation
38const refused = new Set() // edits refused once: the same edit again goes through
39
40// What the pane shows: a search, or one module
41let query = ''
42let selected = null
43
44async function loadRegistry($) {
45 root = $.plugin.root
46 registry = JSON.parse(await $.fs.read(`${root}/registry.json`))
47 byName = new Map(registry.modules.map((m) => [m.name, m]))
48 contracts.clear()
49}
50
51async function contractOf($, name) {
52 const m = byName.get(name)
53 if (!m?.contract?.file) return null
54 if (!contracts.has(name)) {
55 let contract = null
56 try {
57 contract = parseLidl(await $.fs.read(`${root}/modules/${name}/${m.contract.file}`))
58 } catch {}
59 contracts.set(name, contract)
60 }
61 return contracts.get(name)
62}
63
64// The text a Write would leave, or an Edit would leave in the file it changes
65async function editedText($, e) {
66 if (e.tool === 'Write') return e.content
67 try {
68 return applyEdit(await $.fs.read(e.file_path), e)
69 } catch {
70 return null
71 }
72}
73
74export function register(on) {
75 on('session.start', async ($, e, next) => {
76 surface = e.surface
77 await loadRegistry($)
78 await $.tool.register({
79 name: 'module',
80 description:
81 'Everything the Logos Module Atlas knows about one official Logos module (Logos Core / Basecamp): ' +
82 'what it is, where it ships, its version, source commit, dependencies both ways, every contract method ' +
83 'and event, and the paths of its card, LIDL contract, interface source and stack doc.',
84 inputSchema: {
85 type: 'object',
86 properties: { name: { type: 'string', description: 'Module name, such as keystore_module' } },
87 required: ['name'],
88 },
89 })
90 await $.tool.register({
91 name: 'method',
92 description:
93 "One method or event of a Logos module's LIDL contract, with its full description. Methods that take " +
94 'or return JSON in a tstr document the JSON shape only here, so read it before building a payload.',
95 inputSchema: {
96 type: 'object',
97 properties: {
98 module: { type: 'string', description: 'Module name, such as keystore_module' },
99 method: { type: 'string', description: 'Method or event name, such as request_approval' },
100 },
101 required: ['module', 'method'],
102 },
103 })
104 await $.tool.register({
105 name: 'search',
106 description:
107 'Search the official Logos modules and their methods by words, such as "approval" or "monero send". ' +
108 'Use it to find out whether a module that does something already exists.',
109 inputSchema: {
110 type: 'object',
111 properties: { query: { type: 'string', description: 'Words that must all appear' } },
112 required: ['query'],
113 },
114 })
115 // Last, because it throws if the name is taken, which would skip what follows it
116 await $.command.register({
117 name: 'atlas',
118 description: 'Browse the Logos Module Atlas, or look up a module or search it without a turn',
119 argumentHint: '[module | words]',
120 immediate: true,
121 })
122 return next(e)
123 })
124
125 // A new conversation hasn't seen the context earlier prompts carried
126 on('classic.SessionStart', async ($, e, next) => {
127 described.clear()
128 return next(e)
129 })
130
131 on('tool.call', { tool: 'mcp__logos-module-atlas__module' }, async ($, e) => {
132 const m = byName.get(String(e.name ?? '').trim())
133 if (!m) return { result: `No module named "${e.name}". ${searchText(registry, String(e.name ?? ''))}` }
134 return { result: moduleSummary(m, await contractOf($, m.name), root) }
135 })
136
137 on('tool.call', { tool: 'mcp__logos-module-atlas__method' }, async ($, e) => {
138 const m = byName.get(String(e.module ?? '').trim())
139 if (!m) return { result: `No module named "${e.module}". ${searchText(registry, String(e.module ?? ''))}` }
140 const contract = await contractOf($, m.name)
141 if (!contract) return { result: `${m.name} has no LIDL contract in the atlas. Its interface source: ${root}/modules/${m.name}/${m.interfaceSource?.file ?? 'README.md'}` }
142 return {
143 result:
144 methodDetail(m, contract, String(e.method ?? '').trim()) ??
145 `${m.name} declares no method or event "${e.method}". It declares: ${[...contract.methods.keys(), ...contract.events.keys()].join(', ')}`,
146 }
147 })
148
149 on('tool.call', { tool: 'mcp__logos-module-atlas__search' }, async ($, e) => {
150 return { result: searchText(registry, String(e.query ?? '')) }
151 })
152
153 // Name a module in a prompt, and Claude reads where it ships and where its contract is
154 on('prompt.submit', async ($, e, next) => {
155 if (!registry) return next(e)
156 const fresh = findMentions(e.text, byName).filter((n) => !described.has(n))
157 if (!fresh.length) return next(e)
158 for (const n of fresh) described.add(n)
159 const stale = isStale(registry.generatedAt, await $.clock.now())
160 const note = mentionContext(fresh.map((n) => byName.get(n)), registry, root, stale)
161 return next({ ...e, context: [...(e.context ?? []), note] })
162 })
163
164 // Check an edit against the contracts before it lands: calls to methods a module doesn't
165 // declare, and metadata.json mistakes that keep a module from loading. A refused edit
166 // made again goes through, with the problem in Claude's context, so a check that's wrong
167 // can't block Claude for good.
168 on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
169 const path = String(e.file_path ?? '')
170 // The atlas's own files quote wrong calls on purpose, in tests and examples
171 if (!registry || path.startsWith(root + '/')) return next(e)
172 let errors = []
173 let warnings = []
174 if (/(^|\/)metadata\.json$/.test(path)) {
175 const text = await editedText($, e)
176 let meta = null
177 try {
178 meta = JSON.parse(text)
179 } catch {}
180 if (meta && typeof meta === 'object' && typeof meta.name === 'string') ({ errors, warnings } = checkMetadata(meta, byName))
181 } else if (CODE_FILE.test(path)) {
182 const calls = findCalls(e.tool === 'Edit' ? e.new_string : e.content)
183 const known = new Map()
184 for (const name of new Set(calls.map((c) => c.module))) {
185 const contract = await contractOf($, name)
186 if (contract) known.set(name, contract)
187 }
188 errors = describeCallProblems(checkCalls(calls, known), byName, root)
189 }
190
191 if (errors.length) {
192 const key = path + '\n' + errors.join('\n')
193 if (!refused.has(key)) {
194 refused.add(key)
195 return {
196 deny:
197 'Logos Module Atlas refused this edit:\n- ' +
198 errors.join('\n- ') +
199 `\nCheck the contract, or call ${TOOL_PREFIX}module or ${TOOL_PREFIX}method, and fix the edit. ` +
200 'If you have checked and the edit is right, make the same edit again and it will go through.',
201 }
202 }
203 warnings = [...errors, ...warnings]
204 }
205 if (!warnings.length) return next(e)
206 const result = await next(e)
207 if (result.deny || result.isError) return result
208 const note = 'Logos Module Atlas, about the edit just made:\n- ' + warnings.join('\n- ')
209 return { ...result, context: [...(result.context ?? []), note] }
210 })
211
212 // /atlas opens the pane; /atlas <module> or /atlas <words> answers in the transcript
213 on('command.run', { command: 'atlas' }, async ($, e) => {
214 if (!registry) return { text: 'The atlas registry did not load. Check the debug log.' }
215 const arg = String(e.args ?? '').trim()
216 if (byName.has(arg)) return { text: moduleSummary(byName.get(arg), await contractOf($, arg), root) }
217 if (arg) return { text: searchText(registry, arg) }
218 if (!surface) return { text: overviewText(registry) }
219 query = ''
220 selected = null
221 await $.ui.open({ id: PANE, title: 'Logos Module Atlas', focus: true, closeOnEscape: true })
222 return {}
223 })
224
225 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
226 if (e.requestId !== PANE) return next(e)
227 const { Box, Text, Button, Input } = $.ui.resolve(e)
228 const redraw = () => $.ui.invalidate('ui.render')
229 const show = (name) => () => {
230 selected = name
231 redraw()
232 }
233 const line = (children, props = {}) => Text({ wrap: 'truncate-end', ...props, children })
234 const blank = () => Text({ children: [' '] })
235 const moduleRow = (m, note) =>
236 Box({
237 flexDirection: 'row',
238 columnGap: 2,
239 children: [Button({ key: 'm-' + m.name, label: m.name, plain: true, onPress: show(m.name) }), line([note], { dimColor: true })],
240 })
241
242 if (!registry) return line(['The atlas registry did not load.'])
243
244 if (selected && byName.has(selected)) {
245 const m = byName.get(selected)
246 const contract = await contractOf($, m.name)
247 const deps = (m.dependencies ?? []).map((d) => (typeof d === 'string' ? { name: d } : d))
248 const methods = contract ? [...contract.methods.values()] : (m.api?.methods ?? [])
249 const summaries = new Map((m.api?.methods ?? []).map((x) => [x.name, x.summary]))
250 const events = contract ? [...contract.events.values()] : []
251 const related = (title, prefix, list) =>
252 list.length
253 ? [
254 line([title], { bold: true }),
255 ...list.map((d) =>
256 byName.has(d.name)
257 ? Button({ key: prefix + d.name, label: d.version ? `${d.name} ${d.version}` : d.name, plain: true, onPress: show(d.name) })
258 : line([d.version ? `${d.name} ${d.version}` : d.name]),
259 ),
260 ]
261 : []
262 return Box({
263 flexDirection: 'column',
264 children: [
265 Box({
266 flexDirection: 'row',
267 columnGap: 3,
268 children: [
269 Button({ key: 'back', label: 'Back', hotkey: 'b', plain: true, autoFocus: true, onPress: show(null) }),
270 Button({
271 key: 'draft',
272 label: 'Draft a prompt',
273 hotkey: 'd',
274 plain: true,
275 onPress: async () => {
276 await $.prompt.fill({ text: `Using ${m.name} from the Logos Module Atlas, ` })
277 await $.ui.close({ id: PANE })
278 },
279 }),
280 ],
281 }),
282 blank(),
283 line([title(m)], { bold: true }),
284 Text({ children: [m.description ?? ''] }),
285 line([`${m.type} · ${m.language ?? '?'} · ${m.stack} stack · ${whereLine(m)}`], { dimColor: true }),
286 blank(),
287 ...related('Depends on', 'dep-', deps),
288 ...related('Required by', 'req-', (m.requiredBy ?? []).map((name) => ({ name }))),
289 blank(),
290 line([contract ? `Methods (${methods.length})` : `Methods from its interface source (${methods.length}), no LIDL contract`], { bold: true }),
291 ...methods.flatMap((x) => [line([x.signature]), ...(summaries.get(x.name) ? [line([' ' + summaries.get(x.name)], { dimColor: true })] : [])]),
292 ...(events.length ? [blank(), line([`Events (${events.length})`], { bold: true }), ...events.map((x) => line([x.signature]))] : []),
293 blank(),
294 line([`Contract: ${contract ? `${root}/modules/${m.name}/${m.contract.file}` : 'none'}`], { dimColor: true }),
295 ],
296 })
297 }
298
299 const search = Input({
300 key: 'search',
301 label: 'Find',
302 placeholder: 'a module, a method or a word, then Enter',
303 value: query,
304 submitLabel: 'search',
305 autoFocus: true,
306 onSubmit: (value) => {
307 query = value.trim()
308 redraw()
309 },
310 })
311
312 if (query) {
313 const results = searchAtlas(registry, query)
314 return Box({
315 flexDirection: 'column',
316 children: [
317 search,
318 blank(),
319 ...(results.length
320 ? results.map(({ module: m, methods }) => moduleRow(m, methods.length ? 'methods: ' + methods.join(', ') : m.description ?? ''))
321 : [line([`Nothing matches "${query}". Roadmap items that aren't released are in gaps.md.`])]),
322 ],
323 })
324 }
325
326 const stacks = groupByStack(registry).flatMap(([title, mods]) => [
327 blank(),
328 line([`${title} (${mods.length})`], { bold: true }),
329 ...mods.map((m) => moduleRow(m, m.description ?? '')),
330 ])
331 return Box({
332 flexDirection: 'column',
333 children: [
334 search,
335 line([`${registry.modules.length} modules in Basecamp ${registry.basecamp.tag}, registry generated ${registry.generatedAt.slice(0, 10)}`], { dimColor: true }),
336 ...stacks,
337 ],
338 })
339 })
340}
341hooks/atlas.js 461 lines1// Pure helpers for the atlas mod. Nothing here touches the mods API, so tests call these
2// directly and register.js does all the reading, drawing and event handling.
3
4export const TOOL_PREFIX = 'mcp__logos-module-atlas__'
5
6// Stacks whose modules hold or request keys; their context also points at key-custody.md.
7const KEY_STACKS = new Set(['evm-wallet', 'monero-wallet'])
8
9// ---------------------------------------------------------------------------
10// Contracts
11
12// One `method` or `event` line of a .lidl contract. Descriptions are one quoted string
13// with escaped newlines, so every declaration fits on one line.
14const LIDL_DECL =
15 /^\s*(method|event)\s+(\w+)\s*\(([^)]*)\)(?:\s*->\s*(.+?))?(?:\s+description\s+"((?:[^"\\]|\\.)*)")?\s*$/
16
17function unescapeLidl(raw) {
18 try {
19 return JSON.parse('"' + raw + '"')
20 } catch {
21 return raw.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\')
22 }
23}
24
25/** Methods and events of a .lidl contract, each { name, signature, description }. */
26export function parseLidl(text) {
27 const methods = new Map()
28 const events = new Map()
29 for (const line of text.split('\n')) {
30 const m = LIDL_DECL.exec(line)
31 if (!m) continue
32 const [, kind, name, params, ret, desc] = m
33 const signature = `${name}(${params.trim()})` + (ret ? ` -> ${ret.trim()}` : '')
34 const entry = { name, signature, description: desc == null ? '' : unescapeLidl(desc) }
35 ;(kind === 'method' ? methods : events).set(name, entry)
36 }
37 return { methods, events }
38}
39
40// ---------------------------------------------------------------------------
41// Calls in source code
42
43// The call shapes guides/calling-official-modules.md documents. Only shapes that name the
44// module and the method in one expression can be checked; a proxy held in a variable can't.
45const CALL_PATTERNS = [
46 // C++ and Rust typed clients: modules().keystore_module.request_approval(...)
47 { kind: 'typed', re: /\bmodules\(\)\s*\.\s*(\w+)\s*\.\s*(\w+)\s*\(/g },
48 // C++ untyped client: modules().dynamic("keystore_module").invoke("list_accounts", ...)
49 { kind: 'dynamic', re: /\bmodules\(\)\s*\.\s*dynamic\(\s*"(\w+)"\s*\)\s*\.\s*invoke\(\s*"(\w+)"/g },
50 // QML bridge: logos.callModule[Async]("keystore_module", "list_accounts", ...)
51 { kind: 'qml', re: /\blogos\s*\.\s*callModule(?:Async)?\(\s*["'](\w+)["']\s*,\s*["'](\w+)["']/g },
52 // QML events: logos.onModuleEvent("keystore_module", "accounts_changed")
53 { kind: 'event', re: /\blogos\s*\.\s*onModuleEvent\(\s*["'](\w+)["']\s*,\s*["'](\w+)["']/g },
54]
55
56// Wrapper names the SDKs generate around a contract method: C++ fooAsync/fooAsyncResult,
57// Rust foo_async/foo_with_timeout/foo_async_with_timeout.
58const WRAPPER_SUFFIXES = ['AsyncResult', 'Async', '_async_with_timeout', '_with_timeout', '_async']
59
60/** Every checkable module call in `text`, as { kind, module, member }. */
61export function findCalls(text) {
62 const calls = []
63 if (!text) return calls
64 for (const { kind, re } of CALL_PATTERNS) {
65 for (const m of text.matchAll(re)) {
66 if (kind === 'typed' && m[1] === 'dynamic') continue
67 calls.push({ kind, module: m[1], member: m[2] })
68 }
69 }
70 return calls
71}
72
73function methodCandidates(member) {
74 const names = [member]
75 for (const suffix of WRAPPER_SUFFIXES) {
76 if (member.endsWith(suffix) && member.length > suffix.length) names.push(member.slice(0, -suffix.length))
77 }
78 return names
79}
80
81function editDistance(a, b) {
82 const row = Array.from({ length: b.length + 1 }, (_, i) => i)
83 for (let i = 1; i <= a.length; i++) {
84 let prev = row[0]
85 row[0] = i
86 for (let j = 1; j <= b.length; j++) {
87 const cur = row[j]
88 row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1))
89 prev = cur
90 }
91 }
92 return row[b.length]
93}
94
95/**
96 * Up to `limit` names from `entries` (a contract's methods or events) closest to `name`:
97 * by spelling, by shared words, and by words of `name` that a description uses, so that
98 * `sign_tx` finds the keystore's `request_approval` ("approve signing").
99 */
100export function closest(name, entries, limit = 3) {
101 const words = name.split(/_|(?=[A-Z])/).map((w) => w.toLowerCase()).filter((w) => w.length >= 3)
102 return [...entries.values()]
103 .map(({ name: c, description }) => {
104 const spelling = editDistance(name.toLowerCase(), c.toLowerCase()) / Math.max(name.length, c.length)
105 const inName = words.filter((w) => c.toLowerCase().split('_').includes(w)).length
106 const inText = words.filter((w) => new RegExp(`\\b${w}`, 'i').test(description ?? '')).length
107 return { c, score: 10 * spelling - 3 * inName - 1.5 * inText }
108 })
109 .sort((x, y) => x.score - y.score || x.c.localeCompare(y.c))
110 .slice(0, limit)
111 .map((x) => x.c)
112}
113
114/**
115 * Calls that name a module the atlas has a contract for, with a method or event that
116 * contract doesn't declare. `contracts` maps a module name to its parsed contract; a module
117 * missing from it (third-party, or no contract) isn't checked.
118 */
119export function checkCalls(calls, contracts) {
120 const problems = []
121 const seen = new Set()
122 for (const call of calls) {
123 const contract = contracts.get(call.module)
124 if (!contract) continue
125 const key = `${call.kind}:${call.module}.${call.member}`
126 if (seen.has(key)) continue
127 seen.add(key)
128 if (call.kind === 'event') {
129 if (!contract.events.has(call.member)) {
130 problems.push({ ...call, suggestions: closest(call.member, contract.events) })
131 }
132 continue
133 }
134 // A typed client also has event subscriptions (onAccountsChanged, on_event), which
135 // aren't contract methods.
136 if (call.kind === 'typed' && /^on[A-Z_]/.test(call.member)) continue
137 const names = call.kind === 'typed' ? methodCandidates(call.member) : [call.member]
138 if (!names.some((n) => contract.methods.has(n))) {
139 problems.push({ ...call, suggestions: closest(names[names.length - 1], contract.methods) })
140 }
141 }
142 return problems
143}
144
145/** One line per problem, for the text Claude reads. */
146export function describeCallProblems(problems, byName, root) {
147 return problems.map((p) => {
148 const what = p.kind === 'event' ? 'event' : 'method'
149 const near = p.suggestions.length ? ` Closest: ${p.suggestions.join(', ')}.` : ''
150 return `${p.module} declares no ${what} \`${p.member}\`.${near} Contract: ${modulePaths(byName.get(p.module), root).contract}`
151 })
152}
153
154// ---------------------------------------------------------------------------
155// Version ranges, as liblogos's dependency gate reads them
156
157function parseVersion(v) {
158 const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v).trim())
159 return m ? { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] ?? '' } : null
160}
161
162function compare(a, b) {
163 return a.major - b.major || a.minor - b.minor || a.patch - b.patch
164}
165
166// A partial version such as 1, 1.2, 1.x or 1.2.* : the numbers before the first wildcard.
167function parsePartial(s) {
168 const parts = []
169 for (const p of s.replace(/^v/, '').split('.')) {
170 if (p === '' || /^[xX*]$/.test(p)) break
171 if (!/^\d+$/.test(p)) return null
172 parts.push(+p)
173 }
174 return parts.length > 3 ? null : parts
175}
176
177function lower(parts) {
178 return { major: parts[0] ?? 0, minor: parts[1] ?? 0, patch: parts[2] ?? 0 }
179}
180
181// The predicate one comparator such as ^0.1.0, ~1.2, >=1.0.0 or 1.x stands for.
182function comparator(token) {
183 const m = /^(\^|~|>=|<=|>|<|=)?(.*)$/.exec(token)
184 const op = m[1] ?? ''
185 const parts = parsePartial(m[2])
186 if (parts == null) return null
187 if (parts.length === 0) return op === '' || op === '>=' ? () => true : null
188 const lo = lower(parts)
189 let hi = null
190 if (op === '^') {
191 if (parts[0] > 0 || parts.length === 1) hi = { major: parts[0] + 1, minor: 0, patch: 0 }
192 else if ((parts[1] ?? 0) > 0 || parts.length === 2) hi = { major: 0, minor: parts[1] + 1, patch: 0 }
193 else hi = { major: 0, minor: 0, patch: parts[2] + 1 }
194 } else if (op === '~') {
195 hi = parts.length === 1 ? { major: parts[0] + 1, minor: 0, patch: 0 } : { major: parts[0], minor: parts[1] + 1, patch: 0 }
196 } else if (op === '' || op === '=') {
197 if (parts.length === 3) return (v) => compare(v, lo) === 0
198 hi = parts.length === 1 ? { major: parts[0] + 1, minor: 0, patch: 0 } : { major: parts[0], minor: parts[1] + 1, patch: 0 }
199 } else {
200 return {
201 '>=': (v) => compare(v, lo) >= 0,
202 '>': (v) => compare(v, lo) > 0,
203 '<=': (v) => compare(v, lo) <= 0,
204 '<': (v) => compare(v, lo) < 0,
205 }[op]
206 }
207 return (v) => compare(v, lo) >= 0 && compare(v, hi) < 0
208}
209
210/**
211 * Whether `version` satisfies the npm-style `range`: true, false, or 'unsupported' for a
212 * range liblogos refuses outright (hyphen ranges) or can't be read. A prerelease version
213 * never satisfies, as the gate refuses 1.0.0-dev against a caret range.
214 */
215export function satisfies(version, range) {
216 const v = parseVersion(version)
217 if (!v) return false
218 const alternatives = String(range).split('||').map((a) => a.trim())
219 let unsupported = false
220 for (const alt of alternatives) {
221 if (/\s-\s/.test(alt)) {
222 unsupported = true
223 continue
224 }
225 const tests = alt.split(/\s+/).filter(Boolean).map(comparator)
226 if (tests.some((t) => t == null)) {
227 unsupported = true
228 continue
229 }
230 if (!v.pre && tests.every((t) => t(v))) return true
231 }
232 return unsupported ? 'unsupported' : false
233}
234
235// ---------------------------------------------------------------------------
236// metadata.json
237
238/** Every version of a module that the release ships, catalog and bundled. */
239export function releasedVersions(m) {
240 const versions = new Set(m.catalog?.versions ?? [])
241 if (m.catalog?.latest) versions.add(m.catalog.latest)
242 if (m.availability?.bundledVersion) versions.add(m.availability.bundledVersion)
243 return [...versions]
244}
245
246/**
247 * Problems in a Logos module's metadata.json that guides/calling-official-modules.md
248 * describes. `errors` make the module fail to load or misbehave; `warnings` are risks.
249 */
250export function checkMetadata(meta, byName) {
251 const errors = []
252 const warnings = []
253 for (const u of Array.isArray(meta.uses) ? meta.uses : []) {
254 if (typeof u === 'string') {
255 errors.push(
256 `"uses" entry "${u}" is a string. It parses but declares nothing, so every request fails with not_declared. Write it as { "intent": "${u}" }.`,
257 )
258 }
259 }
260 for (const d of Array.isArray(meta.dependencies) ? meta.dependencies : []) {
261 const name = typeof d === 'string' ? d : d?.name
262 const m = byName.get(name)
263 if (!m) continue
264 const released = releasedVersions(m)
265 if (!released.length) continue
266 const range = typeof d === 'string' ? undefined : d?.version
267 if (!range) {
268 const newest = m.catalog?.latest ?? released[released.length - 1]
269 warnings.push(
270 `Dependency ${name} has no version range, so it resolves to the newest catalog version and nothing checks raw lp calls against a newer contract. Pin one, such as "~${newest}".`,
271 )
272 continue
273 }
274 const verdict = released.map((v) => satisfies(v, range))
275 if (verdict.includes('unsupported')) {
276 errors.push(`Dependency ${name}: liblogos refuses the range "${range}". Use ^, ~, x-ranges or comparators, not a hyphen range.`)
277 } else if (!verdict.includes(true)) {
278 errors.push(
279 `Dependency ${name}: the range "${range}" matches no version this release ships (${released.join(', ')}), and liblogos refuses to load a module whose dependency doesn't satisfy its range.`,
280 )
281 }
282 }
283 if (Array.isArray(meta.capabilities) && meta.capabilities.length) {
284 warnings.push(
285 `"capabilities" is decorative: nothing reads it. The real privilege key is host_services, which only capability_module may hold.`,
286 )
287 }
288 return { errors, warnings }
289}
290
291/** The text an Edit would leave in a file, or null when old_string isn't in it. */
292export function applyEdit(current, e) {
293 if (!current.includes(e.old_string)) return null
294 return e.replace_all ? current.split(e.old_string).join(e.new_string) : current.replace(e.old_string, () => e.new_string)
295}
296
297// ---------------------------------------------------------------------------
298// Text for Claude and for the /atlas command
299
300/** Absolute paths of a module's files in the atlas. */
301export function modulePaths(m, root) {
302 const dir = `${root}/modules/${m.name}`
303 return {
304 card: `${dir}/README.md`,
305 contract: m.contract?.file ? `${dir}/${m.contract.file}` : null,
306 interface: m.interfaceSource?.file ? `${dir}/${m.interfaceSource.file}` : null,
307 stack: `${root}/stacks/${m.stack}.md`,
308 }
309}
310
311/** "v0.1.0 in the default catalog", "bundled in Basecamp (v1.0.0)", or both. */
312export function whereLine(m) {
313 const parts = []
314 if (m.availability?.bundledInBasecamp) parts.push(`bundled in Basecamp (v${m.availability.bundledVersion})`)
315 if (m.availability?.inDefaultCatalog) parts.push(`v${m.catalog?.latest} in the default catalog`)
316 return parts.join(', ') || 'not shipped'
317}
318
319/** "keystore_module" or "tx_sender_module: EVM Transaction Sender"; not every module has a display name. */
320export function title(m) {
321 return m.displayName ? `${m.name}: ${m.displayName}` : m.name
322}
323
324function depLine(d) {
325 return typeof d === 'string' ? d : d.version ? `${d.name} ${d.version}` : d.name
326}
327
328/** Names of known modules that `text` mentions, in the order they first appear. */
329export function findMentions(text, byName) {
330 const found = new Set()
331 for (const word of String(text).match(/\b[a-z][a-z0-9_]*\b/g) ?? []) if (byName.has(word)) found.add(word)
332 return [...found]
333}
334
335/** Whether `generatedAt` is more than `days` days before `now` (milliseconds). */
336export function isStale(generatedAt, now, days = 14) {
337 const t = Date.parse(generatedAt)
338 return Number.isFinite(t) && now - t > days * 86_400_000
339}
340
341/** The context a prompt that mentions these modules carries to Claude. */
342export function mentionContext(modules, registry, root, stale) {
343 const rel = registry.basecamp
344 const lines = [`Logos Module Atlas (Basecamp ${rel.tag}, registry generated ${registry.generatedAt.slice(0, 10)}):`]
345 for (const m of modules) {
346 const p = modulePaths(m, root)
347 const contract = p.contract ? `contract ${p.contract}` : `no LIDL contract; interface ${p.interface ?? p.card}`
348 lines.push(`- ${title(m)}, ${m.type} module in the ${m.stack} stack, ${whereLine(m)}. Card ${p.card}; ${contract}.`)
349 }
350 lines.push(
351 `Take method names and payload shapes only from the contract, or from the ${TOOL_PREFIX}module and ${TOOL_PREFIX}method tools. ` +
352 `Before writing integration code, read ${root}/guides/calling-official-modules.md and ${root}/guides/compatibility.md.`,
353 )
354 if (modules.some((m) => KEY_STACKS.has(m.stack))) {
355 lines.push(`Who holds keys and what is ungated: ${root}/stacks/key-custody.md. Design around the custodian; never copy key material into the caller.`)
356 }
357 if (stale) lines.push(`The registry is more than two weeks old; versions may have moved. Update the plugin before relying on them.`)
358 return lines.join('\n')
359}
360
361/** Everything the atlas knows about one module, as plain text. */
362export function moduleSummary(m, contract, root) {
363 const p = modulePaths(m, root)
364 const lines = [
365 `${title(m)} (${m.type}, ${m.language ?? 'unknown language'}, ${m.stack} stack)`,
366 m.description ?? '',
367 `Ships: ${whereLine(m)}. Platforms: ${(m.catalog?.variants ?? []).join(', ') || 'see card'}.`,
368 `Source: https://github.com/${m.source?.repo} at ${m.source?.commit} (read this commit, not HEAD).`,
369 `Depends on: ${(m.dependencies ?? []).map(depLine).join(', ') || 'nothing'}.`,
370 `Required by: ${(m.requiredBy ?? []).join(', ') || 'nothing in this release'}.`,
371 ]
372 const methods = contract ? [...contract.methods.values()] : null
373 const apiMethods = m.api?.methods ?? []
374 if (methods) {
375 lines.push('', `Methods (${methods.length}):`)
376 const summaries = new Map(apiMethods.map((x) => [x.name, x.summary]))
377 for (const x of methods) lines.push(` ${x.signature}` + (summaries.get(x.name) ? ` : ${summaries.get(x.name)}` : ''))
378 const events = [...contract.events.values()]
379 lines.push('', events.length ? `Events (${events.length}):` : 'Events: none.')
380 for (const x of events) lines.push(` ${x.signature}`)
381 } else if (apiMethods.length) {
382 lines.push('', `No LIDL contract. Methods from its interface source (${apiMethods.length}):`)
383 for (const x of apiMethods) lines.push(` ${x.signature}` + (x.summary ? ` : ${x.summary}` : ''))
384 }
385 lines.push(
386 '',
387 `Card: ${p.card}`,
388 p.contract ? `Contract: ${p.contract}` : 'Contract: none',
389 `Interface source: ${p.interface ?? 'none'}`,
390 `Stack doc: ${p.stack}`,
391 )
392 if (methods?.some((x) => x.signature.includes('tstr'))) {
393 lines.push(`Methods that take or return JSON in a tstr document its shape only in their description: call ${TOOL_PREFIX}method for it.`)
394 }
395 return lines.join('\n')
396}
397
398/** One method or event with its full description, or null. */
399export function methodDetail(m, contract, name) {
400 const x = contract?.methods.get(name) ?? contract?.events.get(name)
401 if (!x) return null
402 const kind = contract.methods.has(name) ? 'method' : 'event'
403 return `${m.name} ${kind} ${x.signature}\n\n${x.description || '(no description in the contract)'}`
404}
405
406/** Modules and methods matching every word of `query`, best first. */
407export function searchAtlas(registry, query) {
408 const words = String(query).toLowerCase().split(/\s+/).filter(Boolean)
409 if (!words.length) return []
410 const has = (s) => words.every((w) => String(s ?? '').toLowerCase().includes(w))
411 const results = []
412 for (const m of registry.modules) {
413 // A method matches when the words are in it or in its module's name ("monero send"),
414 // and at least one is in the method itself.
415 const methods = (m.api?.methods ?? [])
416 .filter((x) => {
417 const own = `${x.name} ${x.summary ?? ''}`.toLowerCase()
418 return words.some((w) => own.includes(w)) && words.every((w) => own.includes(w) || m.name.includes(w))
419 })
420 .map((x) => x.name)
421 const byModule = has(`${m.name} ${m.displayName ?? ''} ${m.description ?? ''} ${m.stack}`)
422 if (!byModule && !methods.length) continue
423 const score = (m.name.includes(words[0]) ? 0 : 2) + (byModule ? 0 : 1)
424 results.push({ module: m, methods, score })
425 }
426 return results.sort((a, b) => a.score - b.score || a.module.name.localeCompare(b.module.name))
427}
428
429/** Search results as text. */
430export function searchText(registry, query) {
431 const results = searchAtlas(registry, query)
432 if (!results.length) return `Nothing in the atlas matches "${query}". If you expected a module, check gaps.md: it lists roadmap items that aren't in this release.`
433 return results
434 .slice(0, 25)
435 .map(({ module: m, methods }) => {
436 const d = m.description ?? ''
437 const head = `${m.name} (${m.stack}): ${d.length > 120 ? d.slice(0, 117) + '…' : d}`
438 return methods.length ? `${head}\n methods: ${methods.join(', ')}` : head
439 })
440 .join('\n')
441}
442
443/**
444 * Modules grouped by stack, as [title, modules] in the registry's order. A module the
445 * generator placed in no known stack (a new catalog package nobody has assigned yet)
446 * goes in a last "Other" group.
447 */
448export function groupByStack(registry) {
449 const groups = Object.entries(registry.stacks).map(([key, stack]) => [stack.title, registry.modules.filter((m) => m.stack === key)])
450 groups.push(['Other', registry.modules.filter((m) => !registry.stacks[m.stack])])
451 return groups.filter(([, modules]) => modules.length)
452}
453
454/** Every module, grouped by stack, as text. */
455export function overviewText(registry) {
456 const lines = [`Logos Module Atlas: ${registry.modules.length} modules in Basecamp ${registry.basecamp.tag}, registry generated ${registry.generatedAt.slice(0, 10)}.`]
457 for (const [title, modules] of groupByStack(registry)) lines.push(`${title}: ${modules.map((m) => m.name).join(', ')}`)
458 lines.push('Run /atlas <module> for one module, or /atlas <words> to search.')
459 return lines.join('\n')
460}
461