Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ which routes to the `memory_*.py` modules.
```bash
okfmem status # store + per-project page/archive counts, sync + hook health
okfmem backfill --dry-run # stamp decay frontmatter on existing pages
okfmem init --dry-run # pointers + registry wiring
okfmem init --dry-run # per-repo memory link + pointers + registry wiring
okfmem consolidate --dry-run # decay + archive stale pages + push
okfmem sync [-m "<msg>"] # commit + pull-rebase + push the store (prompts for the message if -m omitted)

Expand Down
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,39 @@ The installer will:

Make sure `~/.local/bin` is in your `$PATH`. (e.g., `export PATH="$HOME/.local/bin:$PATH"`).

### Per-repo setup (`okfmem init`)

`install.sh` is a **once-per-machine** step. The per-project memory link, though, is
resolved from the **current repo** (the cwd's git root), so the installer only wires
the repo you ran it in — the engine clone. In **every other repo** you want memory
for, run `init` once from inside it:

```bash
cd ~/my-project
okfmem init
```

That creates the `<harness-projects>/<encoded-repo-root>/memory -> ~/okfmem-store/projects/<name>`
link, so your agent's `STATE.md` / `MEMORY.md` auto-load resolves to the store instead of an
empty directory. A repo you've never saved memory for has no store project dir yet — `init`
seeds one (with a starter `MEMORY.md` + `STATE.md`) and links it, so a brand-new repo is fully
wired in that single command. It's idempotent — safe to re-run, and worth re-running after an
engine update to repair skill links and pointers.

Forgetting this step fails *silently* (the agent simply never remembers anything), so three
surfaces nag you about it:

* **The installers** end with an unmissable "ONE MORE STEP — required in every repo" block.
* **The SessionStart hook** prints a one-line reminder when the session's repo is unlinked —
even under `--quiet`, since that's the exact case worth interrupting for.
* **`/okfmem` and `/okfmem-save`** probe the repo first and lead with the fix if it's unwired.

All three share one read-only probe, which you can also run yourself:

```bash
okfmem init --project-link-state # -> linked <name> | unlinked <name> | not-a-repo | no-claude
```

### Uninstalling

```bash
Expand Down Expand Up @@ -194,7 +227,7 @@ okfmem init --wire-statusline
It sets your Claude Code `statusLine` to the badge **only when you have none** — an existing/custom statusline is never clobbered; instead it prints a guarded compose snippet to paste in (mirrors how a caveman-style badge is delegated). The badge scripts (`okfmem-statusline.sh`, and `okfmem-statusline.ps1` for PowerShell) are keystroke-cheap (one small file read, no `git`/`python`) and refuse a symlinked flag. The hook also drops a git-ignored `.session-trail.md` in the store (cwd + files touched) so a *forgotten* save still leaves a same-machine trail. Opt the whole thing out with `OKFMEM_NO_STATUS=1`.

### 2. Initialization & Wiring (`okfmem init`)
Scans your system for supported harnesses (Claude Code, Antigravity) and writes a managed `<!-- MEMORY-POINTER v1 -->` block into their global prompts so the AI knows where to find the memory. (The `install.sh` script runs this automatically).
Scans your system for supported harnesses (Claude Code, Antigravity) and writes a managed `<!-- MEMORY-POINTER v1 -->` block into their global prompts so the AI knows where to find the memory. (The `install.sh` script runs this automatically — but only for the repo it runs in, so **run `okfmem init` once inside each new repo** you want project memory for; see [Per-repo setup](#per-repo-setup-okfmem-init).)

### 3. Backfill Metadata (`okfmem backfill`)
An idempotent tool that stamps required YAML frontmatter (like `importance`, `pinned`, `created`) onto all durable pages. (The `install.sh` script runs this automatically).
Expand Down
29 changes: 25 additions & 4 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -316,15 +316,36 @@ Set-StatuslineBadge
Write-Host ""
Write-Host "okfmem installation complete!"
Write-Host ""
Write-Host "Next steps:"

# The one step the installer CANNOT do for you: init resolves the project to
# wire from the process cwd (git rev-parse), so this run only wired the engine
# clone. Every other repo needs its own `okfmem init`. Make that impossible to
# scroll past -- a missed init is a silently memory-less repo, and the failure
# is invisible (the agent just never remembers anything).
$EngineName = Split-Path -Leaf $EngineDir
Write-Host "======================================================================" -ForegroundColor Yellow
Write-Host " ONE MORE STEP -- REQUIRED IN EVERY REPO YOU WANT MEMORY FOR" -ForegroundColor Yellow
Write-Host ""
Write-Host " cd C:\path\to\your-repo"
Write-Host " okfmem init"
Write-Host ""
Write-Host " This install wired only the repo it ran in ($EngineName)."
Write-Host " The memory link is PER-REPO -- repeat those two lines once in each"
Write-Host " project. Skip it and your agent silently remembers nothing there."
Write-Host "======================================================================" -ForegroundColor Yellow
Write-Host ""
Write-Host "Other next steps:"

$Step = 1
$PathDirs = $env:Path -split ";"
if ($PathDirs -notcontains $BinDir) {
Write-Host "1. Add $BinDir to your User PATH, e.g.:"
Write-Host "$Step. Add $BinDir to your User PATH, e.g.:"
Write-Host " [Environment]::SetEnvironmentVariable('Path', `"`$env:Path;$BinDir`", 'User')"
Write-Host " (open a new terminal afterward for PATH changes to take effect)"
$Step++
}
Write-Host "2. Check system status by running: okfmem status"
Write-Host "3. The consolidation Stop hook was wired into Claude Code automatically"
Write-Host "$Step. Check system status by running: okfmem status"
$Step++
Write-Host "$Step. The consolidation Stop hook was wired into Claude Code automatically"
Write-Host " (see the 'stop hook' line above -- nothing to paste). For OTHER"
Write-Host " agents, the hook snippet is in README.md."
27 changes: 23 additions & 4 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -258,12 +258,31 @@ offer_statusline_badge || true
echo ""
echo "✅ okfmem installation complete!"
echo ""
echo "Next steps:"
# The one step the installer CANNOT do for you: init resolves the project to
# wire from the process cwd (git rev-parse), so this run only wired the engine
# clone. Every other repo needs its own `okfmem init`. Make that impossible to
# scroll past -- a missed init is a silently memory-less repo, and the failure
# is invisible (the agent just never remembers anything).
echo "======================================================================"
echo " ONE MORE STEP -- REQUIRED IN EVERY REPO YOU WANT MEMORY FOR"
echo ""
echo " cd /path/to/your-repo"
echo " okfmem init"
echo ""
echo " This install wired only the repo it ran in ($(basename "$ENGINE_DIR"))."
echo " The memory link is PER-REPO -- repeat those two lines once in each"
echo " project. Skip it and your agent silently remembers nothing there."
echo "======================================================================"
echo ""
echo "Other next steps:"
n=1
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
echo "1. Add ~/.local/bin to your PATH in ~/.bashrc or ~/.zshrc:"
echo "$n. Add ~/.local/bin to your PATH in ~/.bashrc or ~/.zshrc:"
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""
n=$((n + 1))
fi
echo "2. Check system status by running: okfmem status"
echo "3. The consolidation Stop hook was wired into Claude Code automatically"
echo "$n. Check system status by running: okfmem status"
n=$((n + 1))
echo "$n. The consolidation Stop hook was wired into Claude Code automatically"
echo " (see the 'stop hook' line above -- nothing to paste). For other"
echo " agents, the hook snippet is in README.md."
114 changes: 112 additions & 2 deletions memory_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -1120,6 +1120,89 @@ def _adopt_and_link_orphan(
return ("changed", ", ".join(bits) + f" -> {name}, then linked{tier_note}")


def _seed_store_project(target, name):
"""Create an empty store project dir with the two files every harness
auto-loads (`MEMORY.md` index + `STATE.md` snapshot), so a repo that has
never been saved still links to something real. Idempotent: an existing
file is never overwritten."""
os.makedirs(target, exist_ok=True)
memory_md = os.path.join(target, "MEMORY.md")
if not os.path.exists(memory_md):
with open(memory_md, "w", encoding="utf-8") as f:
f.write(
f"# MEMORY — {name}\n\n"
"<!-- One line per durable page: - [Title](slug.md) — hook -->\n"
)
state_md = os.path.join(target, "STATE.md")
if not os.path.exists(state_md):
with open(state_md, "w", encoding="utf-8") as f:
f.write(
"---\n"
f"name: {name}-state\n"
f"description: Active-state snapshot for {name}\n"
"type: state\n"
"---\n\n"
f"# STATE — {name}\n\n"
"No session saved yet. Run `/okfmem-save` at the end of a "
"session to populate this.\n"
)


def project_link_state(store, claude_projects=None):
"""Read-only probe: is the CURRENT repo (cwd's git root) wired to the
store? Mutates nothing and never raises -- every reminder surface (the
SessionStart pull hook, the skills, `okfmem status`) shares this one
implementation instead of re-deriving the encoded path by hand (a
`sed 's|/|-|g'` re-derivation is wrong on Windows, where `encode_root`
also encodes the drive colon).

Returns (state, name) where state is one word:
'linked' — memory link resolves to this repo's store project
'unlinked' — in a git repo with Claude Code present, but not linked;
`okfmem init` (run from the repo) is the fix
'not-a-repo' — cwd isn't inside a git repo; nothing to link
'no-claude' — no Claude Code harness detected
`name` is the resolved project name, or None when it can't be resolved.
"""
try:
if claude_projects is None:
claude_projects = os.path.join(
os.path.expanduser("~"), ".claude", "projects"
)
if not detect_harnesses().get("claude_code"):
return ("no-claude", None)
root = _current_git_root()
if not root:
return ("not-a-repo", None)
reg = _load_registry(os.path.join(store, "registry.json"))
name = reg.get("overrides", {}).get(root, os.path.basename(root))
link = os.path.join(claude_projects, encode_root(root), "memory")
target_real = os.path.realpath(os.path.join(store, "projects", name))
if os.path.islink(link) or _is_junction(link):
if os.path.normcase(os.path.realpath(link)) == os.path.normcase(
target_real
):
return ("linked", name)
return ("unlinked", name)
managed_copy = _managed_copy_target(link)
if managed_copy is not None and os.path.normcase(
os.path.realpath(managed_copy)
) == os.path.normcase(target_real):
return ("linked", name)
return ("unlinked", name)
except OSError:
# Fail open: a probe that can't read the filesystem must never break
# the caller (a SessionStart hook, a skill's status line).
return ("no-claude", None)


def cmd_project_link_state(store):
"""`okfmem init --project-link-state`: print '<state> <name>' and exit.
Read-only (rung-1) -- never prompts, never writes."""
state, name = project_link_state(store)
print(state if name is None else f"{state} {name}")


def link_project_memory(
store,
claude_projects,
Expand Down Expand Up @@ -1170,8 +1253,18 @@ def link_project_memory(
return ("skip", "not inside a git repo")
name = reg.get("overrides", {}).get(root, os.path.basename(root))
target = os.path.join(store, "projects", name)
seeded = False
if not os.path.isdir(target):
return ("skip", f"no store project dir for '{name}' yet")
# A repo you've never saved memory for has no store project dir yet.
# Returning "skip" here made the documented per-repo step (`cd <repo>
# && okfmem init`) a silent no-op -- exactly the case the step exists
# for. Seed the dir instead, then link it. Rung-1: additive, inside
# the user's OWN store, and only for the repo they explicitly ran
# `init` in.
if dry_run:
return ("changed", f"would seed store project '{name}' and link it")
_seed_store_project(target, name)
seeded = True
target_real = os.path.realpath(target)
proj_dir = os.path.join(claude_projects, encode_root(root))
link = os.path.join(proj_dir, "memory")
Expand Down Expand Up @@ -1244,7 +1337,8 @@ def link_project_memory(
elif os.path.isdir(link):
os.rmdir(link) # confirmed empty above
tier_note = _install_memory_link(proj_dir, link, target_real)
return ("changed", f"{verb}ed to {name}{tier_note}")
seed_note = " (store project seeded)" if seeded else ""
return ("changed", f"{verb}ed to {name}{tier_note}{seed_note}")


def _write_settings_json(claude_dir, settings, data):
Expand Down Expand Up @@ -2239,12 +2333,28 @@ def main():
"(none|okfmem|custom|no-claude|skip) and exit -- read-only, used by "
"the installers to ask the right question before wiring.",
)
ap.add_argument(
"--project-link-state",
action="store_true",
help="print a one-word probe of whether the CURRENT repo is wired to "
"the store (linked|unlinked|not-a-repo|no-claude) plus the resolved "
"project name, and exit -- read-only, used by the hook and skills to "
"remind you to run `okfmem init` in a new repo.",
)
ap.add_argument(
"--store",
default=os.environ.get("OKFMEM_STORE", os.path.expanduser("~/okfmem-store")),
)
args = ap.parse_args()

if args.project_link_state:
# Pure read; deliberately BEFORE the store-shape check so an
# unconfigured box still answers instead of exiting 2.
cmd_project_link_state(
os.path.abspath(os.path.expanduser(args.store))
)
return

if args.statusline_state:
# Pure read; independent of a store (needs only ~/.claude).
cmd_statusline_state()
Expand Down
38 changes: 37 additions & 1 deletion memory_pull.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,20 +200,56 @@ def pull_store(store, timeout=DEFAULT_TIMEOUT):
ms._release_lock(store, lock)


def unlinked_repo_notice(store):
"""Return a one-shot reminder when the session's repo has no memory link
yet, else None.

This runs on the SessionStart path, which is the ONLY okfmem surface that
fires in a repo the user never ran `okfmem init` in -- and an unlinked repo
fails invisibly (the agent simply never remembers anything), so silence is
the wrong default. Deliberately printed even under `--quiet`: SessionStart
stdout reaches the agent as context, and this is the one line worth the
interruption. Never raises -- the fail-open contract covers this too.
"""
try:
engine = os.path.dirname(os.path.realpath(__file__))
if engine not in sys.path:
sys.path.insert(0, engine)
import memory_init as mi

state, name = mi.project_link_state(store)
if state != "unlinked":
return None
return (
f"okfmem: this repo ('{name}') is NOT wired to the memory store, "
"so nothing said here will be remembered next session. Tell the "
"user to run `okfmem init` once from this repo -- then continue "
"with their request."
)
except Exception:
return None


def main():
ap = argparse.ArgumentParser(
description="Fetch + integrate remote changes into the okfmem store "
"(fast-forward when clean, rebase --autostash otherwise).")
ap.add_argument("--store", default=os.environ.get("OKFMEM_STORE",
os.path.expanduser("~/okfmem-store")))
ap.add_argument("--quiet", action="store_true",
help="suppress output (for hook/automation use)")
help="suppress output (for hook/automation use) -- the "
"unlinked-repo reminder still prints, since a silent "
"unlinked repo is the failure it exists to catch")
args = ap.parse_args()

res = pull_store(args.store)
if not args.quiet:
print(f"okfmem pull: {res['reason']}")

notice = unlinked_repo_notice(args.store)
if notice:
print(notice)

# Fail-open: non-zero ONLY on a rebase conflict a human must resolve.
# Offline / no-upstream / already-up-to-date all exit 0 so an automated
# caller (a SessionStart hook) never trips on this.
Expand Down
19 changes: 17 additions & 2 deletions skills/okfmem-save/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,25 @@ fi

PROJECT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
PROJECT_NAME="$(basename "$PROJECT_ROOT")"
MEMORY_DIR="$HOME/.claude/projects/$(echo "$PROJECT_ROOT" | sed 's|:|-|g; s|/|-|g')/memory"

# Ask the engine whether THIS repo is wired, instead of re-deriving the encoded
# path by hand: `encode_root` also encodes the drive colon on Windows, so a
# hand-rolled sed/replace resolves to the wrong directory there.
LINK_STATE="$(okfmem init --project-link-state)" # "linked <name>" | "unlinked <name>" | "not-a-repo" | "no-claude"

# The probe resolves the project name through the registry (honouring renames),
# so take the name from it rather than assuming basename == project.
STORE="${OKFMEM_STORE:-$HOME/okfmem-store}"
MEMORY_DIR="$STORE/projects/$(echo "$LINK_STATE" | awk '{print $2}')"
```

**If the probe says `unlinked`, stop and fix that first** — this repo has no memory link, so anything you write would land in a directory the agent never auto-loads. Tell the user plainly, then run:

```bash
okfmem init # from the repo root; seeds ~/okfmem-store/projects/<name>/ and links it
```

If `$MEMORY_DIR` does not exist or is not a symlink into `~/okfmem-store/projects/`, the project isn't set up yet — tell the user and offer to scaffold (create `~/okfmem-store/projects/<name>/` with a `MEMORY.md` + `STATE.md`, then symlink it into `$MEMORY_DIR`).
`init` creates the store project dir (with a seed `MEMORY.md` + `STATE.md`) when it doesn't exist yet, so a repo that has never been saved wires up in that one command. `not-a-repo` means `cd` to the project root first; `no-claude` means the harness isn't installed and there's nothing to link.

**Pull latest before writing anything (#26).** The SessionStart pull hook (#16) only fires at session *start* — if another machine pushed to `~/okfmem-store` *during* this session, the store is stale by the time `/okfmem-save` runs. Bring it current before writing `STATE.md`/pages so this session's capture builds on the latest shared history instead of racing it during the Step 7 sync:

Expand Down
Loading
Loading