Skip to content

feat(sdk): mutable-catalog loader seam (upsert/remove/onChange) - #112

Draft
rstagi wants to merge 12 commits into
mainfrom
feat/mutable-catalog-seam
Draft

feat(sdk): mutable-catalog loader seam (upsert/remove/onChange)#112
rstagi wants to merge 12 commits into
mainfrom
feat/mutable-catalog-seam

Conversation

@rstagi

@rstagi rstagi commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Ships the loader seam end-to-end: the mutable-catalog surface, the formal CatalogLoader lifecycle contract on top of it, and a reference loader that proves it on a real source.

Mutable-catalog surface (SDK + core). SkillCatalog.upsert (added-vs-replaced signal), remove, and onChange (the single staleness hook for tools/list_changed re-emits and capability-description re-reads), TS + Python parity. Core gains symmetric SkillRegistry::remove/ToolRegistry::remove (shift_remove + dense-cache invalidate, first emitter of ChurnKind::Remove), so semantic search keeps working after a remove with no rebuild. register notifies in a finally, so a failed eager embed can't swallow the staleness signal for an already-committed mutation.

CatalogLoader lifecycle contract (SDK). start(catalog) / stop / refresh, each sync-or-async; attached with the free function attachLoader(catalog, loader) / attach_loader(...) (mirrors registerMcpServer) which starts the loader and returns a detach/refresh handle. The loader owns its own sync loop and drives the catalog through upsert/remove — lifecycle-only, no SDK-owned diffing. Double-attach guarded (WeakSet); detach idempotent + re-attachable; a start failure keeps partial hydration and re-allows attach; a stop failure still detaches. TS interface / Python typing.Protocol (first in the SDK; mypy-strict conformance for both plain def and async def). No loader telemetry in v1 (deferred to the Cloud loader).

Reference loader (two new packages). @ratel-ai/local-skills and ratel-ai-local-skills load a directory of <name>/SKILL.md files (default ~/.ratel/skills, ADR-0005) into a catalog — real YAML frontmatter (aligned to YAML 1.1 across both SDKs so they parse identically), non-recursive sorted scan, field defaults + type validation with per-scan diagnostics, and a raw-text refresh diff that skips untouched files and never removes foreign skills. Shipped separately so the SDK stays dependency-lean; both are the first implementations of the loader seam, mirroring the planned @ratel-ai/cloud / ratel-ai-cloud layout.

ADR-0003/0005 amended: the seam now has two layers (mutable surface + formal CatalogLoader), and the managed folder is served by the reference local-skills loader package.

Extracted from #100, rebuilt on latest main. Publishing the two new packages is a separate follow-up (RC-first, first publish manual); the concrete Cloud loader packages ship in a separate PR.

rstagi added 12 commits July 16, 2026 09:15
shift_remove keeps survivor order; dense.invalidate on hit so the cache
keeps covering the corpus (semantic search needs no rebuild) and a
removed-then-re-registered id re-embeds fresh. Unknown id: false, no event.
Native SkillRegistry.remove pass-through; catalog upsert returns the
added-vs-replaced signal, remove drops entry+embedding together (no
rebuild), onChange is the single staleness hook (Set-backed, snapshot
iterate, throwing listeners isolated).
Native SkillRegistry.remove pass-through + .pyi stub; catalog mirror of
the TS seam (set-backed listeners, snapshot iterate, raising listeners
isolated).
Seam is SkillCatalog.upsert/remove + onChange; loader config (RATEL_URL,
keys) lives in the loader package, SDK stays source-agnostic; catalog is
runtime-mutable with change notifications.
… semantics

register commits the mutation before the eager buildEmbeddings; a model
failure must not swallow the staleness signal (notify in finally, both
SDKs). New locks: listener unsubscribing/subscribing mid-notify, settled
post-mutation state visible from listeners.
Formal lifecycle seam over the mutable-catalog surface (ADR-0003):
start/stop/refresh, each sync-or-async. attachLoader free fn (mirrors
registerMcpServer) starts the loader and returns a detach/refresh handle;
double-attach guarded by a module-private WeakSet, detach idempotent and
re-attachable. No SDK-owned diffing; no telemetry in v1.
Python mirror of the TS loader seam. CatalogLoader as a typing.Protocol
(start/stop/refresh returning Awaitable[None] | None, so plain def and async
def both conform under mypy strict); attach_loader absorbs sync-or-async via
inspect.isawaitable; CatalogLoaderHandle dataclass mirrors McpServerHandle.
WeakSet double-attach guard, idempotent detach, re-attach on failure.
Reference CatalogLoader (ADR-0003/0005): hydrate a SkillCatalog from a
directory of <name>/SKILL.md files (default ~/.ratel/skills). YAML frontmatter
via the yaml package (SDK stays dependency-lean); non-recursive sorted scan;
field defaults + type validation with per-scan diagnostics; refresh diffs by
raw-text (no re-embed of untouched files) and only removes ids it loaded, never
foreign skills. New pnpm-workspace package + folder README + src/sdk/README.
Python mirror of @ratel-ai/local-skills: LocalSkillsLoader subclasses the
CatalogLoader Protocol (mypy strict enforces conformance) with plain sync
lifecycle methods that attach_loader absorbs. pyyaml frontmatter parsing, same
layout contract, defaults, diagnostics, and raw-text refresh diff. New
hatchling package + folder README + src/sdk/README; CI job builds the local
ratel-ai (maturin) then installs the loader with --no-deps.
0003: the seam now has two layers — the mutable-catalog surface plus a formal
CatalogLoader lifecycle contract (start/stop/refresh) attached via attachLoader;
loader owns its loop, loaders ship as separate packages, local-skills is the
reference, loader telemetry deferred. 0005: the managed folder is served by the
reference local-skills loader package.
…arity

The yaml package defaults to YAML 1.2 core (on/off/yes/no are strings, dup keys
throw) while pyyaml.safe_load is YAML 1.1 (booleans, dup keys last-wins), so the
two mirrored loaders parsed the same SKILL.md differently. Parse TS frontmatter
with { version: '1.1', uniqueKeys: false } to match. Lock the shared behavior
with tests in both packages; align the py README build steps to the CI flow.
Reconcile the loader-facing mutable-catalog seam with main's async batch
register (embedding off-thread, typed errors, configurable models):

- SkillCatalog.upsert now yields the added-vs-replaced signal after the
  awaitable settles; register/remove/onChange keep the seam, notifyChange
  fires synchronously on the metadata commit.
- Public buildEmbeddings folded into register (buildDense) per main.
- SkillRegistry facades (TS+Py) grow remove; TS native remove takes the
  RwLock write guard.
- local-skills(-py) loaders await upsert; loader lifecycle is async.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant