diff --git a/.gitignore b/.gitignore
index a304d65..9a8eadf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -214,6 +214,15 @@ skills-lock.json
/paper/
/memory/
docs/demo/
+!docs/demo/
+docs/demo/*
+!docs/demo/demo.gif
+!docs/demo/demo.sh
+!docs/demo/demo.tape
+!docs/demo/install-demo.gif
+!docs/demo/install-demo.mp4
+!docs/demo/install-demo.tape
+!docs/demo/install-stage-*.png
apps/memory-os-chrome-extension/
apps/memory-os-macos/
diff --git a/AGENTS.md b/AGENTS.md
index d7f12cc..b2a351d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -59,3 +59,15 @@ Target agent profile: `Codex/agent-runner`.
+
+
+# Dhee Native Integration
+
+This workspace has opted into Dhee with `dhee init`.
+
+Required behavior:
+- Start substantive repo/workspace tasks with `dhee_context_bootstrap` using this workspace path before local reconstruction.
+- Prefer `dhee_read`, `dhee_grep`, and `dhee_bash` for large file reads, searches, and commands so raw output stays behind pointers.
+- Use `dhee_scene_context` and `dhee_narrative_prior` as advisory memory/context priors; explicit user intent, facts, privacy, and proof gates win.
+- Keep Dhee scoped to this initialized workspace. Repos/folders without `.dhee/config.json` are vanilla unless the user explicitly opts them in with `dhee init`.
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8a0febf..8950634 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
+## [7.2.0] - 2026-05-26 - Proactive workspace memory and init polish
+
+- Made Dhee workspace behavior explicitly opt-in: Codex/Claude hooks and router
+ enforcement now stay quiet outside folders initialized with `dhee init`.
+- Expanded `dhee init` and `dhee link` to support plain folders and git URLs,
+ while preserving git hooks for real repositories.
+- Added calmer `dhee init` terminal output, typo suggestions for unknown
+ commands, and built-in shell completion for bash, zsh, and fish.
+- Improved Chotu/Dhee memory retrieval quality: sparse vector results now merge
+ DB lexical recall instead of hiding exact durable memories.
+- Hardened Narrative Scene Intelligence by rejecting orphan `scene_event`
+ writes and making SceneCard summaries lead with story progress and durable
+ facts instead of evidence labels.
+- Updated installer docs/demo assets and kept PyPI metadata at
+ `Development Status :: 5 - Production/Stable`.
+
## [7.1.0] - 2026-05-22 - World memory layer and context compiler
- Reframed Dhee as the world memory layer and context compiler for AI agents,
diff --git a/MANIFEST.in b/MANIFEST.in
index f50ce01..6a7fabf 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,9 +1,5 @@
prune dhee/ui/web/node_modules
-prune enterprise
-prune engram-bridge
-prune engram-enterprise
-prune engram-failure
-prune engram-heartbeat
-prune engram-identity
-prune engram-metamemory
-prune engram-policy
+
+# Source distributions are driven by pyproject package discovery and explicit
+# package-data globs. Keep this file quiet unless we intentionally add broad
+# graft rules that need pruning.
diff --git a/README.md b/README.md
index 9275116..7a8f94a 100644
--- a/README.md
+++ b/README.md
@@ -37,15 +37,36 @@ Or use the one-command installer:
curl -fsSL https://raw.githubusercontent.com/Sankhya-AI/Dhee/main/install.sh | sh
```
+
+
+
+
Wire a repo:
```bash
-cd /path/to/repo
+cd /path/to/repo-or-folder
dhee init
dhee status
dhee ui
```
+`dhee init` is the opt-in switch. Run it in the current directory, pass a folder, or pass a git URL:
+
+```bash
+dhee init /path/to/folder
+dhee init https://github.com/org/repo.git
+```
+
+Folders that have not run `dhee init` stay vanilla in Codex and Claude Code.
+
+Shell autocomplete is built in:
+
+```bash
+dhee completion --shell zsh
+dhee completion --shell bash
+dhee completion --shell fish
+```
+
## What Is Dhee?
Dhee is the memory layer agents should have had from day one.
@@ -98,7 +119,7 @@ That story gives the model anticipation. The prior is advisory, never bossy: exp
Dhee can run model-free, but the production retrieval path is built for serious agent work:
```bash
-pip install "dhee[nvidia,zvec,mcp]"
+python3.11 -m pip install "dhee[nvidia,zvec,mcp]"
dhee key set nvidia
```
@@ -201,7 +222,7 @@ Team governance, hosted dashboards, org policy, and managed source connectors ca
## Develop
```bash
-pip install -e ".[dev,nvidia,zvec,mcp]"
+python3.11 -m pip install -e ".[dev,nvidia,zvec,mcp]"
pytest
```
diff --git a/dhee/__init__.py b/dhee/__init__.py
index 8056497..20eed43 100644
--- a/dhee/__init__.py
+++ b/dhee/__init__.py
@@ -41,7 +41,7 @@
# Default import remains model-free for backwards compatibility.
Memory = CoreMemory
-__version__ = "7.1.0"
+__version__ = "7.2.0"
__all__ = [
# Memory classes
"Engram",
diff --git a/dhee/cli.py b/dhee/cli.py
index bf06544..c88fcea 100644
--- a/dhee/cli.py
+++ b/dhee/cli.py
@@ -21,11 +21,12 @@
"""
import argparse
+import difflib
import json
import os
import shutil
import sys
-from typing import Any, Dict, Optional
+from typing import Any, Dict, Iterable, Optional
def _json_out(data: Any) -> None:
@@ -191,8 +192,6 @@ def _history_embedding_dims() -> Optional[int]:
)
if vector_provider == "zvec":
- import zvec # noqa: F401
-
collection_name = str(vector_provider_config.get("collection_name") or DEFAULT_COLLECTION)
vector_config = VectorStoreConfig(
provider="zvec",
@@ -203,8 +202,6 @@ def _history_embedding_dims() -> Optional[int]:
},
)
elif vector_provider == "sqlite_vec":
- import sqlite_vec # noqa: F401
-
collection_name = str(vector_provider_config.get("collection_name") or DEFAULT_COLLECTION)
existing_dims = _existing_sqlite_vec_dims(sqlite_path, collection_name)
if existing_dims is None and collection_name != "dhee_memories":
@@ -353,7 +350,7 @@ def cmd_shell(args: argparse.Namespace) -> None:
def cmd_link(args: argparse.Namespace) -> None:
- """Link a git repo: create /.dhee/, install hooks, register."""
+ """Link a repo or folder: create /.dhee/ and register."""
from dhee import repo_link
info = repo_link.link(args.path or ".")
@@ -362,15 +359,76 @@ def cmd_link(args: argparse.Namespace) -> None:
return
print(f"Linked {info['repo_root']}")
print(f" repo_id {info['repo_id']}")
- print(f" hooks {', '.join(info['hooks']) or 'none'}")
+ print(f" kind {info.get('kind', 'git_repo')}")
+ if info.get("source_url"):
+ print(f" source {info.get('source_url')}")
+ print(f" git hooks {', '.join(info['hooks']) or 'none'}")
manifest = info.get("manifest") or {}
print(f" entries {manifest.get('entry_count', 0)}")
+def _shell_from_env() -> str:
+ shell = os.path.basename(os.environ.get("SHELL") or "").lower()
+ return shell if shell in {"bash", "zsh", "fish"} else "bash"
+
+
+def _completion_commands() -> list[str]:
+ commands = sorted(set(COMMAND_MAP) | {"onboard", "update", "completion"})
+ return [cmd for cmd in commands if cmd]
+
+
+def _bash_completion(commands: Iterable[str]) -> str:
+ words = " ".join(commands)
+ return f"""# Dhee bash completion
+_dhee_complete() {{
+ local cur="${{COMP_WORDS[COMP_CWORD]}}"
+ if [ "$COMP_CWORD" -eq 1 ]; then
+ COMPREPLY=( $(compgen -W "{words}" -- "$cur") )
+ fi
+}}
+complete -F _dhee_complete dhee
+"""
+
+
+def _zsh_completion(commands: Iterable[str]) -> str:
+ items = " ".join(f"{cmd}" for cmd in commands)
+ return f"""#compdef dhee
+# Dhee zsh completion
+_dhee() {{
+ local -a commands
+ commands=({items})
+ if [[ $CURRENT -eq 2 ]]; then
+ compadd -- $commands
+ fi
+}}
+_dhee "$@"
+"""
+
+
+def _fish_completion(commands: Iterable[str]) -> str:
+ lines = ["# Dhee fish completion"]
+ for cmd in commands:
+ lines.append(f"complete -c dhee -f -n '__fish_is_first_arg' -a {cmd}")
+ return "\n".join(lines) + "\n"
+
+
+def cmd_completion(args: argparse.Namespace) -> None:
+ """Print a small shell-completion script for top-level commands."""
+ shell = str(getattr(args, "shell", None) or _shell_from_env()).lower()
+ commands = _completion_commands()
+ if shell == "zsh":
+ print(_zsh_completion(commands), end="")
+ elif shell == "fish":
+ print(_fish_completion(commands), end="")
+ else:
+ print(_bash_completion(commands), end="")
+
+
def cmd_init(args: argparse.Namespace) -> None:
- """One-command on-ramp: link + index markdown + write CLAUDE.md + first-light.
+ """One-command on-ramp: link + index markdown + write harness files + first-light.
- Run from inside any git checkout. Idempotent — safe to re-run.
+ Run from inside any repo/folder, pass a folder path, or pass a git URL.
+ Idempotent — safe to re-run.
"""
from dhee import repo_link
@@ -389,86 +447,9 @@ def cmd_init(args: argparse.Namespace) -> None:
_json_out(info)
return
- repo_root = info["repo_root"]
- print(f"Dhee initialised in {repo_root}")
- print(f" repo_id {info.get('repo_id', '')}")
+ from dhee.cli_pretty import render_init
- hooks = info.get("hooks") or []
- print(f" git hooks {', '.join(hooks) if hooks else 'none'}")
-
- cm = info.get("claude_md") or {}
- if cm.get("created"):
- cm_label = "created"
- elif cm.get("updated"):
- cm_label = "updated"
- else:
- cm_label = "unchanged"
- print(f" CLAUDE.md {cm_label} ({cm.get('path', '')})")
-
- ingest = info.get("ingest") or {}
- status = ingest.get("status", "skipped")
- if status == "ok":
- bits = [
- f"indexed {ingest.get('files_indexed', 0)}",
- f"unchanged {ingest.get('files_unchanged', 0)}",
- f"chunks +{ingest.get('chunks_stored', 0)}",
- ]
- chunks_replaced = int(ingest.get("chunks_replaced", 0) or 0)
- files_pruned = int(ingest.get("files_pruned", 0) or 0)
- chunks_pruned = int(ingest.get("chunks_pruned", 0) or 0)
- if chunks_replaced:
- bits.append(f"replaced {chunks_replaced}")
- if files_pruned or chunks_pruned:
- bits.append(f"pruned {files_pruned} file(s) / {chunks_pruned} chunk(s)")
- print(f" markdown {', '.join(bits)}")
- elif status == "skipped":
- reason = ingest.get("reason", "")
- if reason == "memory_unavailable":
- print(" markdown skipped — provider/API key not configured (run `dhee onboard`)")
- elif reason == "skip_ingest":
- print(" markdown skipped (--skip-ingest)")
- else:
- print(f" markdown skipped ({reason})")
- elif status == "error":
- print(f" markdown error — {ingest.get('reason', 'unknown')}: {ingest.get('detail', '')}")
- else:
- print(f" markdown {status}")
-
- print(f" linked repos {info.get('linked_repos', 0)} on this machine")
-
- fl = info.get("first_light") or {}
- hits = fl.get("hits") or []
- print()
- if hits:
- print("First light — what your brain already knows about this work:")
- for hit in hits:
- text = (hit.get("text") or "").strip().splitlines()
- head = text[0] if text else ""
- head = (head[:140] + "…") if len(head) > 140 else head
- src = hit.get("source_path") or ""
- tag = f" [{hit.get('score', 0):.2f}]"
- if src:
- # Show just the basename + parent so the line stays short.
- from pathlib import Path as _Path
- src_short = "/".join(_Path(src).parts[-2:])
- print(f"{tag} {head}")
- print(f" ↳ {src_short}")
- else:
- print(f"{tag} {head}")
- else:
- if fl.get("status") == "skipped":
- print("First light — skipped.")
- else:
- print(
- "First light — no cross-repo learnings yet. They'll appear as you "
- "work and `dhee promote` adds shared entries."
- )
-
- print()
- print("Next:")
- print(" dhee status see savings + brain health")
- print(" dhee recall \"\" search your personal brain")
- print(" dhee inbox live broadcasts from your other agents")
+ render_init(info)
def cmd_inbox(args: argparse.Namespace) -> None:
@@ -3994,9 +3975,9 @@ def build_parser() -> argparse.ArgumentParser:
# init — one-command on-ramp: link + index markdown + CLAUDE.md + first-light
p_init = sub.add_parser(
"init",
- help="One-command on-ramp: wire this git repo into your developer brain",
+ help="One-command on-ramp: wire the current repo, a folder, or a git URL into Dhee",
)
- p_init.add_argument("path", nargs="?", default=".", help="Repo path (default: cwd)")
+ p_init.add_argument("path", nargs="?", default=".", help="Repo/folder path or git URL (default: cwd)")
p_init.add_argument(
"--max-chunks",
type=int,
@@ -4048,21 +4029,32 @@ def build_parser() -> argparse.ArgumentParser:
# link / unlink / links — personal vs repo context
p_link = sub.add_parser(
"link",
- help="Link a git repo: create /.dhee/, install hooks, share context via git",
+ help="Link a repo or folder: create /.dhee/ and register it",
)
- p_link.add_argument("path", nargs="?", default=".", help="Repo path (default: cwd)")
+ p_link.add_argument("path", nargs="?", default=".", help="Repo/folder path or git URL (default: cwd)")
p_link.add_argument("--json", action="store_true", help="JSON output")
p_unlink = sub.add_parser(
"unlink", help="Remove this repo from the local link registry"
)
- p_unlink.add_argument("path", nargs="?", default=".", help="Repo path (default: cwd)")
+ p_unlink.add_argument("path", nargs="?", default=".", help="Repo/folder path (default: cwd)")
p_unlink.add_argument("--keep-hooks", action="store_true", help="Leave the git hooks in place")
p_unlink.add_argument("--json", action="store_true", help="JSON output")
p_links = sub.add_parser("links", help="List repos linked on this machine")
p_links.add_argument("--json", action="store_true", help="JSON output")
+ p_completion = sub.add_parser(
+ "completion",
+ help="Print shell completion for top-level Dhee commands",
+ )
+ p_completion.add_argument(
+ "--shell",
+ choices=["bash", "zsh", "fish"],
+ default=None,
+ help="Shell to generate for (default: infer from $SHELL)",
+ )
+
# promote / demote — move things between personal and repo context
p_promote = sub.add_parser(
"promote",
@@ -4627,6 +4619,7 @@ def build_parser() -> argparse.ArgumentParser:
"why": cmd_why,
"handoff": cmd_handoff,
"init": cmd_init,
+ "completion": cmd_completion,
"inbox": cmd_inbox,
"link": cmd_link,
"unlink": cmd_unlink,
@@ -4674,9 +4667,42 @@ def build_parser() -> argparse.ArgumentParser:
}
+def _subcommand_names(parser: argparse.ArgumentParser) -> list[str]:
+ for action in getattr(parser, "_actions", []):
+ if isinstance(action, argparse._SubParsersAction):
+ return sorted(str(name) for name in action.choices.keys())
+ return []
+
+
+def _maybe_print_command_suggestion(parser: argparse.ArgumentParser, argv: list[str]) -> bool:
+ if not argv:
+ return False
+ command = argv[0]
+ if not command or command.startswith("-"):
+ return False
+ commands = _subcommand_names(parser)
+ if command in commands:
+ return False
+ from dhee.cli_pretty import CalmPrinter
+
+ ui = CalmPrinter(file=sys.stderr)
+ matches = difflib.get_close_matches(command, commands, n=3, cutoff=0.45)
+ ui.write(f"Unknown command: {command}")
+ if matches:
+ ui.write()
+ ui.write("Did you mean:")
+ for match in matches:
+ ui.write(f" dhee {match}")
+ ui.write()
+ ui.write("Run `dhee --help` for all commands.")
+ return True
+
+
def main() -> None:
"""CLI entry point."""
parser = build_parser()
+ if _maybe_print_command_suggestion(parser, sys.argv[1:]):
+ sys.exit(2)
args = parser.parse_args()
if not args.command:
diff --git a/dhee/cli_onboard.py b/dhee/cli_onboard.py
index 5617721..317bb82 100644
--- a/dhee/cli_onboard.py
+++ b/dhee/cli_onboard.py
@@ -5,8 +5,7 @@
1. Provider selection (NVIDIA default, then OpenAI/Gemini/Ollama)
2. API key paste (masked echo, stored in the encrypted secret store)
- 3. Optional git repo linking for shared `.dhee/context/`
- 4. Final "run ``dhee link`` / ``dhee handoff``" handoff
+ 3. Final "run ``dhee init`` in each chosen repo/folder" handoff
The prompts are routed through ``/dev/tty`` so the flow works even when
the caller is piped — the exact shape ``curl ... | sh`` takes. If the
@@ -70,7 +69,21 @@ def _ask_secret(tty_in: io.TextIOBase, tty_out: io.TextIOBase, prompt: str) -> s
try:
import getpass
- # getpass wants fds; if tty is a real terminal this works.
+ # `curl ... | sh` leaves stdin attached to the pipe, while prompts are
+ # routed through /dev/tty. Let getpass open the controlling terminal so
+ # pasted keys still stay hidden in that install shape.
+ is_real_tty = False
+ try:
+ is_real_tty = bool(tty_in.isatty())
+ except Exception:
+ is_real_tty = False
+
+ if tty_in is not sys.stdin and is_real_tty:
+ try:
+ return getpass.getpass(prompt, stream=tty_out).strip()
+ except Exception:
+ pass
+
if tty_in.fileno() == sys.stdin.fileno():
try:
return getpass.getpass(prompt).strip()
@@ -130,7 +143,7 @@ def _save_provider_in_config(provider: str) -> None:
def _link_repo(path: str) -> Tuple[bool, str]:
- """Run ``repo_link.link()`` on *path*. Returns (ok, message)."""
+ """Back-compat helper for old callers that still invoke link directly."""
from dhee import repo_link
try:
@@ -162,9 +175,11 @@ def _init_repo(path: str) -> Tuple[bool, str]:
return False, f"init error: {exc}"
ingest = info.get("ingest") or {}
cm = info.get("claude_md") or {}
+ am = info.get("agents_md") or {}
cm_state = "created" if cm.get("created") else ("updated" if cm.get("updated") else "unchanged")
+ am_state = "created" if am.get("created") else ("updated" if am.get("updated") else "unchanged")
chunks = int(ingest.get("chunks_stored", 0) or 0)
- parts = [f"linked {info['repo_root']}", f"CLAUDE.md {cm_state}"]
+ parts = [f"linked {info['repo_root']}", f"CLAUDE.md {cm_state}", f"AGENTS.md {am_state}"]
if ingest.get("status") == "ok":
parts.append(f"indexed {ingest.get('files_indexed', 0)} doc(s) → {chunks} chunk(s)")
elif ingest.get("status") == "skipped":
@@ -208,16 +223,16 @@ def _init_repos_interactive(
cwd_is_git = _looks_like_git_repo(cwd)
_print(tty_out, "")
- _print(tty_out, "Wire up a git repo for shared developer-brain context?")
+ _print(tty_out, "Wire up a repo or folder for shared developer-brain context?")
_print(
tty_out,
- " `dhee init` creates `/.dhee/`, installs git hooks, indexes the",
+ " `dhee init` creates `/.dhee/`, installs git hooks for repos, indexes the",
)
_print(
tty_out,
" repo's markdown, and adds a small `## Dhee` section to CLAUDE.md.",
)
- _print(tty_out, " You can also run `dhee init` from any git repo later.")
+ _print(tty_out, " You can also run `dhee init` from any repo or folder later.")
_print(tty_out, "")
if cwd_is_git:
@@ -233,19 +248,16 @@ def _init_repos_interactive(
_print(tty_out, "")
_print(
tty_out,
- "Wire up another repo? (paste absolute path, blank to finish):",
+ "Wire up another repo/folder? (paste path, blank to finish):",
)
while True:
- raw = _ask(tty_in, tty_out, "repo path: ").strip()
+ raw = _ask(tty_in, tty_out, "workspace path: ").strip()
if not raw:
break
path = os.path.abspath(os.path.expanduser(raw))
if not os.path.isdir(path):
_print(tty_out, f" ✗ {path} is not a directory; skipped.")
continue
- if not _looks_like_git_repo(path):
- _print(tty_out, f" ✗ {path} is not inside a git repo; run `git init` first.")
- continue
ok, message = _init_repo(path)
marker = "✓" if ok else "✗"
_print(tty_out, f" {marker} {message}")
@@ -311,22 +323,21 @@ def run_onboard(
else:
_print(tty_out, "No key provided; skipping.")
- # ── Repo wire-up — the "share context across teammates" step ─
+ # ── Explicit workspace wire-up, only when requested by flags ─
if link_paths:
_print(tty_out, "")
for path in link_paths:
- resolved = os.path.abspath(os.path.expanduser(path))
- ok, message = _init_repo(resolved)
+ ok, message = _init_repo(path)
marker = "✓" if ok else "✗"
_print(tty_out, f" {marker} {message}")
- elif not skip_link_prompt:
- _init_repos_interactive(tty_in, tty_out)
_print(tty_out, "")
_print(tty_out, "Done. Dhee Developer Brain is ready.")
_print(tty_out, "")
- _print(tty_out, "Wire up more repos any time:")
- _print(tty_out, " cd && dhee init")
+ _print(tty_out, "Choose what shares context with Dhee:")
+ _print(tty_out, " cd && dhee init")
+ _print(tty_out, " dhee init /path/to/folder")
+ _print(tty_out, " dhee init https://github.com/org/repo.git")
_print(tty_out, "Check savings + brain health:")
_print(tty_out, " dhee status")
_print(tty_out, "Search your personal cross-repo brain:")
@@ -360,7 +371,7 @@ def run_onboard(
def register(sub: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
p = sub.add_parser(
"onboard",
- help="Interactive provider + API key setup plus optional repo linking",
+ help="Interactive provider + API key setup",
)
p.add_argument(
"--provider",
@@ -375,16 +386,16 @@ def register(sub: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None
p.add_argument(
"--link",
action="append",
- metavar="PATH",
+ metavar="PATH_OR_URL",
help=(
- "Link this git repo non-interactively (repeatable). "
- "Skips the interactive repo prompt."
+ "Run `dhee init` for this repo, folder, or git URL non-interactively "
+ "(repeatable; kept for installer compatibility)."
),
)
p.add_argument(
"--skip-link-prompt",
action="store_true",
- help="Skip the 'which git repos to link?' step entirely.",
+ help="Deprecated no-op; onboarding no longer asks for repo paths.",
)
p.set_defaults(
func=lambda args: sys.exit(
diff --git a/dhee/cli_pretty.py b/dhee/cli_pretty.py
new file mode 100644
index 0000000..3e44cae
--- /dev/null
+++ b/dhee/cli_pretty.py
@@ -0,0 +1,273 @@
+"""Calm terminal output helpers for Dhee.
+
+This module is inspired by Clypi's small CLI primitives (styling,
+visible-width alignment, and themeable output). It is intentionally
+implemented as a Python 3.9-safe, dependency-free Dhee layer instead of
+vendoring Clypi's Python 3.11 command framework.
+
+MIT attribution for the adapted ideas/patterns:
+
+Copyright 2025 Daniel Melchor
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+"""
+
+from __future__ import annotations
+
+import os
+import re
+import sys
+from dataclasses import dataclass
+from typing import Any, Iterable, Optional, TextIO
+
+
+_ANSI_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
+
+_FG = {
+ "black": "30",
+ "red": "31",
+ "green": "32",
+ "yellow": "33",
+ "blue": "34",
+ "magenta": "35",
+ "cyan": "36",
+ "white": "37",
+ "default": "39",
+ "dim": "90",
+ # One restrained Dhee accent. ANSI 256-color orange/amber; used sparingly.
+ "amber": "38;5;208",
+}
+
+
+def strip_ansi(text: str) -> str:
+ return _ANSI_RE.sub("", str(text))
+
+
+def visible_width(text: str) -> int:
+ return len(strip_ansi(str(text)))
+
+
+def _color_enabled(file: TextIO) -> bool:
+ if os.environ.get("NO_COLOR"):
+ return False
+ if os.environ.get("DHEE_NO_COLOR"):
+ return False
+ if os.environ.get("TERM") == "dumb":
+ return False
+ is_tty = getattr(file, "isatty", lambda: False)
+ try:
+ return bool(is_tty())
+ except Exception:
+ return False
+
+
+def style(
+ text: Any,
+ *,
+ fg: Optional[str] = None,
+ bold: bool = False,
+ dim: bool = False,
+ enabled: bool = True,
+) -> str:
+ raw = str(text)
+ if not enabled:
+ return raw
+ codes = []
+ if bold:
+ codes.append("1")
+ if dim:
+ codes.append("2")
+ if fg:
+ codes.append(str(_FG.get(fg, _FG["default"])))
+ if not codes:
+ return raw
+ return f"\033[{';'.join(codes)}m{raw}\033[0m"
+
+
+def pad_right(text: str, width: int) -> str:
+ return str(text) + (" " * max(0, width - visible_width(str(text))))
+
+
+@dataclass(frozen=True)
+class DheeTheme:
+ accent: str = "amber"
+ ok: str = "default"
+ warn: str = "amber"
+ error: str = "red"
+ muted: str = "dim"
+
+
+class CalmPrinter:
+ """Small, stable-output printer for Dhee CLI commands."""
+
+ def __init__(
+ self,
+ *,
+ file: Optional[TextIO] = None,
+ color: Optional[bool] = None,
+ theme: DheeTheme = DheeTheme(),
+ label_width: int = 12,
+ ) -> None:
+ self.file = file or sys.stdout
+ self.color = _color_enabled(self.file) if color is None else bool(color)
+ self.theme = theme
+ self.label_width = label_width
+
+ def write(self, text: str = "") -> None:
+ print(text, file=self.file)
+
+ def styled(self, text: Any, *, fg: Optional[str] = None, bold: bool = False, dim: bool = False) -> str:
+ return style(text, fg=fg, bold=bold, dim=dim, enabled=self.color)
+
+ def title(self, text: str, *, subtitle: str = "") -> None:
+ self.write(self.styled(text, fg=self.theme.accent, bold=True))
+ if subtitle:
+ self.write(f" {self.styled(subtitle, fg=self.theme.muted, dim=True)}")
+ self.write()
+
+ def row(self, label: str, value: Any = "", *, muted: bool = False) -> None:
+ key = pad_right(str(label), self.label_width)
+ value_text = str(value)
+ if muted:
+ value_text = self.styled(value_text, fg=self.theme.muted, dim=True)
+ self.write(f" {self.styled(key, fg=self.theme.muted, dim=True)} {value_text}")
+
+ def status(self, label: str, state: str, detail: Any = "") -> None:
+ state_key = str(state or "").lower()
+ color = {
+ "ok": self.theme.ok,
+ "done": self.theme.ok,
+ "created": self.theme.ok,
+ "updated": self.theme.ok,
+ "skip": self.theme.warn,
+ "skipped": self.theme.warn,
+ "warn": self.theme.warn,
+ "error": self.theme.error,
+ }.get(state_key, self.theme.muted)
+ key = pad_right(str(label), self.label_width)
+ state_text = pad_right(state_key or "-", 8)
+ line = f" {self.styled(key, fg=self.theme.muted, dim=True)} {self.styled(state_text, fg=color)}"
+ if detail:
+ line += f" {detail}"
+ self.write(line)
+
+ def paragraph(self, text: str, *, indent: str = " ") -> None:
+ for line in str(text).splitlines() or [""]:
+ self.write(f"{indent}{line}")
+
+ def next(self, commands: Iterable[str]) -> None:
+ self.write()
+ self.write(self.styled("Next", fg=self.theme.accent, bold=True))
+ for command in commands:
+ self.write(f" {command}")
+
+
+def state_from_file_result(result: dict[str, Any]) -> str:
+ if result.get("created"):
+ return "created"
+ if result.get("updated"):
+ return "updated"
+ return "unchanged"
+
+
+def render_init(info: dict[str, Any], *, file: Optional[TextIO] = None, color: Optional[bool] = None) -> None:
+ """Render the human `dhee init` result in Dhee's calm house style."""
+
+ ui = CalmPrinter(file=file, color=color)
+ repo_root = str(info.get("repo_root") or "")
+ kind = str(info.get("kind") or "git_repo")
+ ui.title("Dhee init", subtitle="workspace opted into shared context")
+ ui.row("workspace", repo_root)
+ ui.row("kind", kind.replace("_", " "))
+ ui.row("repo id", str(info.get("repo_id") or ""))
+ if info.get("source_url"):
+ suffix = " (cloned)" if info.get("cloned") else ""
+ ui.row("source", f"{info.get('source_url')}{suffix}")
+ elif info.get("git_remote_url"):
+ ui.row("git remote", str(info.get("git_remote_url")))
+
+ hooks = info.get("hooks") or []
+ ui.status("git hooks", "ok" if hooks else "skip", ", ".join(hooks) if hooks else "none")
+
+ claude = info.get("claude_md") or {}
+ ui.status("CLAUDE.md", state_from_file_result(claude), str(claude.get("path") or ""))
+
+ agents = info.get("agents_md") or {}
+ ui.status("AGENTS.md", state_from_file_result(agents), str(agents.get("path") or ""))
+
+ ingest = info.get("ingest") or {}
+ ingest_status = str(ingest.get("status") or "skipped")
+ if ingest_status == "ok":
+ detail_parts = [
+ f"indexed {ingest.get('files_indexed', 0)}",
+ f"unchanged {ingest.get('files_unchanged', 0)}",
+ f"chunks +{ingest.get('chunks_stored', 0)}",
+ ]
+ replaced = int(ingest.get("chunks_replaced", 0) or 0)
+ files_pruned = int(ingest.get("files_pruned", 0) or 0)
+ chunks_pruned = int(ingest.get("chunks_pruned", 0) or 0)
+ if replaced:
+ detail_parts.append(f"replaced {replaced}")
+ if files_pruned or chunks_pruned:
+ detail_parts.append(f"pruned {files_pruned} file(s) / {chunks_pruned} chunk(s)")
+ ui.status("markdown", "ok", ", ".join(detail_parts))
+ elif ingest_status == "skipped":
+ reason = str(ingest.get("reason") or "")
+ if reason == "memory_unavailable":
+ detail = "provider/API key not configured; run `dhee onboard`"
+ elif reason == "skip_ingest":
+ detail = "--skip-ingest"
+ else:
+ detail = reason or "not run"
+ ui.status("markdown", "skip", detail)
+ elif ingest_status == "error":
+ ui.status("markdown", "error", f"{ingest.get('reason', 'unknown')}: {ingest.get('detail', '')}")
+ else:
+ ui.status("markdown", ingest_status)
+
+ ui.row("linked", f"{info.get('linked_repos', 0)} workspace(s) on this machine")
+
+ first_light = info.get("first_light") or {}
+ hits = first_light.get("hits") or []
+ ui.write()
+ if hits:
+ ui.write(ui.styled("First light", fg=ui.theme.accent, bold=True))
+ for hit in hits:
+ text = (str(hit.get("text") or "").strip().splitlines() or [""])[0]
+ head = (text[:140] + "...") if len(text) > 140 else text
+ score = float(hit.get("score", 0.0) or 0.0)
+ ui.write(f" [{score:.2f}] {head}")
+ src = str(hit.get("source_path") or "")
+ if src:
+ from pathlib import Path
+
+ src_short = "/".join(Path(src).parts[-2:])
+ ui.write(f" {ui.styled(src_short, fg=ui.theme.muted, dim=True)}")
+ elif first_light.get("status") == "skipped":
+ ui.status("first light", "skip", str(first_light.get("reason") or "skipped"))
+ else:
+ ui.status("first light", "skip", "no cross-repo learnings yet")
+
+ ui.next(
+ [
+ "dhee status",
+ 'dhee recall ""',
+ "dhee inbox",
+ ]
+ )
diff --git a/dhee/harness/install.py b/dhee/harness/install.py
index 03945f7..84413e0 100644
--- a/dhee/harness/install.py
+++ b/dhee/harness/install.py
@@ -665,15 +665,17 @@ def _status_hermes(config: Dict[str, Any]) -> Dict[str, Any]:
def _codex_instructions() -> str:
return (
"# Dhee Native Integration\n\n"
- "Dhee is the primary memory, context-router, and shared continuity layer for this Codex session.\n\n"
+ "Dhee is installed as the memory, context-router, and shared continuity layer, but it is opt-in per workspace.\n\n"
"Codex-native surfaces:\n"
"- Dhee is registered in `~/.codex/config.toml` as the Codex MCP server.\n"
"- This global `~/.codex/AGENTS.md` block is loaded by Codex on every session.\n"
+ "- A workspace becomes Dhee-native only after the user runs `dhee init` there, creating `/.dhee/config.json` and repo-local `AGENTS.md` guidance.\n"
"- The Dhee MCP server advertises context-first instructions during MCP initialization.\n"
- "- Dhee syncs Codex session logs opportunistically on Dhee context and collaboration calls.\n\n"
+ "- Dhee syncs Codex session logs opportunistically on Dhee context and collaboration calls for initialized workspaces.\n\n"
"Required behavior:\n"
- "- Start every substantive repo/workspace task with Dhee's router path before local reconstruction.\n"
- "- First call `dhee_context_bootstrap` with the absolute repo path before shell/file exploration.\n"
+ "- First check whether the current repo/folder has `/.dhee/config.json`. If it does not, keep the session vanilla and do not call Dhee tools unless the user explicitly asks.\n"
+ "- In initialized workspaces, start substantive repo/workspace tasks with Dhee's router path before local reconstruction.\n"
+ "- First call `dhee_context_bootstrap` with the initialized workspace path before shell/file exploration.\n"
"- Treat `dhee_context_bootstrap` as the normal one-call replacement for startup `dhee_handoff`, `dhee_shared_task`, `dhee_shared_task_results`, and `dhee_inbox` checks.\n"
"- Fall back to the separate startup tools only when bootstrap is unavailable or you need a specific legacy call.\n"
"- When the user says continue, resume, previous, shared context, or UI context, treat Dhee handoff/shared-task results as the source of continuity.\n"
@@ -682,7 +684,7 @@ def _codex_instructions() -> str:
"- Search promoted learnings with `dhee_search_learnings` when prior Dhee/Hermes self-evolution may apply.\n"
"- After completing a substantial native tool action, refresh Dhee collaboration context with `dhee_inbox` before continuing on shared work.\n"
"- When you discover context another active agent needs now, call `dhee_broadcast` instead of waiting for session end.\n"
- "- Treat Dhee memories, artifacts, repo-shared context, and shared-task results as the canonical reusable context for this repo.\n"
+ "- Treat Dhee memories, artifacts, repo-shared context, and shared-task results as canonical reusable context only for initialized workspaces.\n"
)
diff --git a/dhee/hooks/claude_code/__main__.py b/dhee/hooks/claude_code/__main__.py
index 7032bdf..19a034d 100644
--- a/dhee/hooks/claude_code/__main__.py
+++ b/dhee/hooks/claude_code/__main__.py
@@ -425,11 +425,17 @@ def _discover_repo_config(start: str) -> dict[str, Any]:
except Exception:
return {}
if isinstance(data, dict):
- os.environ.setdefault("DHEE_REPO_ROOT", str(candidate))
+ os.environ["DHEE_REPO_ROOT"] = str(candidate)
return {"repo_root": str(candidate), **data}
return {}
+def _active_repo_config(payload: Any) -> dict[str, Any]:
+ """Return repo config only for workspaces opted in with ``dhee init``."""
+ cfg = _discover_repo_config(_hook_cwd(payload))
+ return cfg if cfg.get("repo_root") else {}
+
+
def _repo_last_session(repo: str) -> dict[str, Any] | None:
try:
from dhee.core.kernel import get_last_session
@@ -601,8 +607,10 @@ def handle_session_start(payload: dict[str, Any]) -> dict[str, Any]:
from dhee.hooks.claude_code.assembler import assemble
from dhee.hooks.claude_code.ingest import auto_ingest_project
- repo_cfg = _discover_repo_config(_hook_cwd(payload))
- repo_root = str(repo_cfg.get("repo_root") or _hook_cwd(payload))
+ repo_cfg = _active_repo_config(payload)
+ if not repo_cfg:
+ return {}
+ repo_root = str(repo_cfg["repo_root"])
state_store = _compiled_state_store(payload, repo=repo_root)
dhee = _get_dhee()
@@ -721,9 +729,12 @@ def handle_user_prompt(payload: dict[str, Any]) -> dict[str, Any]:
if not prompt.strip():
return {}
+ repo_cfg = _active_repo_config(payload)
+ if not repo_cfg:
+ return {}
+
dhee = _get_dhee()
- _discover_repo_config(_hook_cwd(payload))
- repo = os.environ.get("DHEE_REPO_ROOT") or os.getcwd()
+ repo = str(repo_cfg["repo_root"])
state_store = _compiled_state_store(payload, repo=repo)
try:
state_store.observe_prompt(prompt)
@@ -833,6 +844,9 @@ def handle_pre_tool(payload: dict[str, Any]) -> dict[str, Any]:
"""Router enforcement gate. No-op unless DHEE_ROUTER_ENFORCE=1."""
from dhee.router.pre_tool_gate import evaluate
+ if not _active_repo_config(payload):
+ return {}
+
if isinstance(payload, dict):
try:
tool_name = str(payload.get("tool_name") or "")
@@ -864,6 +878,9 @@ def handle_post_tool(payload: dict[str, Any]) -> dict[str, Any]:
if not isinstance(payload, dict):
return {}
+ repo_cfg = _active_repo_config(payload)
+ if not repo_cfg:
+ return {}
tool_name = payload.get("tool_name", "")
tool_input = payload.get("tool_input", {}) or {}
@@ -1048,6 +1065,10 @@ def handle_post_tool(payload: dict[str, Any]) -> dict[str, Any]:
def handle_pre_compact(payload: dict[str, Any]) -> dict[str, Any]:
from dhee.hooks.claude_code.assembler import assemble
+ repo_cfg = _active_repo_config(payload)
+ if not repo_cfg:
+ return {}
+
dhee = _get_dhee()
summary = "session compacted"
@@ -1095,6 +1116,10 @@ def handle_pre_compact(payload: dict[str, Any]) -> dict[str, Any]:
def handle_stop(payload: dict[str, Any]) -> dict[str, Any]:
+ repo_cfg = _active_repo_config(payload)
+ if not repo_cfg:
+ return {}
+
dhee = _get_dhee()
_maybe_tail_ingest_gstack(dhee)
diff --git a/dhee/memory/narrative_scene.py b/dhee/memory/narrative_scene.py
index 007226e..c9c2cf9 100644
--- a/dhee/memory/narrative_scene.py
+++ b/dhee/memory/narrative_scene.py
@@ -561,6 +561,8 @@ def scene_event(
) -> Dict[str, Any]:
if not scene_id:
return {"error": "scene_id is required"}
+ if not self.db.get_scene(scene_id):
+ return {"error": "scene not found"}
event_payload = payload if isinstance(payload, dict) else {}
if not summary and event_payload:
summary = _payload_event_summary(event_payload, event_type)
@@ -631,17 +633,32 @@ def scene_end(
consolidation_payloads = _consolidation_payloads(extra_evidence)
event_summaries = [event["summary"] for event in events if event.get("summary")]
evidence_summaries = [_evidence_summary(item) for item in extra_evidence if _evidence_summary(item)]
+ explicit_durable_facts = _as_text_list(durable_facts, limit=10, item_limit=500)
+ summary_parts = [
+ story_progress_delta,
+ outcome,
+ *explicit_durable_facts[:3],
+ scene.get("summary"),
+ scene.get("title"),
+ scene.get("action"),
+ *event_summaries,
+ *evidence_summaries,
+ ]
+ deduped_summary_parts = []
+ seen_summary_parts = set()
+ for part in summary_parts:
+ text = _clip(str(part or "").strip(), 320)
+ if not text or text in seen_summary_parts:
+ continue
+ seen_summary_parts.add(text)
+ deduped_summary_parts.append(text)
summary = _clip(
- " ".join(event_summaries + evidence_summaries)
- or scene.get("summary")
- or scene.get("title")
- or "Scene completed.",
+ " ".join(deduped_summary_parts) or "Scene completed.",
1200,
)
retrieval_tags = _normalize_categories(
categories + _tokens(" ".join([summary, scene.get("title") or "", scene.get("action") or ""]))[:12]
)
- explicit_durable_facts = _as_text_list(durable_facts, limit=10, item_limit=500)
evidence_refs = [
{
"kind": event.get("event_type", "event"),
diff --git a/dhee/memory/search_pipeline.py b/dhee/memory/search_pipeline.py
index 27f6c3d..a9fc84e 100644
--- a/dhee/memory/search_pipeline.py
+++ b/dhee/memory/search_pipeline.py
@@ -233,15 +233,22 @@ def search(
vector_results = collapse_vector_results(vector_results)
- if not vector_results:
- vector_results = self._db_lexical_fallback(
- query_terms=query_terms,
- effective_filters=effective_filters,
- user_id=user_id,
- agent_id=agent_id,
- limit=limit * 2,
- min_strength=min_strength,
- )
+ lexical_results = self._db_lexical_fallback(
+ query_terms=query_terms,
+ effective_filters=effective_filters,
+ user_id=user_id,
+ agent_id=agent_id,
+ limit=limit * 2,
+ min_strength=min_strength,
+ )
+ if lexical_results:
+ merged = {resolve_memory_id(result): result for result in vector_results}
+ for result in lexical_results:
+ memory_id = resolve_memory_id(result)
+ existing = merged.get(memory_id)
+ if not existing or result.score > existing.score:
+ merged[memory_id] = result
+ vector_results = list(merged.values())
# CategoryMem: Detect relevant categories for the query
category_processor = self._category_processor_fn()
diff --git a/dhee/repo_link.py b/dhee/repo_link.py
index 1b2cd72..61d3660 100644
--- a/dhee/repo_link.py
+++ b/dhee/repo_link.py
@@ -71,6 +71,15 @@ def __init__(self, entry_id: str, message: str, *, conflicts: Optional[List[Dict
self.entry_id = entry_id
self.conflicts = conflicts or []
+
+@dataclass(frozen=True)
+class InitTarget:
+ root: Path
+ kind: str
+ requested: str
+ source_url: Optional[str] = None
+ cloned: bool = False
+
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
@@ -164,6 +173,113 @@ def _git_top(path: Path) -> Optional[Path]:
return Path(out).resolve() if out else None
+def _looks_like_git_url(value: str | os.PathLike[str]) -> bool:
+ text = str(value or "").strip()
+ if not text:
+ return False
+ return (
+ text.startswith(("http://", "https://", "ssh://", "git://", "file://"))
+ or text.startswith("git@")
+ or text.endswith(".git")
+ )
+
+
+def _repo_dir_name_from_url(url: str) -> str:
+ text = str(url or "").rstrip("/").strip()
+ if not text:
+ return "repo"
+ tail = text.rsplit("/", 1)[-1]
+ if ":" in tail and text.startswith("git@"):
+ tail = tail.rsplit(":", 1)[-1]
+ if tail.endswith(".git"):
+ tail = tail[:-4]
+ cleaned = "".join(ch for ch in tail if ch.isalnum() or ch in {"-", "_", "."}).strip(".")
+ return cleaned or "repo"
+
+
+def _git_remote_url(repo_root: Path) -> Optional[str]:
+ if _git_top(repo_root) is None:
+ return None
+ try:
+ out = subprocess.check_output(
+ ["git", "-C", str(repo_root), "remote", "get-url", "origin"],
+ stderr=subprocess.DEVNULL,
+ text=True,
+ timeout=2.0,
+ ).strip()
+ except (subprocess.SubprocessError, OSError):
+ return None
+ return out or None
+
+
+def _clone_git_url(url: str, *, into: Optional[Path] = None) -> Path:
+ target = (into or Path.cwd()) / _repo_dir_name_from_url(url)
+ target = target.expanduser().resolve()
+ if target.exists():
+ if _git_top(target) is not None:
+ return target
+ raise ValueError(
+ f"{target} already exists and is not a git repository. "
+ "Move it, choose another parent directory, or run `dhee init` inside it."
+ )
+ target.parent.mkdir(parents=True, exist_ok=True)
+ try:
+ subprocess.run(["git", "clone", url, str(target)], check=True)
+ except (subprocess.SubprocessError, OSError) as exc:
+ raise ValueError(f"Could not clone {url}: {exc}") from exc
+ return target
+
+
+def _initialized_root(path: Path) -> Optional[Path]:
+ probe = path if path.is_dir() else path.parent
+ try:
+ probe = probe.resolve()
+ except Exception:
+ pass
+ home = Path.home().resolve()
+ for current in [probe, *probe.parents]:
+ if current == home:
+ break
+ if repo_config_path(current).is_file():
+ return current
+ return None
+
+
+def _resolve_init_target(path: str | os.PathLike[str] = ".") -> InitTarget:
+ requested = str(path or ".")
+ if _looks_like_git_url(requested):
+ cloned_path = _clone_git_url(requested)
+ repo_root = _git_top(cloned_path) or cloned_path
+ return InitTarget(
+ root=repo_root,
+ kind="git_repo",
+ requested=requested,
+ source_url=requested,
+ cloned=True,
+ )
+
+ target = _resolve(path)
+ repo_root = _git_top(target)
+ if repo_root is not None:
+ return InitTarget(
+ root=repo_root,
+ kind="git_repo",
+ requested=requested,
+ source_url=_git_remote_url(repo_root),
+ cloned=False,
+ )
+
+ if target.exists() and target.is_file():
+ target = target.parent
+ if not target.exists():
+ raise ValueError(
+ f"{target} does not exist. Create the folder first, or pass a git URL for Dhee to clone."
+ )
+ if not target.is_dir():
+ raise ValueError(f"{target} is not a directory.")
+ return InitTarget(root=target, kind="folder", requested=requested)
+
+
def _path_within(child: Path, parent: Path) -> bool:
try:
return os.path.commonpath([str(child), str(parent)]) == str(parent)
@@ -326,16 +442,40 @@ def _write_repo_config(repo_root: Path, cfg: Dict[str, Any]) -> None:
_write_json(repo_config_path(repo_root), cfg)
-def _ensure_repo_skeleton(repo_root: Path) -> str:
+def _ensure_repo_skeleton(
+ repo_root: Path,
+ *,
+ kind: str = "git_repo",
+ source_url: Optional[str] = None,
+) -> str:
"""Create ``/.dhee/`` and return the repo_id (existing or new)."""
repo_dhee_dir(repo_root).mkdir(parents=True, exist_ok=True)
repo_context_dir(repo_root).mkdir(parents=True, exist_ok=True)
cfg = _read_repo_config(repo_root)
+ changed = False
if not cfg.get("repo_id"):
cfg["repo_id"] = _new_id()
- cfg["schema_version"] = SCHEMA_VERSION
+ changed = True
+ if not cfg.get("linked_at"):
cfg["linked_at"] = _now_iso()
+ changed = True
+ updates = {
+ "schema_version": SCHEMA_VERSION,
+ "kind": kind,
+ "folder_path": str(repo_root),
+ "workspace_root": str(repo_root),
+ }
+ if source_url:
+ updates["source_url"] = source_url
+ git_remote = _git_remote_url(repo_root) if kind == "git_repo" else None
+ if git_remote:
+ updates["git_remote_url"] = git_remote
+ for key, value in updates.items():
+ if cfg.get(key) != value:
+ cfg[key] = value
+ changed = True
+ if changed:
_write_repo_config(repo_root, cfg)
entries = repo_entries_path(repo_root)
@@ -774,41 +914,51 @@ def uninstall_hooks(repo_root: Path) -> List[str]:
def link(path: str | os.PathLike[str] = ".") -> Dict[str, Any]:
- """Link a git repository to this machine.
+ """Link a repo or folder to this machine.
Side-effects (all idempotent):
- * Resolves *path* to its git root.
- * Creates ``/.dhee/`` skeleton with ``config.json``,
+ * Resolves *path* to its git root when inside git, otherwise to the folder.
+ * Creates ``/.dhee/`` skeleton with ``config.json``,
``context/manifest.json``, ``context/entries.jsonl``,
``.gitattributes``.
- * Registers the repo in ``~/.dhee/links.json``.
- * Mirrors the repo into ``~/.dhee/local_context_folders.json``
+ * Registers the root in ``~/.dhee/links.json``.
+ * Mirrors the root into ``~/.dhee/local_context_folders.json``
(the existing UI workspace store) so the canvas finds it.
- * Installs refresh hooks plus a ``pre-push`` conflict check.
+ * Installs refresh hooks plus a ``pre-push`` conflict check for git repos.
"""
- target = _resolve(path)
- repo_root = _git_top(target)
- if repo_root is None:
- raise ValueError(
- f"{target} is not inside a git repository. "
- "Run `git init` first or pass a path inside a checked-out repo."
- )
+ target = _resolve_init_target(path)
+ repo_root = target.root
- repo_id = _ensure_repo_skeleton(repo_root)
- hooks = install_hooks(repo_root)
+ repo_id = _ensure_repo_skeleton(
+ repo_root,
+ kind=target.kind,
+ source_url=target.source_url,
+ )
+ hooks = install_hooks(repo_root) if target.kind == "git_repo" else []
_mirror_workspace(repo_root, shared=True)
repos = list_links()
repos[str(repo_root)] = {
"repo_id": repo_id,
"linked_at": repos.get(str(repo_root), {}).get("linked_at") or _now_iso(),
+ "kind": target.kind,
+ "folder_path": str(repo_root),
+ "workspace_root": str(repo_root),
+ "source_url": target.source_url,
+ "git_remote_url": _git_remote_url(repo_root) if target.kind == "git_repo" else None,
"hooks_installed": bool(hooks),
}
_save_links(repos)
return {
"repo_root": str(repo_root),
+ "folder_path": str(repo_root),
+ "kind": target.kind,
+ "requested": target.requested,
+ "source_url": target.source_url,
+ "git_remote_url": _git_remote_url(repo_root) if target.kind == "git_repo" else None,
+ "cloned": target.cloned,
"repo_id": repo_id,
"hooks": hooks,
"manifest": _read_json(repo_manifest_path(repo_root), {}),
@@ -872,17 +1022,44 @@ def link(path: str | os.PathLike[str] = ".") -> Dict[str, Any]:
rules, gotchas, and architectural decisions outside the markers. -->
"""
+_AGENTS_MD_BODY = """\
+# Dhee Native Integration
+
+This workspace has opted into Dhee with `dhee init`.
+
+Required behavior:
+- Start substantive repo/workspace tasks with `dhee_context_bootstrap` using this workspace path before local reconstruction.
+- Prefer `dhee_read`, `dhee_grep`, and `dhee_bash` for large file reads, searches, and commands so raw output stays behind pointers.
+- Use `dhee_scene_context` and `dhee_narrative_prior` as advisory memory/context priors; explicit user intent, facts, privacy, and proof gates win.
+- Keep Dhee scoped to this initialized workspace. Repos/folders without `.dhee/config.json` are vanilla unless the user explicitly opts them in with `dhee init`.
+"""
+
def _claude_md_path(repo_root: Path) -> Path:
return repo_root / "CLAUDE.md"
+def _agents_md_path(repo_root: Path) -> Path:
+ return repo_root / "AGENTS.md"
+
+
def _build_dhee_section() -> str:
return f"{DHEE_CLAUDE_MD_START}\n{_CLAUDE_MD_BODY.rstrip()}\n{DHEE_CLAUDE_MD_END}\n"
-def write_claude_md(repo_root: Path) -> Tuple[bool, bool]:
- """Idempotently write the Dhee section into ``/CLAUDE.md``.
+def _build_agents_section() -> str:
+ return f"{DHEE_CLAUDE_MD_START}\n{_AGENTS_MD_BODY.rstrip()}\n{DHEE_CLAUDE_MD_END}\n"
+
+
+def _write_managed_markdown(
+ repo_root: Path,
+ path: Path,
+ *,
+ section: str,
+ label: str,
+ include_header: bool = True,
+) -> Tuple[bool, bool]:
+ """Idempotently write a marker-bracketed Dhee section.
Returns ``(created, updated)``:
@@ -895,30 +1072,26 @@ def write_claude_md(repo_root: Path) -> Tuple[bool, bool]:
The dev's content above and below the marker block is preserved
verbatim; we only rewrite what's between the markers.
- SECURITY: refuse to write through a symlink whose target escapes
- the repo. ``/CLAUDE.md`` being a symlink to e.g.
+ SECURITY: refuse to write through a symlink whose target escapes the
+ workspace. ``/CLAUDE.md`` being a symlink to e.g.
``/etc/cron.d/something`` would otherwise let a malicious repo
redirect Dhee's write to a path outside the dev's control.
"""
- path = _claude_md_path(repo_root)
-
if path.exists() or path.is_symlink():
try:
real = path.resolve()
repo_real = repo_root.resolve()
- # The resolved CLAUDE.md must live inside the repo root.
+ # The resolved managed file must live inside the workspace root.
real.relative_to(repo_real)
except (ValueError, OSError):
raise ValueError(
- f"refusing to write CLAUDE.md: {path} resolves outside "
+ f"refusing to write {label}: {path} resolves outside "
f"the repo root ({repo_root}). Investigate the symlink "
"before re-running `dhee init`."
)
- section = _build_dhee_section()
-
if not path.exists():
- header = f"# {repo_root.name}\n\n"
+ header = f"# {repo_root.name}\n\n" if include_header else ""
path.write_text(header + section, encoding="utf-8")
return True, False
@@ -947,6 +1120,28 @@ def write_claude_md(repo_root: Path) -> Tuple[bool, bool]:
return False, True
+def write_claude_md(repo_root: Path) -> Tuple[bool, bool]:
+ """Idempotently write the Dhee section into ``/CLAUDE.md``."""
+ return _write_managed_markdown(
+ repo_root,
+ _claude_md_path(repo_root),
+ section=_build_dhee_section(),
+ label="CLAUDE.md",
+ include_header=True,
+ )
+
+
+def write_agents_md(repo_root: Path) -> Tuple[bool, bool]:
+ """Idempotently write the Dhee section into ``/AGENTS.md``."""
+ return _write_managed_markdown(
+ repo_root,
+ _agents_md_path(repo_root),
+ section=_build_agents_section(),
+ label="AGENTS.md",
+ include_header=True,
+ )
+
+
def _ingest_repo_markdown(repo_root: Path, *, max_chunks: int) -> Dict[str, Any]:
"""Run the extended markdown ingest. Best-effort — never raises.
@@ -1090,7 +1285,7 @@ def init(
skip_ingest: bool = False,
skip_first_light: bool = False,
) -> Dict[str, Any]:
- """One-command on-ramp: link + index + write CLAUDE.md + first-light.
+ """One-command on-ramp: link + index + write harness instructions + first-light.
Idempotent. Re-runs are cheap (SHA-skip on unchanged files,
marker-bracketed CLAUDE.md edit, no duplicate hook entries).
@@ -1099,21 +1294,15 @@ def init(
each section honestly — empty stages render as "no change", not
fake reassurance.
"""
- target = _resolve(path)
- repo_root = _git_top(target)
- if repo_root is None:
- raise ValueError(
- f"{target} is not inside a git repository. "
- "Run `git init` first, then `dhee init`."
- )
-
- link_info = link(repo_root)
+ link_info = link(path)
+ repo_root = Path(str(link_info["repo_root"])).resolve()
repo_id = str(link_info.get("repo_id") or "")
- # Write CLAUDE.md first so it's part of the very first ingest pass.
- # Otherwise the second run would chunk the freshly-written CLAUDE.md
+ # Write harness instructions first so they're part of the very first
+ # ingest pass. Otherwise the second run would chunk freshly-written files
# and re-runs wouldn't be true no-ops.
claude_created, claude_updated = write_claude_md(repo_root)
+ agents_created, agents_updated = write_agents_md(repo_root)
ingest_summary: Dict[str, Any]
if skip_ingest:
@@ -1130,6 +1319,11 @@ def init(
return {
"repo_root": str(repo_root),
+ "folder_path": link_info.get("folder_path") or str(repo_root),
+ "kind": link_info.get("kind") or "git_repo",
+ "source_url": link_info.get("source_url"),
+ "git_remote_url": link_info.get("git_remote_url"),
+ "cloned": bool(link_info.get("cloned")),
"repo_id": repo_id,
"linked_repos": linked_count,
"hooks": link_info.get("hooks") or [],
@@ -1139,6 +1333,12 @@ def init(
"updated": claude_updated,
"unchanged": not claude_created and not claude_updated,
},
+ "agents_md": {
+ "path": str(_agents_md_path(repo_root)),
+ "created": agents_created,
+ "updated": agents_updated,
+ "unchanged": not agents_created and not agents_updated,
+ },
"ingest": ingest_summary,
"first_light": first_light,
}
@@ -1152,7 +1352,7 @@ def unlink(path: str | os.PathLike[str] = ".", *, remove_hooks: bool = True) ->
the git hooks come off.
"""
target = _resolve(path)
- repo_root = _git_top(target) or target
+ repo_root = _git_top(target) or _initialized_root(target) or (target if target.is_dir() else target.parent)
repos = list_links()
removed = repos.pop(str(repo_root), None)
@@ -1195,7 +1395,7 @@ def refresh(repo: str | os.PathLike[str] | None = None) -> List[Dict[str, Any]]:
targets: List[Path] = []
if repo is not None:
target = _resolve(repo)
- root = _git_top(target) or target
+ root = _git_top(target) or _initialized_root(target) or (target if target.is_dir() else target.parent)
targets.append(root)
else:
targets = [Path(p) for p in list_links().keys()]
@@ -1465,7 +1665,7 @@ def demote(
def _resolve_repo(repo: str | os.PathLike[str] | None) -> Optional[Path]:
if repo is not None:
target = _resolve(repo)
- root = _git_top(target) or target
+ root = _git_top(target) or _initialized_root(target) or (target if target.is_dir() else target.parent)
return root
return repo_for_path(Path.cwd())
diff --git a/dhee/router/pre_tool_gate.py b/dhee/router/pre_tool_gate.py
index 9607b3e..0ae492d 100644
--- a/dhee/router/pre_tool_gate.py
+++ b/dhee/router/pre_tool_gate.py
@@ -30,11 +30,12 @@
import json
import re
from pathlib import Path
-from typing import Any
+from typing import Any, Optional
READ_SIZE_THRESHOLD = 20 * 1024 # 20 KB
_FLAG_ENV = "DHEE_ROUTER_ENFORCE"
+_FORCE_ENV = "DHEE_FORCE_ROUTER_ENFORCE"
def _flag_file() -> Path:
@@ -108,6 +109,40 @@ def _candidate_repo(inp: dict[str, Any]) -> Path:
return path
+def _initialized_workspace_root(path: Path) -> Optional[Path]:
+ """Return the nearest Dhee-initialized workspace root for *path*.
+
+ Harnesses may be installed globally, but Dhee should only steer tools in
+ workspaces the user explicitly opted into with ``dhee init``. The public
+ marker for that choice is ``/.dhee/config.json``.
+ """
+ try:
+ probe = path if path.is_dir() else path.parent
+ probe = probe.resolve()
+ except Exception:
+ probe = Path.cwd()
+ try:
+ home = Path.home().resolve()
+ except Exception:
+ home = None
+ for current in [probe, *probe.parents]:
+ if home is not None and current == home:
+ break
+ if (current / ".dhee" / "config.json").is_file():
+ return current
+ return None
+
+
+def _force_global_enforcement() -> bool:
+ return _truthy(os.environ.get(_FORCE_ENV))
+
+
+def _input_is_initialized(inp: dict[str, Any]) -> bool:
+ if _force_global_enforcement():
+ return True
+ return _initialized_workspace_root(_candidate_repo(inp)) is not None
+
+
def _fallback_enforcement_mode(inp: dict[str, Any]) -> str:
if _truthy(os.environ.get("DHEE_REQUIRE_ACTIVE_CONTRACT")):
return "deny"
@@ -148,6 +183,9 @@ def evaluate(payload: dict[str, Any]) -> dict[str, Any]:
if not isinstance(tool_input, dict):
tool_input = {}
+ if not _input_is_initialized(tool_input):
+ return {}
+
contract_denial = _evaluate_contract_supervisor(str(tool), tool_input)
if contract_denial:
return contract_denial
diff --git a/docs/demo/install-demo.gif b/docs/demo/install-demo.gif
new file mode 100644
index 0000000..4204fca
Binary files /dev/null and b/docs/demo/install-demo.gif differ
diff --git a/docs/demo/install-demo.mp4 b/docs/demo/install-demo.mp4
new file mode 100644
index 0000000..8ed86c0
Binary files /dev/null and b/docs/demo/install-demo.mp4 differ
diff --git a/docs/demo/install-demo.tape b/docs/demo/install-demo.tape
new file mode 100644
index 0000000..8d8dc7f
--- /dev/null
+++ b/docs/demo/install-demo.tape
@@ -0,0 +1,44 @@
+Output docs/demo/install-demo.gif
+Output docs/demo/install-demo.mp4
+
+Require curl
+Require sh
+
+Set Shell "zsh"
+Set FontSize 15
+Set Width 1120
+Set Height 680
+Set Theme '{"name":"Dhee Calm","black":"#000000","red":"#ff5f5f","green":"#e8e8e8","yellow":"#ff9f1c","blue":"#9aa7ff","purple":"#d6d6d6","cyan":"#d6d6d6","white":"#e8e8e8","brightBlack":"#6a6a6a","brightRed":"#ff7777","brightGreen":"#ffffff","brightYellow":"#ffb347","brightBlue":"#b8c0ff","brightPurple":"#ffffff","brightCyan":"#ffffff","brightWhite":"#ffffff","background":"#000000","foreground":"#e8e8e8","selectionBackground":"#303030","cursorColor":"#e8e8e8"}'
+Set Padding 20
+Set Framerate 12
+Set TypingSpeed 8ms
+Set PlaybackSpeed 1.6
+Set WindowBar "Rings"
+Set BorderRadius 8
+
+Hide
+Type "cd /Users/chitranjanmalviya/Desktop/Dhee" Enter
+Type "export DEMO_HOME=/tmp/dhee-install-demo-home DEMO_WORKSPACE=/tmp/dhee-install-demo-workspace" Enter
+Type "export DEMO_PATH=/usr/bin:/bin:/opt/homebrew/bin:/usr/local/bin" Enter
+Type "export HOME=$DEMO_HOME SHELL=/bin/zsh PATH=$DEMO_HOME/.local/bin:$DEMO_PATH" Enter
+Type "export DHEE_INSTALL_PACKAGE=$PWD/dist/dhee-7.1.0-py3-none-any.whl" Enter
+Type "rm -rf $DEMO_HOME $DEMO_WORKSPACE && mkdir -p $DEMO_WORKSPACE" Enter
+Type "clear" Enter
+Show
+
+Type "curl -fsSL https://raw.githubusercontent.com/Sankhya-AI/Dhee/main/install.sh | sh" Enter
+Sleep 46s
+Enter
+Sleep 2s
+Type "dhee-demo-key" Enter
+Sleep 8s
+
+Type "cd $DEMO_WORKSPACE" Enter
+Type "dhee init --skip-ingest --skip-first-light" Enter
+Sleep 5s
+
+Type "dhee status" Enter
+Sleep 4s
+
+Type "dhee completion --shell zsh | sed -n '1,9p'" Enter
+Sleep 5s
diff --git a/docs/demo/install-stage-01-provider.png b/docs/demo/install-stage-01-provider.png
new file mode 100644
index 0000000..2e8cf3b
Binary files /dev/null and b/docs/demo/install-stage-01-provider.png differ
diff --git a/docs/demo/install-stage-02-key-hidden.png b/docs/demo/install-stage-02-key-hidden.png
new file mode 100644
index 0000000..e0334fe
Binary files /dev/null and b/docs/demo/install-stage-02-key-hidden.png differ
diff --git a/docs/demo/install-stage-03-init.png b/docs/demo/install-stage-03-init.png
new file mode 100644
index 0000000..5958d8a
Binary files /dev/null and b/docs/demo/install-stage-03-init.png differ
diff --git a/docs/demo/install-stage-04-completion.png b/docs/demo/install-stage-04-completion.png
new file mode 100644
index 0000000..417053d
Binary files /dev/null and b/docs/demo/install-stage-04-completion.png differ
diff --git a/install.sh b/install.sh
index d98666d..98e9821 100755
--- a/install.sh
+++ b/install.sh
@@ -8,15 +8,14 @@
# 2. Installs the dhee package
# 3. Symlinks `dhee` and `dhee-mcp` into ~/.local/bin
# 4. Wires Claude Code (hooks + MCP + router) if available
-# 5. Runs `dhee onboard` — provider picker, API key paste,
-# and optional git repo linking
+# 5. Runs `dhee onboard` — provider picker + API key paste
# 6. Shows `dhee ui` so the developer can inspect the local brain
#
# Non-interactive: pass DHEE_PROVIDER=openai DHEE_API_KEY=sk-... to skip
-# the prompts entirely (CI-friendly). Set DHEE_INIT_REPO=/path/to/repo to
-# wire a git repo non-interactively after install. Set
-# DHEE_INIT_SKIP_INGEST=1 for CI smoke tests that should link the repo without
-# calling an embedding provider.
+# the prompts entirely (CI-friendly). Set DHEE_INIT_REPO to a repo path,
+# folder path, or git URL to run `dhee init` non-interactively after install.
+# Set DHEE_INIT_SKIP_INGEST=1 for CI smoke tests that should link the
+# workspace without calling an embedding provider.
#
# Requires: Python 3.9+ (Claude Code CLI optional)
set -e
@@ -25,21 +24,21 @@ DHEE_HOME="$HOME/.dhee"
VENV_DIR="$DHEE_HOME/.venv"
BIN_DIR="$HOME/.local/bin"
MIN_PYTHON="3.9"
-DEFAULT_PACKAGE="dhee>=7.1.0"
+DEFAULT_PACKAGE="dhee>=7.2.0"
PACKAGE="${DHEE_INSTALL_PACKAGE:-$DEFAULT_PACKAGE}"
FALLBACK_PACKAGE="${DHEE_FALLBACK_PACKAGE:-git+https://github.com/Sankhya-AI/Dhee.git@main}"
# --- Colors ---
if [ -t 1 ]; then
- BOLD="\033[1m" GREEN="\033[32m" YELLOW="\033[33m" RED="\033[31m" DIM="\033[2m" RESET="\033[0m"
+ BOLD="\033[1m" AMBER="\033[38;5;208m" RED="\033[31m" DIM="\033[2m" RESET="\033[0m"
else
- BOLD="" GREEN="" YELLOW="" RED="" DIM="" RESET=""
+ BOLD="" AMBER="" RED="" DIM="" RESET=""
fi
-info() { printf "${GREEN}>${RESET} %s\n" "$1"; }
-warn() { printf "${YELLOW}!${RESET} %s\n" "$1"; }
+info() { printf "> %s\n" "$1"; }
+warn() { printf "${AMBER}!${RESET} %s\n" "$1"; }
error() { printf "${RED}x${RESET} %s\n" "$1" >&2; exit 1; }
-done_() { printf "${GREEN}✓${RESET} %s\n" "$1"; }
+done_() { printf "${BOLD}✓${RESET} %s\n" "$1"; }
pip_install_package() {
"$VENV_DIR/bin/pip" install --upgrade --force-reinstall --no-cache-dir "$1" -q
@@ -189,18 +188,17 @@ ONBOARD_STATUS=0
if [ "$NONINTERACTIVE_DONE" = "1" ]; then
info "Skipping interactive onboarding"
else
- # Interactive: onboard reads from /dev/tty so this works under curl | sh.
- if [ -r /dev/tty ]; then
- "$VENV_DIR/bin/dhee" onboard < /dev/tty || ONBOARD_STATUS=$?
- else
- warn "No TTY detected — skipping interactive onboarding."
- warn "Run 'dhee onboard' manually to pick a provider and paste your API key."
+ # Interactive: `dhee onboard` opens /dev/tty itself when available and
+ # returns a friendly nonzero status when no terminal is attached.
+ "$VENV_DIR/bin/dhee" onboard || ONBOARD_STATUS=$?
+ if [ "$ONBOARD_STATUS" -ne 0 ]; then
+ warn "Interactive onboarding skipped — run 'dhee onboard' from a terminal to pick a provider and paste your API key."
ONBOARD_STATUS=0
fi
fi
if [ -n "${DHEE_INIT_REPO:-}" ]; then
- info "Wiring git repo: ${DHEE_INIT_REPO}"
+ info "Wiring repo/folder: ${DHEE_INIT_REPO}"
INIT_FLAGS=""
[ "${DHEE_INIT_SKIP_INGEST:-}" = "1" ] && INIT_FLAGS="$INIT_FLAGS --skip-ingest"
[ "${DHEE_INIT_SKIP_FIRST_LIGHT:-}" = "1" ] && INIT_FLAGS="$INIT_FLAGS --skip-first-light"
@@ -208,13 +206,14 @@ if [ -n "${DHEE_INIT_REPO:-}" ]; then
if "$VENV_DIR/bin/dhee" init "$DHEE_INIT_REPO" $INIT_FLAGS >/dev/null 2>&1; then
done_ "Repo wired into Dhee"
else
- warn "Repo wire-up failed — run 'cd ${DHEE_INIT_REPO} && dhee init' manually for details"
+ warn "Repo/folder wire-up failed — run 'dhee init ${DHEE_INIT_REPO}' manually for details"
fi
fi
# --- Done ---
-printf "\n${BOLD}${GREEN}Dhee is ready.${RESET}\n"
-printf " Wire up a repo: ${BOLD}cd /path/to/repo && dhee init${RESET}\n"
+printf "\n${BOLD}Dhee is ready.${RESET}\n"
+printf " Wire up context: ${BOLD}cd /path/to/repo-or-folder && dhee init${RESET}\n"
+printf " ${BOLD}dhee init /path/to/folder${RESET} or ${BOLD}dhee init ${RESET}\n"
printf " Open the UI: ${BOLD}dhee ui${RESET} ${DIM}(local command center, folders canvas, firewall)${RESET}\n"
printf " Update later: ${BOLD}dhee update${RESET}\n\n"
printf "${DIM} Status: dhee status (savings + brain health)${RESET}\n"
diff --git a/pyproject.toml b/pyproject.toml
index 54ac992..6833951 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "dhee"
-version = "7.1.0"
+version = "7.2.0"
description = "Dhee - world memory layer and context compiler for AI agents"
readme = "README.md"
requires-python = ">=3.9"
@@ -48,7 +48,7 @@ openai = ["openai>=1.0.0"]
ollama = ["ollama>=0.4.0"]
# Internal providers (not documented, still functional)
nvidia = ["openai>=1.0.0"]
-zvec = ["zvec>=0.4.0"]
+zvec = ["zvec>=0.4.0; python_version >= '3.11'"]
sqlite_vec = ["sqlite-vec>=0.1.1"]
graph = ["kuzu>=0.11.3"]
swe = [
@@ -80,6 +80,7 @@ all = [
"google-genai>=1.0.0",
"openai>=1.0.0",
"ollama>=0.4.0",
+ "zvec>=0.4.0; python_version >= '3.11'",
"mcp>=1.0.0; python_version >= '3.10'",
"fastapi>=0.100.0",
"uvicorn>=0.20.0",
diff --git a/tests/test_claude_code_hooks.py b/tests/test_claude_code_hooks.py
index 943dc61..79e5e4e 100644
--- a/tests/test_claude_code_hooks.py
+++ b/tests/test_claude_code_hooks.py
@@ -708,6 +708,27 @@ def test_user_prompt_empty_returns_empty(self):
assert handle_user_prompt({"prompt": ""}) == {}
assert handle_user_prompt({}) == {}
+ def test_user_prompt_noops_outside_dhee_init(self, tmp_path, monkeypatch):
+ from dhee.hooks.claude_code.__main__ import handle_user_prompt
+
+ monkeypatch.chdir(tmp_path)
+ with patch("dhee.hooks.claude_code.__main__._get_dhee") as mock_dhee:
+ assert handle_user_prompt({"prompt": "continue"}) == {}
+ mock_dhee.assert_not_called()
+
+ def test_post_tool_noops_outside_dhee_init(self, tmp_path, monkeypatch):
+ from dhee.hooks.claude_code.__main__ import handle_post_tool
+
+ monkeypatch.chdir(tmp_path)
+ with patch("dhee.hooks.claude_code.__main__._get_dhee") as mock_dhee:
+ result = handle_post_tool({
+ "tool_name": "Edit",
+ "tool_input": {"file_path": str(tmp_path / "x.py")},
+ "success": True,
+ })
+ assert result == {}
+ mock_dhee.assert_not_called()
+
def test_user_prompt_searches_doc_chunks(self):
"""v3.3.1: UserPromptSubmit does doc-chunk retrieval, not raw memory recall."""
from dhee.hooks.claude_code.__main__ import handle_user_prompt
diff --git a/tests/test_cli_onboard_update.py b/tests/test_cli_onboard_update.py
index c1400e8..f19f560 100644
--- a/tests/test_cli_onboard_update.py
+++ b/tests/test_cli_onboard_update.py
@@ -40,6 +40,8 @@ def test_onboard_provider_default_and_key_paste(tmp_path, monkeypatch):
# Onboard now points devs at `dhee init` (link + index + CLAUDE.md +
# first-light digest) rather than the lower-level `dhee link`.
assert "dhee init" in out
+ assert "workspace path:" not in out
+ assert "repo path:" not in out
def test_onboard_gemini_choice(tmp_path, monkeypatch):
diff --git a/tests/test_cli_pretty.py b/tests/test_cli_pretty.py
new file mode 100644
index 0000000..fe435bb
--- /dev/null
+++ b/tests/test_cli_pretty.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+import io
+
+
+def test_render_init_is_calm_ascii_and_aligned():
+ from dhee.cli_pretty import render_init
+
+ out = io.StringIO()
+ render_init(
+ {
+ "repo_root": "/tmp/project",
+ "kind": "folder",
+ "repo_id": "repo-1",
+ "hooks": [],
+ "claude_md": {"path": "/tmp/project/CLAUDE.md", "created": True},
+ "agents_md": {"path": "/tmp/project/AGENTS.md", "updated": True},
+ "ingest": {"status": "skipped", "reason": "skip_ingest"},
+ "first_light": {"status": "skipped", "reason": "skip_first_light", "hits": []},
+ "linked_repos": 2,
+ },
+ file=out,
+ color=False,
+ )
+
+ text = out.getvalue()
+ assert text.startswith("Dhee init\n")
+ assert "workspace /tmp/project" in text
+ assert "git hooks skip" in text
+ assert "CLAUDE.md created" in text
+ assert "AGENTS.md updated" in text
+ assert "Next\n" in text
+ assert "\x1b[" not in text
+
+
+def test_completion_scripts_include_init():
+ from dhee.cli import _bash_completion
+
+ text = _bash_completion(["init", "status"])
+
+ assert "complete -F _dhee_complete dhee" in text
+ assert "init status" in text
+
+
+def test_unknown_command_suggestion(capsys):
+ from dhee.cli import _maybe_print_command_suggestion, build_parser
+
+ assert _maybe_print_command_suggestion(build_parser(), ["statsu"]) is True
+
+ err = capsys.readouterr().err
+ assert "Unknown command: statsu" in err
+ assert "dhee status" in err
diff --git a/tests/test_dhee_init.py b/tests/test_dhee_init.py
index 5847dc1..11163d5 100644
--- a/tests/test_dhee_init.py
+++ b/tests/test_dhee_init.py
@@ -115,19 +115,27 @@ def test_init_creates_skeleton_and_hooks(self, isolated_home, stub_memory, git_r
assert "" in text
assert "Dhee — shared developer brain" in text
+ agents = git_repo / "AGENTS.md"
+ assert agents.is_file()
+ assert "This workspace has opted into Dhee" in agents.read_text(encoding="utf-8")
+
# ingest summary present
ingest = info["ingest"]
assert ingest["status"] == "ok"
assert ingest["files_pruned"] == 0
- def test_init_non_git_errors_friendly(self, isolated_home, stub_memory, tmp_path):
+ def test_init_plain_folder_is_supported(self, isolated_home, stub_memory, tmp_path):
from dhee import repo_link
plain_dir = tmp_path / "plain"
plain_dir.mkdir()
- with pytest.raises(ValueError, match="git"):
- repo_link.init(plain_dir)
+ info = repo_link.init(plain_dir, skip_first_light=True)
+
+ assert info["kind"] == "folder"
+ assert info["hooks"] == []
+ assert (plain_dir / ".dhee" / "config.json").is_file()
+ assert (plain_dir / "AGENTS.md").is_file()
def test_init_idempotent(self, isolated_home, stub_memory, git_repo):
"""Running init twice on the same repo is a clean no-op."""
diff --git a/tests/test_harness_install.py b/tests/test_harness_install.py
index 145df4f..0dba382 100644
--- a/tests/test_harness_install.py
+++ b/tests/test_harness_install.py
@@ -36,7 +36,8 @@ def test_install_codex_writes_native_config_and_instructions(tmp_path, monkeypat
assert 'approval_mode = "never"' not in config_text
assert agents_path.exists()
instructions = agents_path.read_text(encoding="utf-8")
- assert "primary memory, context-router" in instructions
+ assert "opt-in per workspace" in instructions
+ assert "/.dhee/config.json" in instructions
assert "Codex-native surfaces" in instructions
assert "Dhee syncs Codex session logs opportunistically" in instructions
assert "call `dhee_context_bootstrap`" in instructions
diff --git a/tests/test_memory_quality_contract.py b/tests/test_memory_quality_contract.py
index fdb079b..995c6b7 100644
--- a/tests/test_memory_quality_contract.py
+++ b/tests/test_memory_quality_contract.py
@@ -241,6 +241,44 @@ def test_canonical_recall_falls_back_to_db_when_vector_is_missing(tmp_path):
memory.close()
+def test_db_lexical_recall_is_merged_when_vector_index_is_sparse(tmp_path):
+ memory = Engram(provider="mock", in_memory=True, data_dir=str(tmp_path))
+ memory.add(
+ "Unrelated local NVIDIA router project note.",
+ user_id="default",
+ infer=False,
+ )
+ memory.memory.db.add_memory(
+ {
+ "id": "db-only-chotu-worker-lesson",
+ "memory": (
+ "Chotu worker lesson: if Kimi omits worker-result JSON, recover "
+ "the diff and run Chotu-owned verification before trusting it."
+ ),
+ "user_id": "default",
+ "metadata": {
+ "dhee_memory_class": "ordinary",
+ "memory_type": "semantic",
+ },
+ "namespace": "default",
+ "memory_type": "semantic",
+ "layer": "sml",
+ "strength": 1.0,
+ }
+ )
+
+ results = memory.memory.search(
+ "Kimi omits worker-result JSON recover diff Chotu-owned verification",
+ user_id="default",
+ limit=3,
+ )["results"]
+
+ assert results
+ assert results[0]["id"] == "db-only-chotu-worker-lesson"
+ assert results[0]["recall_explanation"]["matched_memory_id"] == "db-only-chotu-worker-lesson"
+ memory.close()
+
+
def test_repair_memory_quality_can_reindex_db_only_vectors(tmp_path):
memory = Engram(provider="mock", in_memory=True, data_dir=str(tmp_path))
memory_id = "legacy-vectorless-chotu-goal"
diff --git a/tests/test_narrative_scene_intelligence.py b/tests/test_narrative_scene_intelligence.py
index 9cb62b0..1b8c925 100644
--- a/tests/test_narrative_scene_intelligence.py
+++ b/tests/test_narrative_scene_intelligence.py
@@ -243,6 +243,52 @@ def test_scene_lifecycle_creates_cto_series_episode_events_and_card(tmp_path):
assert "raw_transcript" not in str(end["card"])
+def test_scene_event_rejects_unknown_scene_ids(tmp_path):
+ db = SQLiteManager(str(tmp_path / "event-guard.db"))
+ service = NarrativeSceneService(db)
+
+ result = service.scene_event(
+ scene_id="scene_budget_missing",
+ event_type="worker_result",
+ summary="This event should not be orphaned.",
+ )
+
+ assert result == {"error": "scene not found"}
+ with db._get_connection() as conn:
+ count = conn.execute("SELECT COUNT(*) FROM scene_events").fetchone()[0]
+ assert count == 0
+
+
+def test_scene_card_summary_prefers_story_over_evidence_labels(tmp_path):
+ db = SQLiteManager(str(tmp_path / "summary-quality.db"))
+ service = NarrativeSceneService(db)
+
+ start = service.scene_start(
+ user_id="default",
+ agent_id="codex",
+ agent_category="coding_agent",
+ source_app="codex",
+ namespace="repo:dhee",
+ query="Store proactive Chotu memory philosophy",
+ intent_type="memory_consolidation",
+ action_lane="planning",
+ categories=["chotu", "dhee", "memory_quality"],
+ )
+
+ end = service.scene_end(
+ scene_id=start["scene"]["id"],
+ outcome="Stored proactive memory philosophy.",
+ outcome_status="success",
+ story_progress_delta="Dhee now preserves Chotu's proactive-agent purpose.",
+ durable_facts=["Chotu uses Dhee memory to anticipate user needs."],
+ evidence=[{"type": "memory_id", "id": "mem-1"}],
+ )
+
+ assert end["card"]["summary"].startswith("Dhee now preserves Chotu's proactive-agent purpose.")
+ assert "Chotu uses Dhee memory to anticipate user needs." in end["card"]["summary"]
+ assert "scene evidence:" not in end["card"]["summary"][:80]
+
+
def test_scene_end_distills_episode_season_and_series_rollups_with_llm(tmp_path):
db = SQLiteManager(str(tmp_path / "llm-rollups.db"))
rollup_llm = FakeRollupLLM()
diff --git a/tests/test_packaging.py b/tests/test_packaging.py
index c9b8e65..ecb3a3c 100644
--- a/tests/test_packaging.py
+++ b/tests/test_packaging.py
@@ -62,7 +62,7 @@ def test_curl_installer_verifies_handoff_bus():
assert "Cross-agent handoff bus ready" in installer
assert "from dhee.core.kernel import _get_bus" in installer
assert "for bin_name in dhee dhee-mcp engram-bus" in installer
- assert 'DEFAULT_PACKAGE="dhee>=7.1.0"' in installer
+ assert 'DEFAULT_PACKAGE="dhee>=7.2.0"' in installer
assert "DHEE_INSTALL_PACKAGE" in installer
assert "FALLBACK_PACKAGE" in installer
assert "DHEE_INIT_REPO" in installer
diff --git a/tests/test_repo_link.py b/tests/test_repo_link.py
index e552a8e..a94eab6 100644
--- a/tests/test_repo_link.py
+++ b/tests/test_repo_link.py
@@ -86,13 +86,19 @@ def test_link_idempotent(self, isolated_home, git_repo):
# Skeleton and hooks should still be present
assert (git_repo / ".dhee" / "config.json").is_file()
- def test_link_outside_git_repo_raises(self, isolated_home, tmp_path):
+ def test_link_plain_folder_creates_skeleton_without_hooks(self, isolated_home, tmp_path):
from dhee import repo_link
- non_repo = tmp_path / "loose"
- non_repo.mkdir()
- with pytest.raises(ValueError):
- repo_link.link(non_repo)
+ folder = tmp_path / "loose"
+ folder.mkdir()
+ info = repo_link.link(folder)
+
+ assert info["kind"] == "folder"
+ assert info["hooks"] == []
+ assert (folder / ".dhee" / "config.json").is_file()
+ cfg = json.loads((folder / ".dhee" / "config.json").read_text())
+ assert cfg["kind"] == "folder"
+ assert cfg["folder_path"] == str(folder.resolve())
def test_link_registers_in_links_json(self, isolated_home, git_repo):
from dhee import repo_link
@@ -113,6 +119,29 @@ def test_link_mirrors_into_workspace_store(self, isolated_home, git_repo):
assert folders[str(git_repo.resolve())]["shared"] is True
assert folders[str(git_repo.resolve())]["linked"] is True
+ def test_link_git_url_clones_then_links(self, isolated_home, tmp_path, monkeypatch):
+ from dhee import repo_link
+
+ source = tmp_path / "source"
+ source.mkdir()
+ subprocess.run(["git", "init", "-q", str(source)], check=True)
+ subprocess.run(["git", "-C", str(source), "config", "user.email", "test@test"], check=True)
+ subprocess.run(["git", "-C", str(source), "config", "user.name", "test"], check=True)
+ (source / "README.md").write_text("hello\n", encoding="utf-8")
+ subprocess.run(["git", "-C", str(source), "add", "README.md"], check=True)
+ subprocess.run(["git", "-C", str(source), "commit", "-qm", "init"], check=True)
+
+ dest = tmp_path / "dest"
+ dest.mkdir()
+ monkeypatch.chdir(dest)
+ info = repo_link.link(f"file://{source}")
+
+ cloned = dest / "source"
+ assert info["cloned"] is True
+ assert info["kind"] == "git_repo"
+ assert Path(info["repo_root"]) == cloned.resolve()
+ assert (cloned / ".dhee" / "config.json").is_file()
+
def test_link_preserves_existing_user_hook(self, isolated_home, git_repo):
from dhee import repo_link
diff --git a/tests/test_router.py b/tests/test_router.py
index d4459a9..c4648e5 100644
--- a/tests/test_router.py
+++ b/tests/test_router.py
@@ -228,6 +228,9 @@ def test_offnoop_without_flag(self, router_tmp):
def _turn_on(self, router_tmp):
(router_tmp / "enforce").write_text("1\n")
+ cfg = router_tmp / ".dhee" / "config.json"
+ cfg.parent.mkdir(parents=True, exist_ok=True)
+ cfg.write_text(json.dumps({"repo_id": "test", "schema_version": 1}))
def test_on_allows_small_read(self, router_tmp, tmp_path):
self._turn_on(router_tmp)
@@ -246,6 +249,23 @@ def test_on_denies_large_read(self, router_tmp, tmp_path):
assert r.get("permissionDecision") == "deny"
assert "mcp__dhee__dhee_read" in r.get("additionalContext", "")
+ def test_on_noops_outside_initialized_workspace(self, router_tmp, tmp_path):
+ self._turn_on(router_tmp)
+ outside = tmp_path.parent / f"vanilla-{tmp_path.name}"
+ outside.mkdir()
+ big = outside / "big.py"
+ big.write_text("x" * (30 * 1024))
+
+ from dhee.router.pre_tool_gate import evaluate
+
+ r = evaluate({"tool_name": "Read", "tool_input": {"file_path": str(big)}})
+ assert r == {}
+ r = evaluate({
+ "tool_name": "Bash",
+ "tool_input": {"cwd": str(outside), "command": "git log --oneline"},
+ })
+ assert r == {}
+
def test_on_allows_ranged_read(self, router_tmp, tmp_path):
self._turn_on(router_tmp)
big = tmp_path / "big.py"