SLOPSHOPPER

Logos Module Atlas

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

newpaneguardcommandprompttool
v0.1.202610091314MITupdated 2026-10-09corpetty/logos-module-atlas
A shopper browsing a rack in a slop shop
README

Logos Module Atlas

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.

Using it with Claude Code

Pick one:

  • As a plugin (works in any project, nothing to clone):
  /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:

  • Tools for Claude: module, method and search (as mcp__logos-module-atlas__*) answer from the registry and contracts in one call.
  • Context: a prompt that names a module carries a line on where it ships and where its contract is, once per conversation.
  • Edit checks: an edit that calls a method or event a module's contract doesn't declare is refused with the closest real names, and so is a 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.

  • From a project's 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.

  • Without Claude Code: llms.txt indexes everything by raw URL, and registry.json is the whole registry in one file.

What's covered and where it comes from

SourceWhat it gives
Latest logos-co/logos-basecamp releaseThe modules bundled in the app (installedDistributed in its flake.nix, pinned by its flake.lock)
kDefaultRepositoryUrl in that release's package downloaderThe default catalog. Today: logos-co/logos-modules-release
The catalog's index.jsonEvery installable package, with versions, platforms and dependencies
publisherRef tag → catalog commit → submodule gitlinkThe exact source commit each package was built from (same method as logos-release-set)
Each source repo at that commitmetadata.json, the interface source, the docs, and the .lidl contract via nix build #lidl

Layout

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`

Refreshing

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:

LabelOpened whenClosed when
atlas:driftA 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-failedThe daily refresh failsA refresh passes
atlas:canaryThe weekly canary finds the newest Claude Code breaks the modThe canary passes
atlas:raise-pinA newer Claude Code passes, so CI's pin in .github/claude-code-version can moveThe 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.

License

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.

Modules

<!-- registry:start --> _Generated 2026-10-09 from Basecamp 0.3.2 and its default catalog Logos Official (index generated 2026-10-09T12:42)._

Blockchain & LEZ

ModuleTypeVersionWhereContractDescription
amm_modulecore0.1.0catalog—Core module for the Logos DEX (AMM) — on-chain pool resolution, swaps, and liquidity.
amm_uiui_qml0.1.0catalog—Trade tokens and provide liquidity on the Logos DEX.
blockchain_modulecore0.3.1cataloglidlLogos blockchain node for logos-core
blockchain_uiui_qml0.3.2catalog—Blockchain UI module for the Logos application
lez_corecore0.5.0cataloglidlLogos Execution Zone Core Module for Logos Core
lez_explorer_uiui_qml1.2.0catalog—Logos Execution Zone Block Explorer
lez_indexer_modulecore1.2.0cataloglidlLogos Execution Zone Indexer Module for Logos Core
lez_wallet_uiui_qml1.2.1catalog—Execution Zone Wallet UI module for the Logos application
logos_execution_zonecore1.0.0cataloglidlLogos Execution Zone Module for Logos Core

EVM wallet

ModuleTypeVersionWhereContractDescription
eth_rpc_modulecore0.1.0cataloglidlProxyable, fail-closed Ethereum JSON-RPC client. Per-chain config (endpoint + proxy); RPC calls keyed by chainId. socks5h/Tor-ready.
eth_rpc_uiui_qml0.1.0catalog—Device-wide JSON-RPC endpoints and light-client verified routing, shared by every Logos wallet on this device.
eth_wallet_backendcore0.1.0cataloglidlEVM wallet composer over reusable chain, asset, token, account, fee, and transaction-sender modules, with multi-chain balances and activity.
eth_wallet_uiui_qml0.1.0catalog—View EVM assets and activity across enabled networks, and send on an explicitly selected chain. Holds no key material.
evm_assets_modulecore0.1.0cataloglidlReusable native and ERC-20 asset rows, balances, transfer building, and transaction-history decoration.
evm_keystore_clicore0.1.0cataloglidlHeadless custodian for keystore_module: creates, imports, exports and deletes accounts over logosctl call. Every other headless surface…
evm_keystore_uiui_qml0.1.0catalog—The one place accounts are created, imported, exported and deleted. Every other surface only reads which accounts exist.
evm_signer_clicore0.1.0cataloglidlHeadless 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_uiui_qml0.1.0catalog—The signing approval surface: shows what a module asked to sign, in the keystore's own words, and takes the vault password.
fee_modulecore0.1.0cataloglidlEIP-1559 fee suggestion for EVM chains: slow/normal/fast tiers derived from eth_feeHistory, with custom overrides.
keystore_modulecore0.1.0cataloglidlKeystore: scrypt vaults, BIP39/BIP32 HD derivation, secp256k1 signing. No network; private keys never leave the module.
token_list_modulecore0.1.0cataloglidlReusable EVM token catalogue, enabled snapshots, pinned assets and paged picker. Proxyable, fail-closed.
token_list_uiui_qml0.1.0catalog—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_modulecore0.1.0cataloglidlThe one EVM transaction sender on the device: one nonce ledger, call bundles approved as one decision in the keystore, ordered broadcast,…
uniswap_backendcore0.1.0cataloglidlThe Uniswap app's backend: quotes, swaps and swap history composed from reusable EVM chain, token, asset, account, fee, quote and sender …
uniswap_modulecore0.1.0cataloglidlUniswap price oracle + swap router: V2/V3/V4 token prices (best-rate, Multicall3-batched) and V2/V3 swap building. Multi-chain, configura…
uniswap_uiui_qml0.1.0catalog—Swap EVM assets on Uniswap. A view over uniswap_backend, which composes the reusable EVM modules.
verified_proxy_modulecore0.1.3cataloglidlLight-client-verified Ethereum JSON-RPC, wrapping nimbus libverifproxy
verified_proxy_uiui_qml0.1.0catalog—Configure and operate the light-client-verified Ethereum proxy

Messaging

ModuleTypeVersionWhereContractDescription
chat_modulecore0.3.0cataloglidlChat module for Logos
chat_uiui_qml0.3.0catalog—Chat App for Logos - Private messaging interface
delivery_demoui_qml0.3.0catalog—Educational UI demo for logos-delivery-module: subscribe to content topics, send and receive messages, see which delivery_module API call…
delivery_modulecore0.3.2cataloglidlLogos Delivery Module - High-level message-delivery API
liblogos_lez_rln_modulecore4.2.1cataloglidlRLN registry provider (LEZ chain reads + registration/funding txs)
liblogos_rln_modulecore0.10.0cataloglidlRegistry-agnostic RLN membership management (RLN-MEMBERSHIP-MANAGEMENT)
libp2p_modulecore1.1.0cataloglidlLibp2p network protocol module for Logos

Monero wallet

ModuleTypeVersionWhereContractDescription
monero_node_modulecore0.1.1cataloglidlProxyable, fail-closed monerod JSON-RPC client. Per-network config (endpoint + proxy); socks5h/Tor-ready. Never runs a node itself; local…
monero_wallet_backendcore0.1.1cataloglidlMonero wallet coordinator: wallet registry, sync poller, balances and history, send orchestration (build → review → broadcast). Holds no …
monero_wallet_clicore0.1.1cataloglidlHeadless 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_modulecore0.1.1cataloglidlIn-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_uiui_qml0.1.1catalog—The Monero wallet: balances, a reviewed send, receive addresses with a QR, activity, and the wallet management (create, restore, open, cl…
monerod_modulecore0.1.1cataloglidlRuns a Monero node in-process: per-network config, start, stop, status and log tail.
monerod_uiui_qml0.1.1catalog—Run and manage a local Monero node: sync progress, peers, settings and the node log.

Platform

ModuleTypeVersionWhereContractDescription
accounts_uiui_qml0.3.0catalog—Create and manage Logos accounts and their keys.
capability_modulecore1.0.0bundledlidlCoordinates permissions between modules
modules_statecore0.1.0bundledlidlRead-only registry of module lifecycle state
openmetricscore0.1.2cataloglidlServes an OpenMetrics /metrics endpoint by scraping modules that implement collectMetrics()
package_downloadercore1.0.0bundledlidlOnline package catalog and download service
package_managercore1.0.0bundledlidlPlugin manager for the Logos system
package_manager_uiui_qml1.0.0bundled—Package Manager UI plugin for managing plugins and packages

Storage

ModuleTypeVersionWhereContractDescription
storage_modulecore3.0.2bundled + cataloglidlStorage module
storage_uiui_qml3.0.2catalog—Storage interface for the Logos application

<!-- registry:end -->

Source 2 files
hooks/register.js 341 lines
1// 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}
341
hooks/atlas.js 461 lines
1// 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