Skip to content

Experiment/uv tool install#24

Merged
nickroci merged 28 commits into
mainfrom
experiment/uv-tool-install
Jun 10, 2026
Merged

Experiment/uv tool install#24
nickroci merged 28 commits into
mainfrom
experiment/uv-tool-install

Conversation

@nickroci

Copy link
Copy Markdown
Owner

No description provided.

nickroci and others added 28 commits June 8, 2026 12:51
Spike to test whether `uv tool install git+<repo>@<branch>#subdirectory=cli`
(a) installs the thin `ultan` entry point from git (light, no torch), and
(b) resolves the `retrieval` extra's workspace-sibling (agent-mem-search)
from the same git repo.

- root pyproject.toml: virtual uv workspace (members: cli, tools/search).
- cli/: thin `ultan` package, base deps = pyyaml only; heavy retrieval stack
  behind a `retrieval` extra pointing at agent-mem-search via workspace source.
- `ultan doctor` reports whether the retrieval sibling resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root pyproject is now BOTH the workspace root AND the installable `ultan`
package, so the install command drops the #subdirectory fragment:
  uv tool install git+https://github.com/nickroci/ultan

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The thin wrapper runs the (unchanged) daemon through uvx rather than
in-process: `ultan daemon` = `uvx --from "ultan[retrieval] @ git+…" agent-mem-daemon`.
uvx provisions + caches the heavy env; the launcher (ultan/_daemon.py) is ~85
lines, and `ensure_running()` is the lazy-start helper the hooks will call.

- daemon is now a workspace member; the `retrieval` extra pulls agent-mem-daemon
  (transitively agent-mem-search + torch) and resolves from git.
- daemon/pyproject: agent-mem-search path source -> workspace source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- ultan/_priming.py: ported the stdlib-only priming client (socket + crude
  lexical fallback) verbatim from src/hooks — no torch, not even PyYAML.
- ultan/_hooks.py: thin `ultan hook <event>` dispatch. user-prompt-submit
  lazy-starts the daemon (ensure_running) + injects priming; session-start
  warms the daemon. No heavy imports on the hot path.
- tests/test_hook_import.py: guards the invariant — imports `ultan._hooks` in a
  clean subprocess and asserts torch/sentence-transformers/etc. are NOT loaded
  and import time is under the ceiling. Measured ~10ms today.
- pyproject: dev group (pytest), testpaths, ruff config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… daemon

`ultan mcp` is the server Claude Code launches for the plugin. It starts light
(no torch), async-spawns the memory daemon via ensure_running() so the MCP
handshake never blocks on the ~25s model boot, then serves an `ultan_recall`
tool that proxies to the daemon over the socket (lexical fallback while warming).

- ultan/_mcp.py: build_server() (unit-testable construction) + serve().
- mcp added to base deps (light, no ML); lazy-imported only by `ultan mcp`.
- import guard test extended: `mcp` must NOT land on the hook hot path either.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Install-from-inside-CC path, following the claude-mem pattern:
- .claude-plugin/plugin.json: manifest; MCP server via `uvx --from <git> ultan mcp`
  (self-provisioning, starts once/session, async-spawns the daemon).
- hooks/hooks.json: per-turn hooks call the INSTALLED binary
  `${CLAUDE_PLUGIN_DATA}/bin/ultan hook <event>` (no per-prompt uvx overhead);
  SessionStart runs the provisioner.
- scripts/ensure-ultan.sh: SessionStart provisioner — `uv tool install` the thin
  CLI into ${CLAUDE_PLUGIN_DATA}/bin on first run, then warm the daemon. Never
  blocks the session.

End-user install becomes `/plugin install ultan@<marketplace>`. Still needs a
real `/plugin install` test in Claude Code to confirm the env-var/ordering
mechanics. Slash commands (/ultan, /ultan-advisor) + the README come next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single-plugin marketplace pointing at the repo root, so the plugin can be
installed via:  /plugin marketplace add nickroci/ultan  then  /plugin install ultan@ultan

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a build-system to src/ that flattens its hooks/ + scripts/ flat modules to
TOP-LEVEL (same pattern tools/search uses), and add src as a workspace member.
Verified: scope, config, _events, _priming_client, _nudges, the hook handlers
all install as top-level modules — so in a synced one-venv install every module
resolves WITHOUT sys.path hacks. (Enables the sys.path-removal sweep next.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
src/ is now an installable workspace member whose flat hooks/ and
scripts/ modules install as top-level names, so the runtime
sys.path.insert shims that let the standalone scripts find their
siblings are obsolete. Remove them from every affected source module and
hoist the sibling imports (scope/config/_events/aliases/_nudges/...) into
the normal top-of-file import block, dropping the # noqa: E402 markers
that only existed because those imports sat after the sys.path
manipulation.

- src/hooks/_events.py: drop scripts/ sys.path block + unused `import sys`;
  keep the deliberately lazy `from config import get_config` (now only
  noqa: PLC0415, the E402 no longer applies).
- src/hooks/{post_tool_use,pre_tool_use,session_start,stop,
  user_prompt_submit}.py: remove sys.path blocks, hoist imports.
- src/hooks/{pre_compact,session_end}.py: remove sys.path blocks; the
  _CODE_ROOT value they passed to snapshot_and_spawn_flush (a real
  flush-script anchor, not a sys.path artifact) now comes from the
  canonical config.CODE_ROOT instead of a recomputed Path.
- src/scripts/{compile,lint,query}.py: remove _SCRIPTS_DIR sys.path
  blocks, hoist claude_agent_sdk/config/utils imports.
- src/scripts/flush.py: drop the sys.path block; keep _SCRIPTS_DIR (still
  needed for the COMPILE_SCRIPT constant), moved below the hoisted imports.
- tools/ultan/advisor.py: replace the daemon-dir sys.path.insert + ignored
  import with a direct `from agent_mem_daemon import library_tools` (kept
  lazy alongside the SDK import because it pulls the heavy embeddings
  stack); refresh the stale "runtime sys.path shim" comment.
- uv.lock: workspace lock from `uv lock` so `uv sync --all-packages`
  installs every member (incl. src editable) into one venv.

Hook hot path stays torch-free; tests/test_hook_import.py guard passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Captures the non-interactive half of the Claude Code plugin install path
in scripts/validate-plugin.sh: manifest JSON validity, the SessionStart
provisioner installing the thin ultan CLI into ${CLAUDE_PLUGIN_DATA}/bin
(asserting no torch leaks in), the MCP server's JSON-RPC initialize +
tools/list handshake over stdio, and the UserPromptSubmit hook emitting
additionalContext JSON (graceful no-op when no library exists).

Runs entirely in throwaway temp dirs (AGENT_MEM_HOME / CLAUDE_PLUGIN_DATA)
and tears them down on exit, so it never touches the live install.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make tools/ultan a workspace member (agent-mem-tools) with a flat-module
hatch install (advisor.py, remember.py, install.py at top level, mirroring
tools/search). Add `ultan advisor` and `ultan remember` subcommands that
lazy-import the advisor/remember flat modules — kept off the hook hot path,
so the import guard (tests/test_hook_import.py) still passes (torch/mcp do
not load on the hook path).

remember.py / advisor.py each gain a behaviour-preserving run() entrypoint
that main() now delegates to; the subcommands call run() directly.

ultan now depends on agent-mem, agent-mem-daemon and agent-mem-tools so a
plain `uv tool install ultan` pulls everything advisor/remember need.

Add commands/ultan.md and commands/ultan-advisor.md (Claude Code slash
commands invoking `ultan remember` / `ultan advisor`) and reference them via
`"commands": "./commands/"` in .claude-plugin/plugin.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`src` (agent-mem) installs editable into the workspace venv, flattening
its `hooks/`/`scripts/` modules to top-level names. The test files no
longer need the `_THIS_DIR` + `sys.path.insert/append` wedge to import
siblings (`_blockers`, `config`, `conftest`, etc.).

For each test/conftest: removed the `_THIS_DIR` definition and the
`if ... not in sys.path` block (keeping `_THIS_DIR` where it still backs
a `HOOK_SCRIPT` path or `hook_runner`), dropped now-unused `import sys`,
lifted post-block imports into the normal import block, and removed their
`# noqa: E402`. scripts/conftest.py docstring/comments updated to reflect
the editable-install resolution.

Suites stay green: src 259, daemon 645, tools/search 174, root hook
guard 1. ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- advisor.py: library_tools._SERVER_NAME -> .SERVER_NAME (pre-existing bug that
  would crash /ultan-advisor at runtime; surfaced by the clean import).
- marketplace.json: drop undocumented owner.url + the backward-compat metadata
  block (agent-validated against current CC plugin docs).
- uv.lock: relock after the merged dependency graph (ultan now pulls the full
  stack so advisor/remember work from one install; hook path stays lazy).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… hooks

SessionStart provisioner now detaches the full `uv tool install` (which pulls
torch and can exceed the 60s SessionStart timeout on a cold cache) so it never
blocks the session; a lock prevents concurrent installs. The 6 per-turn hooks
guard on the binary existing ([ -x BIN ] && BIN hook X || true) so they no-op
cleanly during the install window, then run normally once it lands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude Code's plugin hooks-file loader expects { "hooks": { <events> } }, not
the events at the top level — install failed with 'expected record, received
undefined' at path ["hooks"].
…eferencing them caused 'Duplicate hooks file'
…ree)

Packaging/install path:
- ship _device.py in the agent-mem-search wheel (daemon died at import on
  every built-wheel install; spawn stderr was DEVNULL so it died silently)
- restore the thin base: ultan deps = mcp only; agent-mem-daemon +
  agent-mem-tools move behind a real [retrieval] extra that ensure-ultan.sh
  installs (ULTAN_SPEC override for local validation); uvx MCP env stays small
- exclude scripts/__init__.py from the agent-mem wheel (stray top-level
  __init__.py landed in site-packages root)
- drop dead member uv.locks; key CI cache on the root lock

Capture path (ultan/_hooks.py, ultan/_events.py):
- byte-aware truncation cascade — two large strings or non-ASCII payloads no
  longer silently drop the whole payload (reproduced, fixed, test-pinned)
- SessionStart boundary event + pre-compact SessionEnd seal (legacy parity)
- unknown hook event exits 1, not 2 (2 is the block signal); non-UTF-8 stdin
  degrades to {} instead of a traceback; second SessionStart hooks.json entry
  captures the event with real stdin; PreToolUse no-op spawn removed

Daemon spawn (ultan/_daemon.py, daemon __main__.py):
- spawn output tees to ~/.agent-mem/daemon-spawn.log instead of DEVNULL
  (stale-PID refusal was invisible); flock'd backoff stamp; socket probe
  context-managed; acquire_pid_file now precedes configure_logging so a
  rejected duplicate can't rotate the live daemon's log

Priming fallback (both verbatim copies, sync-pinned by test):
- shared wall-clock deadline across socket + fallback, per-file 256KiB cap,
  k<=0 guard (no more empty envelope), docstrings match the code

Scripts:
- ensure-ultan.sh: atomic noclobber lock with ownership token, shim health
  probe, never-block guards, installs ultan[retrieval], records installer PID
- validate-plugin.sh: waits for the detached install, validates the LOCAL
  tree, structural manifest checks (would catch both prior regressions),
  argv-correct pkill, no ls|grep -q SIGPIPE hazard, check 3 matches design
- bin/ultan wrapper: /ultan and /ultan-advisor work for plugin-only users
  (plugin bin/ joins the Bash tool PATH)

Plugin completeness:
- ship the ultan-search skill (README promised it; priming text recommends it)
- README: reverse-migration section for /ultan-install users, @main not
  experiment branch, compile references gone (also src/README, AGENTS.md,
  SCHEMA.md, lint.py orphan/stale checks removed with compile.py)

CI: root + tools/ultan jobs green (smoke test so pytest collects, shared-venv
pyright path, basic typing gate with TODO to graduate, ruff format), root
pytest in pre-commit; src guard test pins the legacy hot path import-thin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…anch validation

Temporary: plugin.json mcpServers + ensure-ultan.sh SPEC (+ README note)
track the branch so the plugin install path can be validated end-to-end
before merge. Revert this commit when merging to main.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…~60s

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`ultan doctor` now answers "is it working, and if not, where is it stuck":
runtime deps, daemon pid + socket (HEALTHY / WARMING / IDLE / BROKEN),
priming round-trip latency + lane, capture-stream freshness, library size.
Read-only and stdlib-only.

The plugin bin/ultan wrapper covers the pre-install half — when the real
binary doesn't exist yet it reports whether the background install is
running (with the last install.log line), stalled, failed, or not started,
instead of a bare error. Quick start points at `ultan doctor`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1. Scholar batches REQUEUE once on agent timeout instead of dropping.
   scholar.review returns the packets on a first timeout; the worker
   re-enqueues them with a scholar_retries marker and review drops on a
   second timeout — one retry, never a loop. (Observed: a lid-close froze
   the in-flight run and its 2 proposals were permanently lost.)

2. run_typed closes the SDK stream on cancellation. A wait_for timeout
   abandoned the query generator mid-iteration, leaking the spawned CLI
   subprocess (observed: 7h40m at ~3% CPU). The CancelledError handler now
   aclose()s the stream — shielded and time-bounded — which drives the
   SDK transport's SIGTERM→SIGKILL teardown.

3. User-asserted /ultan memories can no longer be silently vetoed. New
   boundary validator (validate_user_asserted_filed): a scan whose
   transcript carries [USER-ASSERTED] turns must file at least one
   proposal; ModelRetry sends the model back to self-correct, and a
   persistent refusal fails loudly instead of emitting an empty packet.
   (Observed: a remember-event scan emitted 0 proposals against the
   prompt's own file-don't-veto contract.)

4. Decay sweep classifies fence-less catalog files (folder READMEs) as
   skipped, not errored — errored=142 was exactly the README count,
   burying real parse failures in constant noise. Real YAML failures now
   also get a per-file warning instead of a silent counter bump.

654 daemon tests pass (9 new) at 90.5% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The root CI job syncs only the thin base (mcp) — advisor/remember live
behind the [retrieval] extra by design, so their lazy call sites in
ultan/__main__.py typed as unknown under strict pyright. Inline ignores
flip-flop (unnecessary in the full dev venv, required in CI), so ship
stubs/{advisor,remember}.pyi instead: identical signatures in every env,
zero suppressions. Stubs are pinned to the real tools/ultan signatures.

tools/ultan had no dev dependency-group at all, so CI's `uv sync --locked`
installed no ruff/pyright/pytest ('Failed to spawn: ruff'). Added the same
dev group the other members carry.

test_doctor now stubs the runtime-deps probe: the IDLE/WARMING verdicts it
pins are daemon-state classifications, and whether [retrieval] is installed
depends on which env runs the suite. A third test pins the BROKEN path.

Verified against simulated thin CI envs (UV_PROJECT_ENVIRONMENT +
pyright --venvpath), not just the fully-synced dev venv that masked this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nickroci nickroci merged commit c3a0c9e into main Jun 10, 2026
6 checks passed
@nickroci nickroci deleted the experiment/uv-tool-install branch June 10, 2026 15:39
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