dsh-hypatia-auto-memory
中文:README.zh-CN.md
Event-driven Hypatia memory for
DSH: automatic conversation
logging and background consolidation — span summaries, a log₁₆(n) archive
cascade, and adjudicated work-unit extraction — all on a dedicated model
route, with zero model work in the main session.
Writing is automatic; reading is the agent's job, through the bundled
hypatia-memory skill, with the bundled smart-memory preset carrying the
retrieval discipline. That split is deliberate — see Recall below.
This plugin replaces dsh-hypatia. That plugin left the writing to the
agent, which in practice did not happen: in the deployment this one was written
for, the model loaded the protocol and still issued a hypatia command in one
recorded session out of fifteen — the one where it was asked to directly. Here
the protocol is driven from DSH native events, and the plugin talks to
hypatia over its own hypatia mcp process on the subprocess service (the CLI,
with argv arrays, as fallback) — no shell, no tool call, no sandbox
escalation, no approval prompt. What is still worth having from dsh-hypatia
ships here: auto-approval for the agent's own bash hypatia calls, and its
hypatia / hypatia-dream skills. Install one or the other, not both.
Install
Prerequisite: hypatia CLI on PATH (or add its basename to binaries). A
build with the mcp subcommand (hypatia #20) is used over MCP; an older one
works through the CLI instead, with one warning at the first call. Two later
additions are used when the binary has them and skipped when it does not:
--no-embed (hypatia #26) keeps the log layer out of the vector index, and
similar --exclude-tags (#35) keeps it out of the work-unit candidate search.
The first call logs which the binary offers
(hypatia over mcp: no-embed yes, similar filters yes).
The plugin also needs dsh ≥ 0.1.7, and dsh enforces that itself rather than
this README: the package declares
peerDependencies["@deepseek-ai/dsh"]: ">=0.1.7-rc.1 || ^0.2.0-rc.1 || ^0.2.1-alpha.1"
(the same range also sits under dsh.engines.dsh for display), and dsh checks
every @deepseek-ai/dsh* peer against the running version when a bundle is
installed and again at startup — an older runtime refuses the install instead of
loading a plugin built for API it does not have, and dsh plugin allow-version
grants an exact-version exemption when the risk is accepted deliberately. The
range names each published channel explicitly rather than one open lower bound:
node-semver admits a prerelease only when the range carries a comparator with the
same [major, minor, patch], so a bare >=0.1.7-rc.1 matches no 0.2.x
release candidate at all.
Every peer the running dsh already supplies — @deepseek-ai/dsh,
@deepseek-ai/cordis, and the dsh-llm / dsh-tools / dsh-storage-domain
services — is marked optional in peerDependenciesMeta, so that neither npm
nor a profile installation pulls a copy of its own: the launcher supplies them,
and two copies of a package whose instances must be shared is how a plugin ends
up beside the wrong one. dsh ignores the flag — its check reads
peerDependencies alone — so the ranges above are still enforced at install and
at startup.
# from a local checkout (development)
dsh plugin --profile web add link:/path/to/dsh-hypatia-auto-memory
# registry install once published
dsh plugin --profile web add dsh-hypatia-auto-memory
Repeat for other profiles (desktop, dsh-tui). Restart the profile after install.
The browser half — the settings card and the conversation's Memory tab — is
pre-built in lib/client.js and shipped with the
package. If you edit src/client/*, run npm install && npm run build in this
directory to regenerate it; while pnpm run dev:web is active in the DSH
checkout, client-plugin changes reload without a full page refresh.
Replacing dsh-hypatia: remove it from the profile and restart.
dsh plugin --profile web remove dsh-hypatia
Both register the same skill names and the registry keeps whichever plugin got
there first. This one refuses to shadow another provider (it warns instead), so
leaving dsh-hypatia installed hands the agent exactly the agent-driven
protocol whose failure this plugin exists to fix. If it has to stay for some
other reason, skills: false on its bundle row is the minimum.
A skill on disk of the same name — ~/.agents/skills, ~/.dsh/skills, a
custom directory, or a project's .dsh/skills / .agents/skills — also wins,
in every agent session. DSH's skill registry is layered per scope: the nearest
layer's same-name entry wins outright, and rank (project > plugin > user)
only breaks ties inside one layer. Agent presets (st, standard, …) mount
their own filesystem skill provider in the preset's layer, nearer to the agent
than this plugin's global registration. That includes the canonical
hypatia-memory which hypatia skill install --agent codex writes to
~/.agents/skills: it is the agent-driven protocol and needs host hooks DSH
does not have. Delete such a copy or move it out of the skills directory;
renaming its directory in place is not enough, because DSH names a skill by its
frontmatter name. The startup warning names the directory. The skill center shows the global view, so it can list this plugin
as a skill's provider while sessions load the disk copy.
Agent preset: smart-memory(长程记忆)
The bundle ships one agent preset. cordis.patch.yml inserts a
preset-smart-memory row declaring @deepseek-ai/dsh-agent-preset with
config.id: smart-memory, so the profile's bundle layer is what puts it in the
agent-preset picker beside the shipped standard, ptc and minimal: install
the plugin and a memory-first agent is available, with no hand-written profile
patch.
It carries the reading half of the split, and nothing else. Writing here is
already automatic — logging, consolidation, the session seed. The preset carries
the discipline the automatic layer cannot: when the agent must go and look
(before planning, at every milestone, when an unfamiliar internal name or a
familiar-looking bug shows up, before anything destructive — commit, push,
delete or overwrite, config change, install or upgrade, migration, release —
whenever the user refers to history, and periodically across a long run), the
retrieval quality rules (query the concept rather than the user's words;
hypatia similar for ideas and hypatia search for exact identifiers; always
exclude the operational layer), and the one write rule no background pass can
enforce: a durable rule, taboo or correction the user states is written
immediately, as knowledge-update on an existing entry rather than
delete-and-recreate.
The persona states when; the hypatia-memory skill carries how — the
commands, their flags, and the shelf and scope rules. The preset deliberately
does not restate that protocol or mount it: it is a prompt, not a second
provider, so load the skill when the work touches memory.
Tool set. The standard roster with the plan-mode group and present
removed (46 tools on a web profile). Its sub-plugins — @deepseek-ai/dsh-persona,
the dsh-tool-* rows, dsh-skill-filesystem — come from the dsh installation
and are deliberately not dependencies of this package; the launcher supplies
them.
Nothing here mounts the plugin's own machinery. The preset starts no second
collector, consolidator, recall listener or auto-approval. Those belong to the
hypatia-auto-memory row above and behave identically whichever preset a session
runs under, so a session on this preset writes memory exactly like one on
standard.
Selecting it. Pick 长程记忆 in the new-session preset picker, or make it the
default for a surface from the profile's own patch file:
- id: agent-preset-registry
name: '@deepseek-ai/dsh-agent-preset-registry'
config:
selectedDefault: smart-memory
Replacing a hand-written copy of it. If you declared this preset yourself
before it shipped here, delete that copy. The packaged row deliberately reuses
the row id preset-smart-memory, and rows sharing a row id collapse to the later
one — the profile's patch file is applied after every bundle layer, so a
leftover copy wins and masks the packaged declaration, and the Plugin Manager
then reports that row as unaddressable because its id has two candidate rows.
Two distinct rows declaring one config.id fail the other way, with Duplicate agent preset: smart-memory from the registry. A comment where the row used to be
is enough.
How it works
session/event (emit)
├─ turn/end ──▶ durable queue (storageDomain tasks)
│ ├─ log-message ──▶ hypatia msg-<sid>-<index>
│ ├─ consolidate ──▶ ctx.llm.stream ──▶ sum-* (summary 1) + wu-*
│ └─ cascade ──▶ $not-summaried ──▶ sum<N>-* (summary N)
└─ session/title | compaction/summary ──▶ session-node ──▶ session-<sid> + belongTo
session/disposed ──▶ final flush + consolidation, thresholds waived (a session
closed while DSH keeps running)
collect fiber dispose ──▶ the same flush for every session still open, persisted
before the queue stops; the model call finishes at the next start
(a restart, a config-change restart, or a crash: the startup
consolidation backfill)
explicit request ──▶ /hypatia-summarize (no model turn) | hypatia_summarize |
the Memory tab button ──▶ the same consolidate task, thresholds waived
agent/created ──▶ rules/taboos inject()
-
Transport is a private hypatia mcp process: JSON-RPC over stdio on the
subprocess service, one request at a time, which is how that server handles
them anyway. It is not a dsh-mcp-client entry, so the model never sees
these tools and the plugin's calls meet no tool policy, hook or approval.
Over the CLI, every call was a process that opened the shelf; here a burst of
writes shares one. Content travels as JSON rather than one argv element, so
it is no longer cut at 96 KiB to fit a command line. Tags and scopes are
split on commas as the CLI splits them, so an entry is stored the same over
either transport.
One process means one call at a time, where the CLI ran as many as
queue.concurrency allowed. A call waits behind the one in flight — at
worst a similar reloading the embedding model, or a write paying overdue
embedding debt — and that includes the rules/taboos preload at session
start.
The process starts on the first call and is closed after 60 s without one.
hypatia mcp keeps the embedding model's allocations after a semantic call
(hundreds of MB), and reads the shelf registry once, at start. For the same
reason a shelf '<name>' is not connected error restarts it, so a shelf
connected meanwhile is found on the retry, and the shelf listing for the
settings card always comes from hypatia list. A call that overruns its
30 s deadline kills the process, as the CLI path kills a command; the next
call starts another.
transport: cli restores one CLI command per call. A binary that serves no
usable MCP — no mcp subcommand, a tool this plugin calls missing, or an
error in answer to the handshake — makes the plugin use the CLI until the
profile reloads. Any other failure to start
fails only that call, and the next one tries MCP again: a binary that cannot
be started at all would fail the CLI too, and a start that timed out or
crashed may not do so twice.
-
Collector records human user/message and assistant/message, redacts
secrets, rewrites relative dates against the time the message was sent, caps
both user and assistant text, and enqueues idempotent log tasks.
Tool-call, thinking and reasoning blocks are internal to the model and leave
no placeholder in the content. Assistant turns whose only output is one of
those (tool calls only, empty reasoning marker, etc.) produce no msg-*
entry at all — there is nothing substantive to remember. Plugin-sourced
messages are skipped — no feedback loops. By default no tool-call ledger is
written at all; set hypatia-auto-memory.collector.toolLedger: true to record
a compact ledger (1. \read` ×5 — ✅ 1.2s total`) for assistant messages that
do have text/image content. In either mode in the log layer.
Configuration
The plugin's cordis Config is its configuration form. dsh ≥ 0.1.7 projects a
plugin's Config schema onto the sidebar's Plugins page, and the card renders
on this bundle's own page there; a deployment whose profile renders no Plugins
page gets the same card as a tab under Settings → Built-in plugins. The card
takes whichever seat exists and moves between them without ever appearing twice
(src/client/plugin-card-seat.ts). On the bundle's own page it starts folded to
its header, because that page already names the plugin; as a Settings tab it
starts open. Folding keeps staged edits, but nothing is written before Save and
leaving the page drops them. Every field below applies on
save, without a profile reload. Stored as the plugin entry's config: in the
profile patch; a pre-0.1.7 settings.yaml section is imported once on first
launch. All fields optional, defaults shown. A blank consolidation route is
refused on save; a duplicate one is dropped at runtime with a warning.
consolidation.models is a priority order, not a rotation. A task's first
attempt takes the first route, and each retry degrades to the next one, reusing
the last route past the end. Two consequences are worth knowing: the chain a
task can reach is min(queue.maxAttempts, models.length) long — with the
default maxAttempts: 3 a fourth and later route is never used — and each
downgrade costs one queue.retryDelayMs (5 s), because it happens on a retry
rather than inside one attempt. This replaced a round-robin that used every
selected route regardless of preference; reorder the list, or use the card's
↑/↓ buttons, to change which route is preferred.
Each route may also set a thinking level. reasoningEffort names an
adapter-owned level — the settings card offers exactly what the model declares,
which on llm-deepseek is off/low/high/max. Omitting it keeps the
per-purpose policy described under Consolidator above; setting it overrides
that policy for every call on that route, and the output cap grows to pay for
it. A level the model does not declare is dropped at call time with one warning
instead of being sent, because the core rejects an undeclared effort before any
provider I/O.
hypatia-auto-memory:
enabled: true
binaries: [hypatia]
transport: mcp # or cli; a binary without `mcp` falls back to cli
shelf: default # where every entry goes; read at startup (see below)
autoApprove: true # approve the AGENT's plain `hypatia …` bash calls
collector:
enabled: true
maxAssistantChars: 8000 # per-message cap before truncation marker
maxUserChars: 32000 # same, for what the user typed or pasted
toolLedger: false # off by default; true records a compact tool-call ledger
consolidation:
enabled: true
# PRIORITY order: every consolidation tries the first route, and a retry
# degrades to the next one (see the note above).
models: [] # select one or more { provider, model } routes
# Each route may also carry `reasoningEffort: <level>` — a thinking level the
# card offers from what the model declares (off/low/high/max on deepseek).
# Omit it to keep the per-purpose policy.
maxInputTokens: 16000 # transcript cap (chars/4 estimate)
maxOutputTokens: 2000
timeoutMs: 120000
checkEveryTurns: 5 # minimum turns between accepted tasks
minNewTokens: 3000 # unconsolidated tokens required to trigger
maxWorkUnitsPerRun: 3
adjudicate: true # judge how a new work unit relates to nearby ones
dedupMaxDistance: 0.45 # cosine ceiling on candidates worth judging
dedupCandidates: 5
cascade:
enabled: true
batchSize: 16 # entries per tier before archiving one tier up
queue:
concurrency: 1 # parallel sessions
maxAttempts: 3 # retries per task; also caps the model fallback chain
retryDelayMs: 5000 # and the delay before the next route is tried
# NOT a batching knob. Spans are cut at turn/end; for a turn still running
# this long, the next step/end flushes what is complete so far.
flushWindowMs: 120000
recall:
enabled: true
preloadRulesTaboos: true
housekeeping:
reconcileOnStartup: true # reset rows whose session has no msg-* left in the shelf
pruneVanishedSessions: true # drop rows and tasks of sessions DSH no longer has
backfillConsolidation: true # consolidate a logged tail the in-session trigger never reached
consolidationBackfillPerScope: 4 # sessions that pass may queue per scope, per start
The cordis config block on the bundle row only carries skill packaging:
- id: hypatia-auto-memory
config:
skills: true # register hypatia-memory + hypatia + hypatia-dream
skillsDir: /abs/path # override the packaged skills/ directory
enabled, shelf and autoApprove are read when the collector starts, so
saving a change to any of them restarts the collector (queue drained, state
re-opened); every other switch applies in place. The same restart is what
applies an imported pre-0.1.7 settings.yaml: dsh imports it only after every
plugin has started, and the collector itself waits for that point before it
starts, so it rarely runs on the defaults at all.
Choosing the shelf
shelf names the hypatia shelf every entry is written to and every lookup
reads — logging, consolidation, the cascade, the rules/taboos preload, and the
startup housekeeping. The settings card offers it as a dropdown of what
hypatia list reports — read on demand when the tab opens; a successful
listing is cached for 60 s on the Host, a failed one never is — marking
shelves that are registered but not connected.
- Takes effect on save, like
enabled: the collector restarts on the new
shelf, and the log says so.
- Progress is kept per shelf. Watermarks and queued tasks are stored per
shelf, so a newly chosen shelf starts every session from zero, and switching
back resumes exactly where that shelf left off — nothing is re-logged or
re-consolidated into a shelf that already has it. The cost is on the new
shelf: every live session is logged from its start there — and every other
session the next time it is active — and consolidated again, one model call
per span. Work queued for the other shelf waits, untouched, until it is
chosen again, and housekeeping (pruning vanished sessions, failed tasks) only
touches the current shelf's rows.
default keeps the rows
written before this setting existed.
- The agent follows. When the shelf is not
default, the session seed tells
the agent to pass --shelf <name> to its own hypatia commands; the bundled
hypatia-memory skill says the same, and hypatia-dream consolidates that
shelf when the request names none.
- A missing shelf is logged at startup, not refused: writes to it fail and
retry like any other hypatia failure until it is connected
(
hypatia connect <dir> --name <name>). Over MCP, that failure restarts the
server, so the retry after a connect reaches the shelf.
The listing reaches the browser over the same route family as the Memory tab —
GET /api/dsh-hypatia-auto-memory/shelves, mounted before the startup
reconcile and backfill (and even while enabled: false), so the list is there
when a broken shelf needs replacing. (It rode a read-only settings namespace until dsh 0.1.7 removed that
Host-side API; a shelf listing is not configuration, and the settings domain
now only projects plugin Config forms.)
Operations checklist
- After a turn ends, entries appear within seconds:
hypatia knowledge-get msg-<sessionId>-<n> (n counts messages, from 0).
The conversation's Memory tab answers the same question without a CLI,
and adds what the CLI cannot show: the watermarks themselves.
- Watermarks live in the state domain; failed tasks stay in the
tasks
table with their last error for inspection. Both are what the Memory tab
reads; refresh it after changing the shelf, since the tab's snapshot is
keyed to the shelf this run is writing to.
- Restart mid-write is safe: messages already stored are skipped
(get-before-create); uncovered ranges are re-enqueued from watermarks.
- To check the route warning: consolidation with no selected
models
logs a one-time warning and otherwise stays silent.
- Which model a background call actually ran on is recorded per attempt in
~/.dsh/storages/hypatia_auto_memory_diag.json (tables.model_calls,
newest first by seq, bounded to 100). The settings list is not the answer:
consolidation.models is a priority order, and one task's rows walk it —
the first attempt names the head, a retry names the next route down, which
is how a fallback is confirmed to have happened. effort is what the call
actually SENT, which is not the configured value when the route refused it;
outputTokens / reasoningTokens are the route's own usage report, and they
are what says whether the cap went to the answer or to the reasoning (both 0
when the route reports no usage). outcome is pending (in flight, or the
process died mid-call), ok (a usable result was produced), incomplete
(the call settled without one — output cap, abort, unparseable reply) or
error (the call threw); in the last two the attempt was spent and nothing
was stored. detail is a finish kind, a fixed label, or the provider's
message, capped at 200 characters — never message content and never model
output.
hypatia backfill --status reports the shelf's embedding debt. With a
current hypatia, logging a turn adds nothing to it; only summaries and work
units are pending until the next flush. hypatia scope list --count shows
the scopes in use — the one this project's msg-* entries carry is the one
the session seed reads.
- Uninstall:
dsh plugin --profile web remove dsh-hypatia-auto-memory —
hypatia entries themselves are left in ~/.hypatia/.
Failure modes
| Symptom | Cause / handling |
|---|
| No entries after chatting | enabled: false, missing hypatia binary, or the collect fiber PENDING (needs sessions, storageDomain, subprocess from the base composition) — check profile logs |
| Nothing logged for a whole run after a restart | The storage domain refused to open because a stored record failed its schema. The storage service validates on read, not on write, so a bad write only surfaces at the next startup — and one bad row fails the whole domain. Look for startup failed / does not match its schema in the profile log. Task rows missing error are now defaulted; for any other bad row, stop DSH, remove it from ~/.dsh/storages/hypatia_auto_memory.json, and restart |
| Memory tab never shows data, or shows a read failure | The page is not reached over a loopback origin. The route authenticates itself — loopback socket, loopback Host, same-origin — so reading DSH from another device (a LAN address, or a 0.0.0.0 binding) is refused by design, and so is a tunnel presenting a public Host. Both halves of the payload are refused together, so the tab reports a load failure rather than partial data. Open the GUI at 127.0.0.1 or localhost |
| Entries appear only after a turn finishes | By design: spans are cut at turn/end, or at the first step/end once a turn has run longer than flushWindowMs, so an entry never lacks its own tool results |
| Logging works, no summaries | consolidation.models is empty or invalid — one warning at first trigger |
Summaries but no sum2-* | Fewer than cascade.batchSize unarchived tier-1 summaries in that project yet |
| Work units have no relationships | No embedding model on the shelf (similar fails), every candidate was beyond dedupMaxDistance, or the adjudication call itself produced no verdict — model_calls shows that as incomplete / max-tokens, which is what a thinking-enabled route does when it spends the whole output budget on reasoning before answering. The call now asks a route for reasoningEffort: 'off' once the adapter advertises it, and carries a 1024-token cap for routes that cannot honour it — a route whose names a real level instead gets that 1024 plus the thinking allowance. Since hypatia #19 the local model is looked up in (or the Hugging Face cache), no longer beside the shelf: a shelf that used to answer and now says needs , or pointing at the files it already has |
Known limitations
Deliberate, and worth knowing before you rely on them:
-
No topic-shift detection.
memory-nolinear.md
ranks topic switches as the strongest session-splitting signal, above task
boundaries and time gaps. Detecting one needs a model call every turn, which contradicts this
plugin's central trade-off (zero model work in the main session), so it is not
implemented. Task boundaries (turn/end reasons) and session close are
honoured; a session covering three unrelated topics is summarised as one.
-
Summary names are mechanical. The protocol asks for descriptive names
extracted from content. A model-authored title cannot be the key — a retry may
word it differently and silently fork the entry — so keys stay derivable
(sum-<session>-<from>-<to>, sum<N>-<digest>) and the descriptive title is
the first heading in the body.
-
No session-<id> node without a host summary. The protocol forbids
inventing one, and DSH emits no session-summary event of its own; the node is
built from a session/title or compaction/summary when one appears.
-
The plugin does not use knowledge-update yet. hypatia gained it in #20
(it keeps created_at, discards the old vector and re-embeds on the next
flush), but older binaries lack it, so the plugin still never edits an entry
in place: a second session title does not replace the first, and a work unit
is never rewritten.
-
Duplicate knowledge-create is detected by error text. hypatia has no
upsert for knowledge entries, so hypatia-cli.js recognises
UNIQUE constraint failed: / duplicate key to make a replay idempotent.
statement-create has been idempotent on its own since hypatia #20 (a repeat
exits 0 with Statement already exists); on an older binary the same
error-text path covers it. The matching is confined to one function, and
test/integration accepts either statement behaviour.
-
What was embedded stays embedded. The log layer opts out of the vector
index at write time, so entries and edges written before this version, or by
a hypatia without --no-embed, keep their vectors and keep surfacing in
similar. skip_tags + backfill retracts the knowledge entries' (see
Failure modes); the edges have no retraction path short of deleting and
recreating them, which the plugin does not do.
Directory structure
dsh-hypatia-auto-memory/
├── package.json # bundle + client manifest, deps
├── cordis.patch.yml # bundle layer: the plugin row + the smart-memory preset
├── tsconfig.json # browser TS/TSX type-check config
├── tsconfig.build.json # declaration emit for client bundle
├── tsdown.config.ts # browser CJS factory build
├── src/
│ ├── index.js # fiber composition (collect + optional children)
│ ├── config.js # cordis Config schema (volatile), defaults, live updates
│ ├── shelf.js # per-shelf table views, `hypatia list` parsing
│ ├── state.js # storageDomain spec + progress helpers
│ ├── collector.js # session/event filtering, ledger, backfill
│ ├── content-policy.js # redaction, dates, caps, slugs (pure)
│ ├── queue.js # durable per-session queue with retry/dispose
│ ├── hypatia-client.js # transport switch: MCP, CLI fallback
│ ├── hypatia-mcp.js # private `hypatia mcp` connection + result mapping
│ ├── hypatia-cli.js # argv-only subprocess wrapper
│ ├── writer.js # idempotent get-before-create writes
│ ├── consolidator.js # thresholds, prompt, llm.stream, validation
│ ├── cascade.js # log₁₆(n) hierarchical summary archive
│ ├── model-log.js # bounded record of which model each attempt ran on
│ ├── memory-status.js # per-session fold of the two state tables (pure)
│ ├── memory-api.js # Memory tab + shelf listing; its one write route too
│ ├── on-demand.js # /hypatia-summarize + the hypatia_summarize tool
│ ├── recall.js # rules/taboos preload at session start
│ ├── auto-approve.js # approves the agent's own plain bash hypatia calls
│ ├── skills.js # bundled skill registration (never shadows another provider)
│ ├── status.js # counters + structured logging
│ └── client/ # settings card + Memory tab
│ ├── index.tsx # client plugin entry + slot registration
│ ├── SettingsCard.tsx
│ ├── MemoryView.tsx # the Memory tab body
│ ├── memory-client.ts # its fetch + merge rules (pure)
│ ├── plugin-card-seat.ts # card seat: official Plugins page, else Settings tab
│ ├── shelves.ts # shelf dropdown choices (listing from the /shelves route)
│ └── slot-contract.ts
├── lib/
│ └── client.js # built browser factory (commit this)
├── skills/
│ ├── hypatia-memory/ # this plugin's variant (retrieval is the agent's)
│ ├── hypatia/ # vendored from hypatia's skills/
│ └── hypatia-dream/ # vendored from hypatia's skills/, with local patches
├── scripts/
│ └── it-shelf.sh # throwaway shelf for manual CLI probing
├── test/ # node:test units (npm test)
│ └── integration/ # contracts against a real hypatia (npm run test:integration)
└── README.md