feat: update profile path resolution and enhance scripts for improved project adaptability
This commit is contained in:
@@ -41,7 +41,7 @@ bash scripts/memory/memory.sh search "PDIAP-15765"
|
||||
bash scripts/memory/memory.sh create work-item pdiap-15999 "Example title"
|
||||
```
|
||||
|
||||
This interface defaults to Markdown files under `project-knowledge/`, uses Obsidian CLI when useful and available, and falls back to direct file operations.
|
||||
This interface resolves the canonical Markdown directory from `profiles/<profile>/workspace.json`, uses Obsidian CLI when useful and available, and falls back to direct file operations.
|
||||
|
||||
The default workspace Mattermost extractor now lives in:
|
||||
|
||||
@@ -79,7 +79,7 @@ Expected behavior:
|
||||
- avoid interactive prompts
|
||||
- return a non-zero exit code on failure
|
||||
|
||||
OpenCode can then use that output to refresh `ai/inbox/mattermost-latest.md` proactively. When the local Mattermost proxy mirror is running, commands should prefer `ai/inbox/mattermost-mirror/` through `scripts/mattermost-proxy/read-context.py` and use legacy sync output as fallback evidence.
|
||||
OpenCode can then use that output to refresh the active profile inbox proactively. When the local Mattermost proxy mirror is running, commands should prefer `<profile inbox>/mattermost-mirror/` through `scripts/mattermost-proxy/read-context.py --profile <profile>` and use legacy sync output only as fallback evidence.
|
||||
|
||||
Historical Slack exports can also be imported through:
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ The service manager unifies startup and status. It does not move capture behavio
|
||||
|
||||
## Local project-knowledge index
|
||||
|
||||
The workspace includes a dependency-free local indexer for canonical Markdown memory. The index is derived from `project-knowledge/` and written under `.aiw/indexes/<profile>/`; it is safe to delete and rebuild.
|
||||
The workspace includes a dependency-free local indexer for canonical Markdown memory. The index is derived from the profile's configured `knowledge_dir` and written under the profile's configured `index_dir`; it is safe to delete and rebuild.
|
||||
|
||||
```bash
|
||||
python3 scripts/aiw/indexer.py build --profile fidelity
|
||||
@@ -62,7 +62,7 @@ Current fields:
|
||||
}
|
||||
```
|
||||
|
||||
Use `scripts/aiw/profile.py` from new scripts instead of hardcoding root-level `project-knowledge/` or `ai/inbox/` paths.
|
||||
Use `scripts/aiw/profile.py` from new scripts instead of hardcoding root-level project memory or inbox paths.
|
||||
|
||||
## Robustness features
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ instead of hardcoding root-level project paths.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -70,3 +71,37 @@ def relative_to_root(path: Path, root: Path | None = None) -> str:
|
||||
return str(path.relative_to(base))
|
||||
except ValueError:
|
||||
return str(path)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
path_parser = subparsers.add_parser("path", help="Print a resolved profile path.")
|
||||
path_parser.add_argument("kind", choices=["knowledge", "inbox", "index"])
|
||||
path_parser.add_argument("--profile", default="fidelity")
|
||||
|
||||
config_parser = subparsers.add_parser("config", help="Print resolved workspace configuration as JSON.")
|
||||
config_parser.add_argument("--profile", default="fidelity")
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.command == "path":
|
||||
if args.kind == "knowledge":
|
||||
print(knowledge_dir(args.profile))
|
||||
elif args.kind == "inbox":
|
||||
print(inbox_dir(args.profile))
|
||||
else:
|
||||
print(index_dir(args.profile))
|
||||
return
|
||||
|
||||
config = load_workspace_config(args.profile)
|
||||
config["resolved"] = {
|
||||
"knowledge_dir": str(knowledge_dir(args.profile)),
|
||||
"inbox_dir": str(inbox_dir(args.profile)),
|
||||
"index_dir": str(index_dir(args.profile)),
|
||||
}
|
||||
print(json.dumps(config, ensure_ascii=False, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -42,6 +42,12 @@ class ProfileTests(unittest.TestCase):
|
||||
self.assertEqual(profile.inbox_dir("missing", root=root), root / "ai" / "inbox")
|
||||
self.assertEqual(profile.index_dir("missing", root=root), root / ".aiw" / "indexes" / "missing")
|
||||
|
||||
def test_relative_to_root_handles_external_paths(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
self.assertEqual(profile.relative_to_root(root / "a" / "b", root=root), "a/b")
|
||||
self.assertEqual(profile.relative_to_root(Path("/external/path"), root=root), "/external/path")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -152,17 +152,18 @@ Keep this utility as an isolated image mailbox. If a project wants easy access,
|
||||
link the project inbox to the mailbox instead of making this utility know about
|
||||
the project.
|
||||
|
||||
Example:
|
||||
Example using the active profile inbox:
|
||||
|
||||
```bash
|
||||
mkdir -p ai/inbox
|
||||
ln -s "$HOME/Pictures/Photo Inbox" ai/inbox/photos
|
||||
PROFILE_INBOX="$(python3 scripts/aiw/profile.py path inbox --profile fidelity)"
|
||||
mkdir -p "$PROFILE_INBOX"
|
||||
ln -s "$HOME/Pictures/Photo Inbox" "$PROFILE_INBOX/photos"
|
||||
```
|
||||
|
||||
Or point the receiver at a project-owned folder from `.env`:
|
||||
|
||||
```bash
|
||||
PHOTO_INBOX_DIR=/absolute/path/to/project/ai/inbox/photos
|
||||
PHOTO_INBOX_DIR=/absolute/path/to/profile/inbox/photos
|
||||
```
|
||||
|
||||
The symlink approach keeps this utility reusable across projects and devices.
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
MATTERMOST_MIRROR_HOST_ALLOW=
|
||||
|
||||
# Output directory for raw evidence and normalized AI-readable context.
|
||||
MATTERMOST_MIRROR_DIR=ai/inbox/mattermost-mirror
|
||||
# Optional. If omitted, run-mirror.sh writes to the active profile inbox:
|
||||
# <inbox_dir from profiles/<profile>/workspace.json>/mattermost-mirror
|
||||
# MATTERMOST_MIRROR_DIR=/absolute/path/to/mattermost-mirror
|
||||
|
||||
# mitmproxy listener used by launch-mattermost.sh.
|
||||
MATTERMOST_MIRROR_LISTEN_HOST=127.0.0.1
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Local read-only Mattermost Desktop mirror for AI workspace context.
|
||||
|
||||
This is for **raw evidence only**. It writes under `ai/inbox/mattermost-mirror/`; durable project memory still belongs in `project-knowledge/` after normal promotion rules.
|
||||
This is for **raw evidence only**. By default it writes under the active profile inbox at `<inbox_dir>/mattermost-mirror/`; durable project memory still belongs in the profile's canonical project knowledge vault after normal promotion rules.
|
||||
|
||||
## Why this exists
|
||||
|
||||
@@ -48,7 +48,7 @@ missing.
|
||||
## Output layout
|
||||
|
||||
```text
|
||||
ai/inbox/mattermost-mirror/
|
||||
<profile inbox>/mattermost-mirror/
|
||||
latest.jsonl # bounded AI-readable window
|
||||
latest.md # bounded Markdown view
|
||||
state.json # last seen by channel and user cache
|
||||
@@ -138,7 +138,7 @@ Each line in the normalized JSONL contains:
|
||||
## Useful environment variables
|
||||
|
||||
- `MATTERMOST_MIRROR_HOST_ALLOW`: exact host or parent domain to capture.
|
||||
- `MATTERMOST_MIRROR_DIR`: output directory, default `ai/inbox/mattermost-mirror`.
|
||||
- `MATTERMOST_MIRROR_DIR`: optional output directory. If omitted, `run-mirror.sh` uses `<inbox_dir from profiles/<profile>/workspace.json>/mattermost-mirror`.
|
||||
- `MATTERMOST_MIRROR_LATEST_LIMIT`: number of messages in `latest.*`, default `200`.
|
||||
- `MATTERMOST_MIRROR_CHANNEL_IDS`: optional comma-separated channel ID allowlist.
|
||||
- `MATTERMOST_MIRROR_WRITE_RAW`: set to `1` to save compact raw REST/WebSocket evidence.
|
||||
@@ -170,7 +170,7 @@ do not create message files. Open a channel, open a thread, scroll slightly in
|
||||
history, or wait for/send a new message. Then check:
|
||||
|
||||
```text
|
||||
ai/inbox/mattermost-mirror/latest.md
|
||||
ai/inbox/mattermost-mirror/channels/<channel-name>/YYYY/MM/YYYY-MM-DD.jsonl
|
||||
ai/inbox/mattermost-mirror/by-date/YYYY/MM/YYYY-MM-DD.jsonl
|
||||
<profile inbox>/mattermost-mirror/latest.md
|
||||
<profile inbox>/mattermost-mirror/channels/<channel-name>/YYYY/MM/YYYY-MM-DD.jsonl
|
||||
<profile inbox>/mattermost-mirror/by-date/YYYY/MM/YYYY-MM-DD.jsonl
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@ This addon is intentionally narrow:
|
||||
- redact secrets
|
||||
- normalize posts into date-rotated JSONL files for AI context
|
||||
|
||||
The output under ai/inbox/ is raw evidence, not canonical project memory.
|
||||
The output under the profile inbox is raw evidence, not canonical project memory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,7 +23,7 @@ from urllib.parse import urlparse
|
||||
from mitmproxy import http
|
||||
|
||||
|
||||
DEFAULT_OUT_DIR = "ai/inbox/mattermost-mirror"
|
||||
DEFAULT_OUT_DIR = "mattermost-mirror"
|
||||
POST_ID_RE = re.compile(r"^[a-z0-9]{26}$")
|
||||
SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9._-]+")
|
||||
|
||||
|
||||
@@ -13,17 +13,29 @@ import argparse
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MIRROR_DIR = ROOT / "ai" / "inbox" / "mattermost-mirror"
|
||||
LEGACY_LATEST = ROOT / "ai" / "inbox" / "mattermost-latest.md"
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "aiw"))
|
||||
import profile as aiw_profile # noqa: E402
|
||||
|
||||
DEFAULT_PROFILE = os.getenv("AIW_PROJECT_PROFILE", "fidelity")
|
||||
MIRROR_DIR = aiw_profile.inbox_dir(DEFAULT_PROFILE, root=ROOT) / "mattermost-mirror"
|
||||
LEGACY_LATEST = aiw_profile.inbox_dir(DEFAULT_PROFILE, root=ROOT) / "mattermost-latest.md"
|
||||
LEGACY_GENERATED = ROOT / "scripts" / "mattermost" / "generated" / "mattermost_context.jsonl"
|
||||
LOCAL_ENV = Path(__file__).resolve().parent / ".env"
|
||||
|
||||
|
||||
def configure_profile_paths(profile: str) -> None:
|
||||
global MIRROR_DIR, LEGACY_LATEST
|
||||
inbox = aiw_profile.inbox_dir(profile, root=ROOT)
|
||||
MIRROR_DIR = inbox / "mattermost-mirror"
|
||||
LEGACY_LATEST = inbox / "mattermost-latest.md"
|
||||
|
||||
|
||||
def load_local_env(path: Path = LOCAL_ENV) -> None:
|
||||
"""Load simple KEY=VALUE pairs from the connector-local .env.
|
||||
|
||||
@@ -201,6 +213,7 @@ def main() -> None:
|
||||
load_local_env()
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--profile", default=DEFAULT_PROFILE)
|
||||
parser.add_argument("--mode", choices=["latest", "previous-workday", "standup", "focused"], default="latest")
|
||||
parser.add_argument("--today", default="")
|
||||
parser.add_argument(
|
||||
@@ -210,6 +223,7 @@ def main() -> None:
|
||||
)
|
||||
parser.add_argument("--limit", type=int, default=80, help="Max records per section; use 0 for no limit.")
|
||||
args = parser.parse_args()
|
||||
configure_profile_paths(args.profile)
|
||||
channels = parse_channels(args.channels or None)
|
||||
limit = args.limit if args.limit > 0 else None
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ if [ -f "$SCRIPT_DIR/.env" ]; then
|
||||
set +a
|
||||
fi
|
||||
|
||||
export MATTERMOST_MIRROR_DIR="${MATTERMOST_MIRROR_DIR:-$WORKSPACE_ROOT/ai/inbox/mattermost-mirror}"
|
||||
PROFILE="${AIW_PROJECT_PROFILE:-fidelity}"
|
||||
PROFILE_INBOX_DIR="$(python3 "$WORKSPACE_ROOT/scripts/aiw/profile.py" path inbox --profile "$PROFILE")"
|
||||
export MATTERMOST_MIRROR_DIR="${MATTERMOST_MIRROR_DIR:-$PROFILE_INBOX_DIR/mattermost-mirror}"
|
||||
export MATTERMOST_MIRROR_LISTEN_HOST="${MATTERMOST_MIRROR_LISTEN_HOST:-127.0.0.1}"
|
||||
export MATTERMOST_MIRROR_LISTEN_PORT="${MATTERMOST_MIRROR_LISTEN_PORT:-8080}"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
This directory contains the workspace-local Mattermost extractor used by OpenCode to refresh communication context.
|
||||
|
||||
The preferred live Mattermost evidence source is now the proxy mirror under
|
||||
`ai/inbox/mattermost-mirror/` when it is running. This legacy extractor remains
|
||||
the active profile inbox, `<profile inbox>/mattermost-mirror/`, when it is running. This legacy extractor remains
|
||||
the fallback and explicit refresh path for commands that need a fresh pull from
|
||||
the Mattermost API.
|
||||
|
||||
@@ -72,7 +72,7 @@ Generic workspace variables are preferred for reusable projects:
|
||||
- `AIW_MATTERMOST_SYNC_CMD`
|
||||
- `AIW_MATTERMOST_SYNC_INTERVAL_MINUTES`
|
||||
|
||||
The older `FIDELITY_*` variables remain supported for this project profile.
|
||||
Profile-specific compatibility variables may exist for older project setups, but new reusable workflows should prefer the generic `AIW_*` variables.
|
||||
|
||||
Previous workday mode for standups:
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ python3 scripts/mcp/aiw-context-mcp/server.py --transport stdio
|
||||
- `memory_hybrid_search`
|
||||
- `photos_latest`
|
||||
|
||||
All tools are read-only. Mattermost tools read `ai/inbox/mattermost-mirror/`; photo tools list local Photo Inbox files without embedding image data; project tools read canonical Markdown under `project-knowledge/`.
|
||||
All tools are read-only. Mattermost tools read the active profile's `mattermost-mirror/` inbox; photo tools list local Photo Inbox files without embedding image data; project tools read canonical Markdown from the profile's configured `knowledge_dir`.
|
||||
|
||||
`memory_hybrid_search` reads the derived local index built by:
|
||||
|
||||
@@ -53,7 +53,7 @@ All tools are read-only. Mattermost tools read `ai/inbox/mattermost-mirror/`; ph
|
||||
python3 scripts/aiw/indexer.py build --profile fidelity
|
||||
```
|
||||
|
||||
If the index is missing, it falls back to bounded live Markdown search over `project-knowledge/`. The index is not canonical memory; `project-knowledge/` remains the source of truth.
|
||||
If the index is missing, it falls back to bounded live Markdown search over the profile's configured knowledge directory. The index is not canonical memory; Markdown remains the source of truth.
|
||||
|
||||
Mattermost latest/date/standup tools filter to the active profile's context channels by default. For Fidelity, that list lives in `profiles/fidelity/context-sources.json`. Pass explicit `channels` to override the profile list, or `include_all_channels: true` when broad unfiltered mirror evidence is intentionally needed.
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
This directory exposes a project-agnostic interface for canonical project knowledge.
|
||||
|
||||
The current implementation uses Markdown files under `project-knowledge/` and can optionally delegate to the Obsidian CLI when it is available. The agent should depend on this memory interface, not on Obsidian-specific behavior, so the backing tool can be replaced later.
|
||||
The current implementation resolves the canonical Markdown directory from `profiles/<profile>/workspace.json` and can optionally delegate to the Obsidian CLI when it is available. The agent should depend on this memory interface, not on Obsidian-specific behavior, so the backing tool can be replaced later.
|
||||
|
||||
## Backend Model
|
||||
|
||||
- `AIW_PROJECT_KNOWLEDGE_DIR` points to the canonical Markdown project knowledge directory.
|
||||
- `AIW_MEMORY_VAULT_DIR` and `AIW_OBSIDIAN_VAULT_DIR` are transition aliases.
|
||||
- `AIW_PROJECT_PROFILE` selects the profile, defaulting to `fidelity` in this repository.
|
||||
- `profiles/<profile>/workspace.json` defines the canonical Markdown project knowledge directory.
|
||||
- `AIW_PROJECT_KNOWLEDGE_DIR` can override the resolved directory for local experiments.
|
||||
- `AIW_MEMORY_BACKEND` defaults to `auto`.
|
||||
- `auto` uses Obsidian CLI when it is useful and available, then falls back to direct Markdown operations.
|
||||
- `files` forces direct Markdown operations.
|
||||
|
||||
@@ -4,7 +4,8 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
PROJECT_KNOWLEDGE_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-${AIW_MEMORY_VAULT_DIR:-${AIW_OBSIDIAN_VAULT_DIR:-$WORKSPACE_ROOT/project-knowledge}}}"
|
||||
PROFILE="${AIW_PROJECT_PROFILE:-fidelity}"
|
||||
PROJECT_KNOWLEDGE_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-$(python3 "$WORKSPACE_ROOT/scripts/aiw/profile.py" path knowledge --profile "$PROFILE")}"
|
||||
VAULT_DIR="$PROJECT_KNOWLEDGE_DIR"
|
||||
BACKEND="${AIW_MEMORY_BACKEND:-auto}"
|
||||
OBSIDIAN_CLI="$WORKSPACE_ROOT/scripts/obsidian/cli.sh"
|
||||
|
||||
@@ -4,7 +4,8 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-${AIW_MEMORY_VAULT_DIR:-${AIW_OBSIDIAN_VAULT_DIR:-$WORKSPACE_ROOT/project-knowledge}}}"
|
||||
PROFILE="${AIW_PROJECT_PROFILE:-fidelity}"
|
||||
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-$(python3 "$WORKSPACE_ROOT/scripts/aiw/profile.py" path knowledge --profile "$PROFILE")}"
|
||||
|
||||
if ! command -v obsidian >/dev/null 2>&1; then
|
||||
echo "obsidian CLI is not installed or not in PATH" >&2
|
||||
|
||||
@@ -4,7 +4,8 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-${AIW_MEMORY_VAULT_DIR:-${AIW_OBSIDIAN_VAULT_DIR:-$WORKSPACE_ROOT/project-knowledge}}}"
|
||||
PROFILE="${AIW_PROJECT_PROFILE:-fidelity}"
|
||||
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-$(python3 "$WORKSPACE_ROOT/scripts/aiw/profile.py" path knowledge --profile "$PROFILE")}"
|
||||
VAULT_NAME="${AIW_OBSIDIAN_VAULT_NAME:-$(basename "$VAULT_DIR")}"
|
||||
|
||||
URI="$("$SCRIPT_DIR/uri.sh" daily "vault=$VAULT_NAME")"
|
||||
|
||||
@@ -4,7 +4,8 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-${AIW_MEMORY_VAULT_DIR:-${AIW_OBSIDIAN_VAULT_DIR:-$WORKSPACE_ROOT/project-knowledge}}}"
|
||||
PROFILE="${AIW_PROJECT_PROFILE:-fidelity}"
|
||||
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-$(python3 "$WORKSPACE_ROOT/scripts/aiw/profile.py" path knowledge --profile "$PROFILE")}"
|
||||
VAULT_NAME="${AIW_OBSIDIAN_VAULT_NAME:-$(basename "$VAULT_DIR")}"
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
|
||||
@@ -4,7 +4,8 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-${AIW_MEMORY_VAULT_DIR:-${AIW_OBSIDIAN_VAULT_DIR:-$WORKSPACE_ROOT/project-knowledge}}}"
|
||||
PROFILE="${AIW_PROJECT_PROFILE:-fidelity}"
|
||||
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-$(python3 "$WORKSPACE_ROOT/scripts/aiw/profile.py" path knowledge --profile "$PROFILE")}"
|
||||
VAULT_NAME="${AIW_OBSIDIAN_VAULT_NAME:-$(basename "$VAULT_DIR")}"
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
|
||||
Reference in New Issue
Block a user