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 ``` +

+ Dhee curl install, provider setup, dhee init, status, and shell completion demo +

+ 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"