Skip to content
Draft
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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,31 @@ All 15 tools your AI can call:
| `pjm brief` | One-screen session-start briefing: warnings, stale memories, open issues, decisions, score |
| `pjm export [--claude-md\|--cursor]` | Compile live memory into CLAUDE.md / .cursorrules for agents without MCP |

### Project registry

The optional project registry tracks registered project directories, their
active selection, brain, and explicit tags. Registration normally requires an
initialized `.projectmem/` directory; use `--allow-uninitialized` to register
an existing directory before initialization. The registry is stored at
PROJECTMEM_HOME/projects.json, or ~/.projectmem/projects.json when
PROJECTMEM_HOME is unset. The file contains local absolute filesystem paths and
is never uploaded by projectmem.

~~~text
pjm project register C:\path\to\repo --brain coding --tag python
pjm project list --brain coding --tag python
pjm project detect --path C:\path\to\repo\src
pjm project use repo
pjm project set-brain repo personal
pjm project tag add repo local-first
pjm project tag list repo
pjm project tag remove repo local-first
pjm project remove repo
~~~

Repeated tag filters use AND semantics. Registry commands never modify a
project's repo-local events or summary.

### Intelligence layer

| Command | Purpose |
Expand Down
3 changes: 3 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ projectmem is open source and we'd rather be transparent about the trade-offs th
| `.projectmem/summary.md` | Distilled briefing for AI agents | Committed to git |
| `.projectmem/PROJECT_MAP.md` | Architecture map | Committed to git |
| `~/.projectmem/global/` | Cross-project patterns and gotchas | Local only, shared across all your projects |
| `$PROJECTMEM_HOME/projects.json` (default: `~/.projectmem/projects.json`) | Registered project IDs, aliases, absolute local paths, brains, tags, and active selection | Local only |
| `.projectmem/watch.pid` · `watch.log` | Watcher state and runtime log | Gitignored, local only |

The project registry contains local filesystem paths. It remains on your machine; projectmem adds no telemetry or network transfer for registry data.

### Honest trade-offs

1. **Never paste secrets into `pjm log/note/decision`.** The event log is append-only. Treat it like git history: don't commit anything you wouldn't want re-read later.
Expand Down
17 changes: 17 additions & 0 deletions TUTORIAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,23 @@ You should see, in order:

## Step 3 — Wire up your AI client

### Optional: register projects

The registry is useful when you work across several initialized repositories.
Registration is explicit in this release:

~~~text
pjm project register C:\path\to\repo --brain coding --tag python
pjm project list
pjm project use repo
~~~

Use pjm project detect --path PATH to find the deepest registered project
containing a file or directory. Manage metadata with pjm project set-brain and
pjm project tag add, remove, or list. The registry lives at
PROJECTMEM_HOME/projects.json (default ~/.projectmem/projects.json), contains
local absolute paths, and does not change repo-local .projectmem events.

Paste the printed config block into your client's MCP config file:

| Client | Config file |
Expand Down
2 changes: 2 additions & 0 deletions src/projectmem/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from projectmem.commands import map as map_command
from projectmem.commands import note as note_command
from projectmem.commands import precheck as precheck_command
from projectmem.commands import project as project_command
from projectmem.commands import regenerate as regenerate_command
from projectmem.commands import score as score_command
from projectmem.commands import search as search_command
Expand All @@ -37,6 +38,7 @@
no_args_is_help=True,
add_completion=False,
)
app.add_typer(project_command.project_app, name="project")


@app.callback()
Expand Down
170 changes: 170 additions & 0 deletions src/projectmem/commands/project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
from __future__ import annotations

from collections.abc import Callable
from pathlib import Path
from typing import Any, TypeVar

import typer

from projectmem.project_registry import (
ProjectRecord,
ProjectRegistryError,
_filter_projects,
_normalize_project_filters,
_required_project,
add_project_tag,
detect_project,
load_registry,
register_project,
remove_project,
remove_project_tag,
set_active_project,
set_project_brain,
)
from projectmem.storage import discover_mem_dir


project_app = typer.Typer(
help="Register and select ProjectMem projects.",
no_args_is_help=True,
add_completion=False,
)
tag_app = typer.Typer(
help="Manage project tags.",
no_args_is_help=True,
add_completion=False,
)
project_app.add_typer(tag_app, name="tag")

T = TypeVar("T")


def _domain_call(function: Callable[..., T], *args: Any, **kwargs: Any) -> T:
try:
return function(*args, **kwargs)
except ProjectRegistryError as exc:
typer.echo(str(exc), err=True)
raise typer.Exit(1) from exc


def _render_record(record: ProjectRecord) -> str:
tags = ",".join(record.tags)
return (
f"{record.alias} {record.default_brain} {tags} "
f"{record.path}"
)


def _find_initialized_root(path: Path) -> Path | None:
candidate = path.parent if path.is_file() else path
mem_dir = discover_mem_dir(candidate)
return mem_dir.parent if mem_dir is not None else None


@project_app.command("register")
def register_command(
path: Path,
alias: str | None = typer.Option(None, "--alias"),
brain: str = typer.Option("coding", "--brain"),
tags: list[str] | None = typer.Option(None, "--tag"),
allow_uninitialized: bool = typer.Option(
False, "--allow-uninitialized"
),
) -> None:
record = _domain_call(
register_project,
path,
alias=alias,
brain=brain,
tags=tags or (),
allow_uninitialized=allow_uninitialized,
)
typer.echo(f"Registered {_render_record(record)}")


@project_app.command("list")
def list_command(
brain: str | None = typer.Option(None, "--brain"),
tags: list[str] | None = typer.Option(None, "--tag"),
) -> None:
normalized_brain, required_tags = _domain_call(
_normalize_project_filters,
brain=brain,
tags=tags or (),
)
registry = _domain_call(load_registry)
records = _domain_call(
_filter_projects,
registry,
brain=normalized_brain,
required_tags=required_tags,
)
typer.echo("ACTIVE ALIAS BRAIN TAGS PATH")
for record in records:
active = "*" if registry.active_project == record.id else ""
typer.echo(
f"{active:<6} {record.alias} {record.default_brain} "
f"{','.join(record.tags)} {record.path}"
)


@project_app.command("detect")
def detect_command(
path: Path = typer.Option(..., "--path"),
) -> None:
expanded = path.expanduser()
if not expanded.exists():
typer.echo(f"Path does not exist: {expanded}", err=True)
raise typer.Exit(1)
resolved = expanded.resolve()
record = _domain_call(detect_project, resolved, _domain_call(load_registry))
if record is None:
project_root = _find_initialized_root(resolved)
if project_root is None:
typer.echo(
f"Project is not initialized: {resolved}. "
"Run pjm init from the project root.",
err=True,
)
raise typer.Exit(1)
typer.echo(f'pjm project register "{project_root}"', err=True)
raise typer.Exit(1)
typer.echo(_render_record(record))


@project_app.command("use")
def use_command(identifier: str) -> None:
record = _domain_call(set_active_project, identifier)
typer.echo(f"Using project {record.id}")


@project_app.command("remove")
def remove_command(identifier: str) -> None:
record = _domain_call(remove_project, identifier)
typer.echo(f"Removed project {record.id}")


@project_app.command("set-brain")
def set_brain_command(identifier: str, brain: str) -> None:
record = _domain_call(set_project_brain, identifier, brain)
typer.echo(f"Project {record.id} brain: {record.default_brain}")


@tag_app.command("add")
def tag_add_command(identifier: str, tag: str) -> None:
record = _domain_call(add_project_tag, identifier, tag)
typer.echo(f"Project {record.id} tags: {','.join(record.tags)}")


@tag_app.command("remove")
def tag_remove_command(identifier: str, tag: str) -> None:
record = _domain_call(remove_project_tag, identifier, tag)
typer.echo(f"Project {record.id} tags: {','.join(record.tags)}")


@tag_app.command("list")
def tag_list_command(identifier: str) -> None:
registry = _domain_call(load_registry)
record = _domain_call(_required_project, identifier, registry)
for tag in record.tags:
typer.echo(tag)
Loading