Skip to content

Commit c3a0c9e

Browse files
authored
Merge pull request #24 from nickroci/experiment/uv-tool-install
Experiment/uv tool install
2 parents 551f17d + 68d46a7 commit c3a0c9e

88 files changed

Lines changed: 4090 additions & 5159 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude-plugin/marketplace.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "ultan",
3+
"owner": { "name": "Nicholas Holden" },
4+
"plugins": [
5+
{
6+
"name": "ultan",
7+
"source": "./",
8+
"description": "Local, brain-inspired markdown memory for Claude Code — a curator pair gates writes by surprise, three retrieval tiers surface what matters, all on your disk."
9+
}
10+
]
11+
}

.claude-plugin/plugin.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "ultan",
3+
"version": "0.2.0",
4+
"description": "Local, brain-inspired markdown memory for Claude Code — a curator pair gates writes by surprise, three retrieval tiers surface what matters, all on your disk.",
5+
"author": { "name": "Nicholas Holden", "url": "https://github.com/nickroci" },
6+
"homepage": "https://github.com/nickroci/ultan",
7+
"license": "MIT",
8+
"mcpServers": {
9+
"ultan": {
10+
"command": "uvx",
11+
"args": [
12+
"--from",
13+
"git+https://github.com/nickroci/ultan@main",
14+
"ultan",
15+
"mcp"
16+
]
17+
}
18+
}
19+
}

.github/workflows/ci.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,18 @@ jobs:
1717
fail-fast: false
1818
matrix:
1919
include:
20+
# The root `ultan` plugin package (the runtime) and tools/ultan were
21+
# previously unguarded — added so quality runs on every package.
22+
- package: .
23+
python-version: "3.12"
2024
- package: daemon
2125
python-version: "3.10"
2226
- package: tools/search
2327
python-version: "3.10"
2428
- package: src
2529
python-version: "3.12"
30+
- package: tools/ultan
31+
python-version: "3.12"
2632
defaults:
2733
run:
2834
working-directory: ${{ matrix.package }}
@@ -32,7 +38,10 @@ jobs:
3238
- uses: astral-sh/setup-uv@v6
3339
with:
3440
enable-cache: true
35-
cache-dependency-glob: ${{ matrix.package }}/uv.lock
41+
# The workspace root uv.lock is the only lockfile — member dirs
42+
# resolve against it (`uv sync --locked` from a member validates
43+
# the root lock), so it is the correct cache key for every job.
44+
cache-dependency-glob: uv.lock
3645

3746
- name: Install dependencies
3847
run: uv sync --locked

.pre-commit-config.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,11 @@ repos:
156156
files: ^src/
157157
pass_filenames: false
158158
stages: [pre-push]
159+
160+
- id: pytest-root
161+
name: pytest (root ultan package)
162+
language: system
163+
entry: bash -c 'uv run --frozen pytest'
164+
files: ^(ultan/|tests/|pyproject\.toml)
165+
pass_filenames: false
166+
stages: [pre-push]

README.md

Lines changed: 84 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -94,33 +94,58 @@ The deliberate choice: deterministic-and-cheap text retrieval at always-on Tier
9494

9595
## Quick start
9696

97-
```bash
98-
# 1. Sync the daemon's deps (uv-managed)
99-
cd daemon && uv sync --group dev
97+
Ultan installs as a native **Claude Code plugin** — no editing `settings.json`, no
98+
daemon to babysit. You just need [`uv`](https://docs.astral.sh/uv) on your `PATH`; the
99+
plugin uses it to provision Ultan's runtime on first use.
100100

101-
# 2. Sync the search CLI (separate venv, shared BM25 implementation).
102-
# Pulls in sentence-transformers + einops; HuggingFace model weights
103-
# are downloaded on first daemon start (see step 4 below).
104-
cd ../tools/search && uv sync
101+
Inside Claude Code:
105102

106-
# 3. Install the slash commands and hooks
107-
# - /ultan, /ultan-install, /ultan-advisor live at ~/.claude/commands/
108-
# - `/ultan-install` writes hooks into ~/.claude/settings.json (GLOBAL — every
109-
# Claude Code project). One daemon per machine serves the whole library
110-
# across every repo, so global is the recommended default.
111-
# - `/ultan-install --project` if you'd rather scope to one repo only.
103+
```text
104+
/plugin marketplace add nickroci/ultan
105+
/plugin install ultan@ultan # choose "user" scope to enable it in every project
106+
/reload-plugins # load the hooks + MCP into the running session
107+
```
112108

113-
# 4. Start the daemon (foreground; logs to ~/.agent-mem/daemon.log). One per
114-
# machine — it listens on ~/.agent-mem/priming.sock and answers Tier-1
115-
# priming requests from every hook on every project.
116-
cd /path/to/ultan/daemon && uv run agent-mem-daemon -v
117-
# (nohup, tmux, or a launchd plist if you want it persistent — auto-supervision
118-
# not yet implemented.)
109+
> **`/plugin install` looks frozen — it isn't.** Claude Code shows **no progress
110+
> while it downloads the plugin, which can take a minute**. Don't cancel; the
111+
> "plugin changed" confirmation appears when it's done. After that, the first session
112+
> provisions Ultan's retrieval stack (torch + the embedding/rerank models — a few
113+
> hundred MB) in the background, and you can keep working while it finishes.
119114
120-
# 5. Open Claude Code in any project (no per-project setup needed once the
121-
# hooks are global) and work normally. Entries land under
122-
# ~/.agent-mem/knowledge/ as the Scholar approves them.
123-
```
115+
That's it. Skills and slash commands hot-load the instant you install; `/reload-plugins`
116+
pulls in the hooks and the MCP server. **A full Claude Code restart is not required** — a
117+
fresh session also works, but you don't need one.
118+
119+
> 🩺 **Wondering what it's doing? Ask Claude to run `ultan doctor`.** It reports
120+
> whether the background install is still running, the daemon's state (warming /
121+
> healthy / idle), priming latency, and capture freshness — at any stage, even
122+
> mid-install.
123+
124+
On first use a `SessionStart` hook provisions that retrieval stack into the plugin's
125+
private storage **in the background**. Until it finishes, priming falls back to a fast
126+
lexical scan; after that the daemon **lazy-starts on demand**. Models download anonymously
127+
from HuggingFace — see *First-start expectations* below.
128+
129+
You now have:
130+
131+
- `/ultan <text>` to save a memory, `/ultan-advisor <question>` to consult the library
132+
- the `ultan-search` skill and the `ultan_recall` MCP tool
133+
- automatic priming on every prompt
134+
- everything under `~/.agent-mem/knowledge/` as plain markdown — local, no cloud, no telemetry
135+
136+
> **Don't also run `/ultan-install`.** It wires a *second* copy of the hooks into
137+
> `settings.json`; with the plugin installed, every hook would then fire twice.
138+
> `/ultan-install` is only for the from-source path — see *Development setup* below.
139+
140+
> **Already running Ultan from source?** If you previously ran `/ultan-install`, remove
141+
> its hooks *before* installing the plugin or every event will be captured twice:
142+
> open `~/.claude/settings.json` (or the project's `.claude/settings.json` if you used
143+
> `--project`) and delete every `hooks` entry whose command points into your agent-mem
144+
> checkout. Stop any manually started daemon too — the plugin's daemon lazy-starts on
145+
> its own. Your library needs no migration: `~/.agent-mem/` is shared by both setups.
146+
147+
> The plugin currently installs from the `main` branch (`@main`) and will move to a
148+
> tagged release before wider promotion.
124149
125150
### Where your memories live
126151

@@ -185,6 +210,41 @@ To save a memory explicitly: `/ultan never deploy to prod without my explicit OK
185210

186211
To ask before asking the user: `/ultan-advisor should I use respx or hand-roll an httpx mock?`.
187212

213+
## Development setup
214+
215+
For hacking on Ultan itself, or running from source without the plugin. This is the
216+
manual path the plugin automates — you wire the hooks yourself and run the daemon
217+
directly from the repo.
218+
219+
```bash
220+
# 1. Sync the daemon's deps (uv-managed)
221+
cd daemon && uv sync --group dev
222+
223+
# 2. Sync the search CLI (separate venv, shared BM25 implementation).
224+
# Pulls in sentence-transformers + einops; HuggingFace model weights
225+
# are downloaded on first daemon start (see step 4 below).
226+
cd ../tools/search && uv sync
227+
228+
# 3. Install the slash commands and hooks
229+
# - /ultan, /ultan-install, /ultan-advisor live at ~/.claude/commands/
230+
# - `/ultan-install` writes hooks into ~/.claude/settings.json (GLOBAL — every
231+
# Claude Code project). One daemon per machine serves the whole library
232+
# across every repo, so global is the recommended default.
233+
# - `/ultan-install --project` if you'd rather scope to one repo only.
234+
# NOTE: don't run this if you've installed the plugin — the hooks would double-fire.
235+
236+
# 4. Start the daemon (foreground; logs to ~/.agent-mem/daemon.log). One per
237+
# machine — it listens on ~/.agent-mem/priming.sock and answers Tier-1
238+
# priming requests from every hook on every project.
239+
cd /path/to/ultan/daemon && uv run agent-mem-daemon -v
240+
# (nohup, tmux, or a launchd plist if you want it persistent — auto-supervision
241+
# not yet implemented.)
242+
243+
# 5. Open Claude Code in any project (no per-project setup needed once the
244+
# hooks are global) and work normally. Entries land under
245+
# ~/.agent-mem/knowledge/ as the Scholar approves them.
246+
```
247+
188248
---
189249

190250
## How it works (the short version)
@@ -247,7 +307,7 @@ agent-mem/
247307
pyproject.toml ← uv-managed
248308
src/ ← Phase-0 hook layer (forked from claude-memory-compiler)
249309
hooks/ ← UserPromptSubmit, PostToolUse, Stop, ...
250-
scripts/ ← compile / flush / lint / query
310+
scripts/ ← flush / lint / query
251311
AGENTS.md ← entry-schema reference
252312
tools/
253313
ultan/ ← /ultan, /ultan-install, /ultan-advisor scripts

bin/ultan

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#!/usr/bin/env bash
2+
# Plugin-root bin/ is added to the Bash tool's PATH while the plugin is
3+
# enabled — this is what lets /ultan, /ultan-advisor, and `ultan doctor` work
4+
# without the user installing anything on their own PATH. The real binary
5+
# lives in the plugin's private data dir (installed by ensure-ultan.sh).
6+
# CLAUDE_PLUGIN_DATA is not set for command-invoked bash, so fall back to the
7+
# default data location for this plugin+marketplace ("ultan" @ "ultan").
8+
DATA="${CLAUDE_PLUGIN_DATA:-$HOME/.claude/plugins/data/ultan-ultan}"
9+
REAL="$DATA/bin/ultan"
10+
if [ -x "$REAL" ]; then
11+
exec "$REAL" "$@"
12+
fi
13+
14+
# No binary yet — report WHERE the install stands instead of a bare error, so
15+
# `ultan doctor` answers "is it still installing?" even before the tool env
16+
# exists. (The full doctor lives in the installed package; this is the
17+
# pre-install half.)
18+
LOG="$DATA/install.log"
19+
PIDFILE="$DATA/.install.pid"
20+
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then
21+
echo "ultan: STILL INSTALLING — the background 'uv tool install' is running." >&2
22+
[ -f "$LOG" ] && echo "ultan: progress: $(tail -n 1 "$LOG" 2>/dev/null | cut -c1-120)" >&2
23+
echo "ultan: first install downloads torch (minutes on a cold cache); try again shortly." >&2
24+
exit 1
25+
fi
26+
if [ -f "$DATA/.install.lock" ]; then
27+
echo "ultan: an install lock exists but no installer process is running —" >&2
28+
echo "ultan: it either just started or died mid-install." >&2
29+
[ -f "$LOG" ] && echo "ultan: last log line: $(tail -n 1 "$LOG" 2>/dev/null | cut -c1-120)" >&2
30+
echo "ultan: a new Claude Code session retries automatically (stale locks clear after 30 min)." >&2
31+
exit 1
32+
fi
33+
if [ -f "$LOG" ]; then
34+
echo "ultan: NOT INSTALLED — the last install attempt ended without producing a binary." >&2
35+
echo "ultan: install.log tail:" >&2
36+
tail -n 3 "$LOG" 2>/dev/null | sed 's/^/ultan: /' >&2
37+
echo "ultan: a new Claude Code session retries automatically." >&2
38+
exit 1
39+
fi
40+
echo "ultan: not provisioned yet — the plugin installs Ultan in the background at" >&2
41+
echo "ultan: session start (progress lands in $DATA/install.log)." >&2
42+
echo "ultan: if this is a fresh install, start a new session or wait a minute." >&2
43+
exit 1

commands/ultan-advisor.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
description: Consult Ultan's stored preferences before asking the user a preference-shaped question. Usage /ultan-advisor <the question you were going to ask>
3+
argument-hint: <the question or taste/convention decision>
4+
allowed-tools: Bash(ultan advisor:*)
5+
disable-model-invocation: true
6+
---
7+
8+
Consult the user's Ultan memory library before asking them this question — they
9+
may have already answered it in a previous session. Run:
10+
11+
```
12+
ultan advisor "$ARGUMENTS"
13+
```
14+
15+
This runs the two-step advisor pipeline (Librarian finds relevant entries, then
16+
the Scholar writes a referenced markdown answer with `[[wikilink]]` citations).
17+
It can take 30-60s and is read-only — it never writes to the library. Use a
18+
generous Bash timeout (at least 120s).
19+
20+
When it returns:
21+
- If the library has a relevant stored preference, follow it and tell the user
22+
what Ultan already remembers, citing the `[[wikilink]]`. Don't re-ask what
23+
they've already answered.
24+
- If the library is silent on the question, say so plainly, then proceed and ask
25+
the user directly.

commands/ultan.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
description: Save a memory to Ultan (cross-session agent memory). Usage /ultan <text>
3+
argument-hint: <the memory text to remember>
4+
allowed-tools: Bash(ultan remember:*)
5+
disable-model-invocation: true
6+
---
7+
8+
The user wants to commit something to long-term Ultan memory. Run it now:
9+
10+
!`ultan remember "$ARGUMENTS"`
11+
12+
The command above appends a user-asserted event to the daemon's queue; the
13+
Librarian picks it up and files it. Report the confirmation line ("queued for
14+
librarian …") back to the user in one short sentence. Do not do anything else.

daemon/agent_mem_daemon/__main__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,13 @@ def run(args: argparse.Namespace) -> int:
335335
logfile = args.log_file or log_path()
336336
pidfile = args.pid_file or pid_path()
337337

338+
# Claim the PID file BEFORE configure_logging: configure_logging rotates
339+
# daemon.log when it grows past the cap, so a rejected duplicate spawn
340+
# (acquire_pid_file → SystemExit(2)) must not touch the live daemon's log
341+
# files on its way out. acquire_pid_file writes only to raw stderr, which
342+
# the spawner tees to daemon-spawn.log (see ultan/_daemon.py).
343+
acquire_pid_file(pidfile)
344+
338345
log = configure_logging(
339346
logfile,
340347
level=logging.DEBUG if args.verbose else logging.INFO,
@@ -345,8 +352,6 @@ def run(args: argparse.Namespace) -> int:
345352
log.info(" log file: %s", logfile)
346353
log.info(" pid file: %s", pidfile)
347354

348-
acquire_pid_file(pidfile)
349-
350355
# Verify indexes are in sync with the markdown source of truth.
351356
# Rebuilds on drift (manual edits, git pull from another machine,
352357
# restore from backup); logs "ready (N docs)" otherwise. Failures

daemon/agent_mem_daemon/decay.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,17 @@ def _read_and_parse_frontmatter(
120120
return fm, m.group(1), body
121121

122122

123+
def _lacks_frontmatter_fence(entry_path: Path) -> bool:
124+
"""True when the file is readable but has no ``---`` frontmatter fence —
125+
i.e. it is a catalog file (folder README), not a malformed entry. An
126+
unreadable file returns False so the caller counts it as a real error."""
127+
try:
128+
text = entry_path.read_text(encoding="utf-8")
129+
except OSError:
130+
return False
131+
return _FRONTMATTER_HEAD_RE.match(text) is None
132+
133+
123134
def _atomic_write_entry(entry_path: Path, fm: Dict[str, Any], body: str) -> bool:
124135
"""Serialise ``fm`` back to YAML and rewrite the entry atomically.
125136
Returns True on success, False on any I/O / YAML failure."""
@@ -449,10 +460,17 @@ def _archive_entry(
449460
@dataclass
450461
class SweepResult:
451462
"""Summary of one sweep pass — what got moved, what got skipped,
452-
what failed. Returned by :func:`run_sweep` and logged at INFO."""
463+
what failed. Returned by :func:`run_sweep` and logged at INFO.
464+
465+
``skipped`` counts files with no frontmatter fence at all — folder
466+
``README.md`` catalogs and the like. Those are catalog content, not
467+
decayable entries, and MUST NOT count as ``errored``: a library of a
468+
few hundred entries carries ~25% READMEs, and lumping them into the
469+
error count buries real parse failures in constant noise."""
453470

454471
archived: int = 0
455472
kept: int = 0
473+
skipped: int = 0
456474
errored: int = 0
457475
archived_paths: List[str] = field(default_factory=lambda: [])
458476

@@ -512,7 +530,13 @@ def run_sweep(
512530
try:
513531
parsed = _read_and_parse_frontmatter(entry_path)
514532
if parsed is None:
515-
result.errored += 1
533+
if _lacks_frontmatter_fence(entry_path):
534+
# Catalog file (folder README etc.) — not a decayable
535+
# entry and not an error.
536+
result.skipped += 1
537+
else:
538+
log.warning("decay.run_sweep: unparseable frontmatter in %s", entry_path)
539+
result.errored += 1
516540
continue
517541
fm, _raw, body = parsed
518542
if not _is_eligible_for_archive(fm, today=today):
@@ -535,9 +559,10 @@ def run_sweep(
535559

536560
write_last_sweep_at(when)
537561
log.info(
538-
"decay.sweep: archived=%d kept=%d errored=%d",
562+
"decay.sweep: archived=%d kept=%d skipped=%d errored=%d",
539563
result.archived,
540564
result.kept,
565+
result.skipped,
541566
result.errored,
542567
)
543568
return result

0 commit comments

Comments
 (0)