feat: enhance profile path resolution and add example profiles for better project adaptability
This commit is contained in:
@@ -180,6 +180,7 @@ Indexes live under `.aiw/indexes/` and are ignored because they are rebuildable
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/aiw/test_services.py
|
python3 scripts/aiw/test_services.py
|
||||||
|
python3 scripts/aiw/test_profile.py
|
||||||
python3 scripts/aiw/test_indexer.py
|
python3 scripts/aiw/test_indexer.py
|
||||||
python3 scripts/mcp/aiw-context-mcp/test_server.py
|
python3 scripts/mcp/aiw-context-mcp/test_server.py
|
||||||
python3 scripts/iphone-photo-inbox/test_receiver.py
|
python3 scripts/iphone-photo-inbox/test_receiver.py
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ AI clients and agent workflows
|
|||||||
|
|
||||||
## Current Repository Shape
|
## Current Repository Shape
|
||||||
|
|
||||||
The current repo still keeps the first real profile's vault at root-level `project-knowledge/`. That is acceptable during migration, but reusable code should increasingly resolve paths from profile configuration rather than hardcoding Fidelity-specific locations.
|
The current repo still keeps the first real profile's vault at root-level `project-knowledge/`. That is acceptable during migration, but reusable code should resolve paths from `profiles/<profile>/workspace.json` rather than hardcoding Fidelity-specific locations.
|
||||||
|
|
||||||
Target direction:
|
Target direction:
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ Human-readable summary for agents and developers:
|
|||||||
|
|
||||||
### `workspace.json`
|
### `workspace.json`
|
||||||
|
|
||||||
Planned path configuration. Initial versions can point to current paths:
|
Profile path configuration. Initial versions can point to current paths:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -58,6 +58,8 @@ Planned path configuration. Initial versions can point to current paths:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Reusable scripts should resolve these paths through `scripts/aiw/profile.py`.
|
||||||
|
|
||||||
### `services.json`
|
### `services.json`
|
||||||
|
|
||||||
Profile-specific local service manifest for `scripts/aiw/services.py`.
|
Profile-specific local service manifest for `scripts/aiw/services.py`.
|
||||||
|
|||||||
8
profiles/example/workspace.json
Normal file
8
profiles/example/workspace.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"profile": "example",
|
||||||
|
"display_name": "Example Project",
|
||||||
|
"description": "Sanitized example profile for adapting AI Workspace to a new project.",
|
||||||
|
"knowledge_dir": "workspaces/example/project-knowledge",
|
||||||
|
"inbox_dir": "workspaces/example/inbox",
|
||||||
|
"index_dir": ".aiw/indexes/example"
|
||||||
|
}
|
||||||
8
profiles/fidelity/workspace.json
Normal file
8
profiles/fidelity/workspace.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"profile": "fidelity",
|
||||||
|
"display_name": "Fidelity",
|
||||||
|
"description": "Current Fidelity AI Workspace profile. Paths intentionally point to the existing root-level vault and inbox until the data migration phase.",
|
||||||
|
"knowledge_dir": "project-knowledge",
|
||||||
|
"inbox_dir": "ai/inbox",
|
||||||
|
"index_dir": ".aiw/indexes/fidelity"
|
||||||
|
}
|
||||||
@@ -44,6 +44,26 @@ python3 scripts/aiw/indexer.py search "dismissal lifecycle" --profile fidelity
|
|||||||
|
|
||||||
`aiw-context-mcp` exposes the same derived search through the read-only `memory_hybrid_search` tool and falls back to live Markdown search if the index has not been built yet.
|
`aiw-context-mcp` exposes the same derived search through the read-only `memory_hybrid_search` tool and falls back to live Markdown search if the index has not been built yet.
|
||||||
|
|
||||||
|
## Profile path configuration
|
||||||
|
|
||||||
|
Reusable scripts resolve profile-specific paths through:
|
||||||
|
|
||||||
|
```text
|
||||||
|
profiles/<profile>/workspace.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Current fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"knowledge_dir": "project-knowledge",
|
||||||
|
"inbox_dir": "ai/inbox",
|
||||||
|
"index_dir": ".aiw/indexes/fidelity"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `scripts/aiw/profile.py` from new scripts instead of hardcoding root-level `project-knowledge/` or `ai/inbox/` paths.
|
||||||
|
|
||||||
## Robustness features
|
## Robustness features
|
||||||
|
|
||||||
- Manifest validation before lifecycle actions.
|
- Manifest validation before lifecycle actions.
|
||||||
@@ -59,5 +79,6 @@ python3 scripts/aiw/indexer.py search "dismissal lifecycle" --profile fidelity
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 scripts/aiw/test_services.py
|
python3 scripts/aiw/test_services.py
|
||||||
|
python3 scripts/aiw/test_profile.py
|
||||||
python3 scripts/aiw/test_indexer.py
|
python3 scripts/aiw/test_indexer.py
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import argparse
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -20,11 +21,13 @@ from typing import Any
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[2]
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
INDEX_ROOT = ROOT / ".aiw" / "indexes"
|
|
||||||
DEFAULT_PROFILE = "fidelity"
|
DEFAULT_PROFILE = "fidelity"
|
||||||
MAX_CHARS = 1800
|
MAX_CHARS = 1800
|
||||||
OVERLAP_CHARS = 180
|
OVERLAP_CHARS = 180
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
import profile as aiw_profile # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Chunk:
|
class Chunk:
|
||||||
@@ -37,15 +40,15 @@ class Chunk:
|
|||||||
|
|
||||||
|
|
||||||
def project_knowledge_dir(profile: str) -> Path:
|
def project_knowledge_dir(profile: str) -> Path:
|
||||||
profile_base = ROOT / "profiles" / profile
|
return aiw_profile.knowledge_dir(profile, root=ROOT)
|
||||||
candidate = profile_base / "project-knowledge"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return ROOT / "project-knowledge"
|
|
||||||
|
|
||||||
|
|
||||||
def index_dir(profile: str) -> Path:
|
def index_dir(profile: str) -> Path:
|
||||||
return INDEX_ROOT / profile
|
return aiw_profile.index_dir(profile, root=ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
def rel(path: Path) -> str:
|
||||||
|
return aiw_profile.relative_to_root(path, root=ROOT)
|
||||||
|
|
||||||
|
|
||||||
def index_path(profile: str) -> Path:
|
def index_path(profile: str) -> Path:
|
||||||
@@ -120,13 +123,13 @@ def build_chunks(profile: str) -> list[Chunk]:
|
|||||||
chunks: list[Chunk] = []
|
chunks: list[Chunk] = []
|
||||||
for path in iter_markdown_files(base):
|
for path in iter_markdown_files(base):
|
||||||
raw = path.read_text(encoding="utf-8", errors="replace")
|
raw = path.read_text(encoding="utf-8", errors="replace")
|
||||||
rel = str(path.relative_to(ROOT))
|
rel_path = rel(path)
|
||||||
digest = hashlib.sha256(raw.encode("utf-8", errors="replace")).hexdigest()
|
digest = hashlib.sha256(raw.encode("utf-8", errors="replace")).hexdigest()
|
||||||
mtime = path.stat().st_mtime
|
mtime = path.stat().st_mtime
|
||||||
for section_index, (heading, section) in enumerate(split_sections(raw)):
|
for section_index, (heading, section) in enumerate(split_sections(raw)):
|
||||||
for chunk_index, chunk in enumerate(chunk_text(section)):
|
for chunk_index, chunk in enumerate(chunk_text(section)):
|
||||||
chunk_digest = hashlib.sha256(f"{rel}\n{section_index}\n{chunk_index}\n{chunk}".encode("utf-8")).hexdigest()[:16]
|
chunk_digest = hashlib.sha256(f"{rel_path}\n{section_index}\n{chunk_index}\n{chunk}".encode("utf-8")).hexdigest()[:16]
|
||||||
chunks.append(Chunk(chunk_id=chunk_digest, path=rel, heading=heading, text=chunk, mtime=mtime, sha256=digest))
|
chunks.append(Chunk(chunk_id=chunk_digest, path=rel_path, heading=heading, text=chunk, mtime=mtime, sha256=digest))
|
||||||
return chunks
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
@@ -140,14 +143,14 @@ def write_index(profile: str) -> dict[str, Any]:
|
|||||||
files = sorted({chunk.path for chunk in chunks})
|
files = sorted({chunk.path for chunk in chunks})
|
||||||
manifest = {
|
manifest = {
|
||||||
"profile": profile,
|
"profile": profile,
|
||||||
"source": str(project_knowledge_dir(profile).relative_to(ROOT)),
|
"source": rel(project_knowledge_dir(profile)),
|
||||||
"canonical": False,
|
"canonical": False,
|
||||||
"derived_from": "project-knowledge",
|
"derived_from": "project-knowledge",
|
||||||
"index_type": "lexical-markdown-chunks",
|
"index_type": "lexical-markdown-chunks",
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"file_count": len(files),
|
"file_count": len(files),
|
||||||
"chunk_count": len(chunks),
|
"chunk_count": len(chunks),
|
||||||
"index_path": str(index_path(profile).relative_to(ROOT)),
|
"index_path": rel(index_path(profile)),
|
||||||
}
|
}
|
||||||
manifest_path(profile).write_text(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
manifest_path(profile).write_text(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
return manifest
|
return manifest
|
||||||
@@ -225,7 +228,7 @@ def search_index(profile: str, query: str, limit: int = 10) -> dict[str, Any]:
|
|||||||
def status(profile: str) -> dict[str, Any]:
|
def status(profile: str) -> dict[str, Any]:
|
||||||
manifest_file = manifest_path(profile)
|
manifest_file = manifest_path(profile)
|
||||||
if not manifest_file.is_file():
|
if not manifest_file.is_file():
|
||||||
return {"profile": profile, "indexed": False, "index_path": str(index_path(profile).relative_to(ROOT))}
|
return {"profile": profile, "indexed": False, "index_path": rel(index_path(profile))}
|
||||||
manifest = json.loads(manifest_file.read_text(encoding="utf-8"))
|
manifest = json.loads(manifest_file.read_text(encoding="utf-8"))
|
||||||
path = index_path(profile)
|
path = index_path(profile)
|
||||||
manifest["indexed"] = path.is_file()
|
manifest["indexed"] = path.is_file()
|
||||||
|
|||||||
72
scripts/aiw/profile.py
Normal file
72
scripts/aiw/profile.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Profile path resolution for AI Workspace scripts.
|
||||||
|
|
||||||
|
Profiles own their configuration. Reusable scripts should call this module
|
||||||
|
instead of hardcoding root-level project paths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_WORKSPACE = {
|
||||||
|
"knowledge_dir": "project-knowledge",
|
||||||
|
"inbox_dir": "ai/inbox",
|
||||||
|
"index_dir": ".aiw/indexes/{profile}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def workspace_config_path(profile: str, root: Path | None = None) -> Path:
|
||||||
|
base = root or ROOT
|
||||||
|
return base / "profiles" / profile / "workspace.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_workspace_config(profile: str, root: Path | None = None) -> dict[str, Any]:
|
||||||
|
base = root or ROOT
|
||||||
|
config = dict(DEFAULT_WORKSPACE)
|
||||||
|
config["profile"] = profile
|
||||||
|
path = workspace_config_path(profile, root=base)
|
||||||
|
if path.is_file():
|
||||||
|
try:
|
||||||
|
loaded = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(loaded, dict):
|
||||||
|
config.update(loaded)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_path(raw: str | None, *, profile: str, root: Path | None = None, fallback: str) -> Path:
|
||||||
|
base = root or ROOT
|
||||||
|
value = (raw or fallback).format(profile=profile)
|
||||||
|
path = Path(value).expanduser()
|
||||||
|
return path if path.is_absolute() else base / path
|
||||||
|
|
||||||
|
|
||||||
|
def knowledge_dir(profile: str, root: Path | None = None) -> Path:
|
||||||
|
config = load_workspace_config(profile, root=root)
|
||||||
|
return resolve_path(config.get("knowledge_dir"), profile=profile, root=root, fallback="project-knowledge")
|
||||||
|
|
||||||
|
|
||||||
|
def inbox_dir(profile: str, root: Path | None = None) -> Path:
|
||||||
|
config = load_workspace_config(profile, root=root)
|
||||||
|
return resolve_path(config.get("inbox_dir"), profile=profile, root=root, fallback="ai/inbox")
|
||||||
|
|
||||||
|
|
||||||
|
def index_dir(profile: str, root: Path | None = None) -> Path:
|
||||||
|
config = load_workspace_config(profile, root=root)
|
||||||
|
return resolve_path(config.get("index_dir"), profile=profile, root=root, fallback=".aiw/indexes/{profile}")
|
||||||
|
|
||||||
|
|
||||||
|
def relative_to_root(path: Path, root: Path | None = None) -> str:
|
||||||
|
base = root or ROOT
|
||||||
|
try:
|
||||||
|
return str(path.relative_to(base))
|
||||||
|
except ValueError:
|
||||||
|
return str(path)
|
||||||
@@ -30,7 +30,7 @@ class IndexerTests(unittest.TestCase):
|
|||||||
real.write_text("# XFlow\nDismissal lifecycle context", encoding="utf-8")
|
real.write_text("# XFlow\nDismissal lifecycle context", encoding="utf-8")
|
||||||
template.write_text("# XFlow\nTemplate-only text", encoding="utf-8")
|
template.write_text("# XFlow\nTemplate-only text", encoding="utf-8")
|
||||||
|
|
||||||
with patch.object(indexer, "ROOT", root), patch.object(indexer, "INDEX_ROOT", root / ".aiw" / "indexes"):
|
with patch.object(indexer, "ROOT", root):
|
||||||
manifest = indexer.write_index("fidelity")
|
manifest = indexer.write_index("fidelity")
|
||||||
result = indexer.search_index("fidelity", "dismissal lifecycle", limit=5)
|
result = indexer.search_index("fidelity", "dismissal lifecycle", limit=5)
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ class IndexerTests(unittest.TestCase):
|
|||||||
def test_status_reports_unindexed_profile(self) -> None:
|
def test_status_reports_unindexed_profile(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = Path(tmp)
|
root = Path(tmp)
|
||||||
with patch.object(indexer, "ROOT", root), patch.object(indexer, "INDEX_ROOT", root / ".aiw" / "indexes"):
|
with patch.object(indexer, "ROOT", root):
|
||||||
result = indexer.status("fidelity")
|
result = indexer.status("fidelity")
|
||||||
|
|
||||||
self.assertFalse(result["indexed"])
|
self.assertFalse(result["indexed"])
|
||||||
@@ -51,6 +51,26 @@ class IndexerTests(unittest.TestCase):
|
|||||||
payload = {"matches": [{"path": "project-knowledge/01-current/current-work.md", "score": 1.0}]}
|
payload = {"matches": [{"path": "project-knowledge/01-current/current-work.md", "score": 1.0}]}
|
||||||
self.assertIsInstance(json.dumps(payload), str)
|
self.assertIsInstance(json.dumps(payload), str)
|
||||||
|
|
||||||
|
def test_build_uses_workspace_json_paths(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
config = root / "profiles" / "demo" / "workspace.json"
|
||||||
|
real = root / "workspaces" / "demo" / "project-knowledge" / "03-context" / "project.md"
|
||||||
|
config.parent.mkdir(parents=True)
|
||||||
|
real.parent.mkdir(parents=True)
|
||||||
|
config.write_text(json.dumps({
|
||||||
|
"knowledge_dir": "workspaces/demo/project-knowledge",
|
||||||
|
"index_dir": ".aiw/indexes/demo",
|
||||||
|
}), encoding="utf-8")
|
||||||
|
real.write_text("# Demo\nReusable profile memory", encoding="utf-8")
|
||||||
|
|
||||||
|
with patch.object(indexer, "ROOT", root):
|
||||||
|
manifest = indexer.write_index("demo")
|
||||||
|
result = indexer.search_index("demo", "profile memory", limit=5)
|
||||||
|
|
||||||
|
self.assertEqual(manifest["source"], "workspaces/demo/project-knowledge")
|
||||||
|
self.assertEqual(result["matches"][0]["path"], "workspaces/demo/project-knowledge/03-context/project.md")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
47
scripts/aiw/test_profile.py
Normal file
47
scripts/aiw/test_profile.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE_PATH = Path(__file__).with_name("profile.py")
|
||||||
|
SPEC = importlib.util.spec_from_file_location("aiw_profile", PROFILE_PATH)
|
||||||
|
profile = importlib.util.module_from_spec(SPEC)
|
||||||
|
assert SPEC.loader is not None
|
||||||
|
sys.modules[SPEC.name] = profile
|
||||||
|
SPEC.loader.exec_module(profile)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileTests(unittest.TestCase):
|
||||||
|
def test_workspace_config_resolves_profile_paths(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
config = root / "profiles" / "demo" / "workspace.json"
|
||||||
|
config.parent.mkdir(parents=True)
|
||||||
|
config.write_text(json.dumps({
|
||||||
|
"knowledge_dir": "workspaces/demo/project-knowledge",
|
||||||
|
"inbox_dir": "workspaces/demo/inbox",
|
||||||
|
"index_dir": ".aiw/indexes/demo",
|
||||||
|
}), encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertEqual(profile.knowledge_dir("demo", root=root), root / "workspaces" / "demo" / "project-knowledge")
|
||||||
|
self.assertEqual(profile.inbox_dir("demo", root=root), root / "workspaces" / "demo" / "inbox")
|
||||||
|
self.assertEqual(profile.index_dir("demo", root=root), root / ".aiw" / "indexes" / "demo")
|
||||||
|
|
||||||
|
def test_defaults_preserve_current_root_paths(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
|
||||||
|
self.assertEqual(profile.knowledge_dir("missing", root=root), root / "project-knowledge")
|
||||||
|
self.assertEqual(profile.inbox_dir("missing", root=root), root / "ai" / "inbox")
|
||||||
|
self.assertEqual(profile.index_dir("missing", root=root), root / ".aiw" / "indexes" / "missing")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -27,7 +27,9 @@ PROTOCOL_VERSION = "2025-06-18"
|
|||||||
SERVER_NAME = "aiw-context-mcp"
|
SERVER_NAME = "aiw-context-mcp"
|
||||||
SERVER_VERSION = "0.1.0"
|
SERVER_VERSION = "0.1.0"
|
||||||
LOCAL_ENV = ROOT / "scripts" / "mattermost-proxy" / ".env"
|
LOCAL_ENV = ROOT / "scripts" / "mattermost-proxy" / ".env"
|
||||||
INDEX_ROOT = ROOT / ".aiw" / "indexes"
|
AIW_SCRIPT_DIR = ROOT / "scripts" / "aiw"
|
||||||
|
sys.path.insert(0, str(AIW_SCRIPT_DIR))
|
||||||
|
import profile as aiw_profile # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
def load_local_env(path: Path = LOCAL_ENV) -> None:
|
def load_local_env(path: Path = LOCAL_ENV) -> None:
|
||||||
@@ -47,22 +49,20 @@ def load_local_env(path: Path = LOCAL_ENV) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def profile_dir(profile: str) -> Path:
|
def profile_dir(profile: str) -> Path:
|
||||||
if profile == "fidelity":
|
|
||||||
return ROOT
|
|
||||||
candidate = ROOT / "profiles" / profile
|
candidate = ROOT / "profiles" / profile
|
||||||
return candidate if candidate.exists() else ROOT
|
return candidate if candidate.exists() else ROOT
|
||||||
|
|
||||||
|
|
||||||
def knowledge_dir(profile: str) -> Path:
|
def knowledge_dir(profile: str) -> Path:
|
||||||
base = profile_dir(profile)
|
return aiw_profile.knowledge_dir(profile, root=ROOT)
|
||||||
candidate = base / "project-knowledge"
|
|
||||||
return candidate if candidate.exists() else ROOT / "project-knowledge"
|
|
||||||
|
|
||||||
|
|
||||||
def inbox_dir(profile: str) -> Path:
|
def inbox_dir(profile: str) -> Path:
|
||||||
base = profile_dir(profile)
|
return aiw_profile.inbox_dir(profile, root=ROOT)
|
||||||
candidate = base / "ai" / "inbox"
|
|
||||||
return candidate if candidate.exists() else ROOT / "ai" / "inbox"
|
|
||||||
|
def rel(path: Path) -> str:
|
||||||
|
return aiw_profile.relative_to_root(path, root=ROOT)
|
||||||
|
|
||||||
|
|
||||||
def mattermost_mirror_dir(profile: str) -> Path:
|
def mattermost_mirror_dir(profile: str) -> Path:
|
||||||
@@ -257,7 +257,7 @@ def project_current_context(args: dict[str, Any]) -> dict[str, Any]:
|
|||||||
result = []
|
result = []
|
||||||
for path in files:
|
for path in files:
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
result.append({"path": str(path.relative_to(ROOT)), "text": path.read_text(encoding="utf-8", errors="replace")})
|
result.append({"path": rel(path), "text": path.read_text(encoding="utf-8", errors="replace")})
|
||||||
return tool_result({"profile": profile, "canonical": True, "files": result})
|
return tool_result({"profile": profile, "canonical": True, "files": result})
|
||||||
|
|
||||||
|
|
||||||
@@ -270,8 +270,8 @@ def project_search_memory(args: dict[str, Any]) -> dict[str, Any]:
|
|||||||
base = knowledge_dir(profile)
|
base = knowledge_dir(profile)
|
||||||
matches: list[dict[str, Any]] = []
|
matches: list[dict[str, Any]] = []
|
||||||
for path in sorted(base.rglob("*.md")):
|
for path in sorted(base.rglob("*.md")):
|
||||||
rel = path.relative_to(base)
|
relative_to_base = path.relative_to(base)
|
||||||
if str(rel).startswith("09-templates/"):
|
if str(relative_to_base).startswith("09-templates/"):
|
||||||
continue
|
continue
|
||||||
text = path.read_text(encoding="utf-8", errors="replace")
|
text = path.read_text(encoding="utf-8", errors="replace")
|
||||||
lowered = text.lower()
|
lowered = text.lower()
|
||||||
@@ -280,18 +280,18 @@ def project_search_memory(args: dict[str, Any]) -> dict[str, Any]:
|
|||||||
continue
|
continue
|
||||||
start = max(0, index - 220)
|
start = max(0, index - 220)
|
||||||
end = min(len(text), index + len(query) + 220)
|
end = min(len(text), index + len(query) + 220)
|
||||||
matches.append({"path": str(path.relative_to(ROOT)), "snippet": text[start:end].strip()})
|
matches.append({"path": rel(path), "snippet": text[start:end].strip()})
|
||||||
if len(matches) >= limit:
|
if len(matches) >= limit:
|
||||||
break
|
break
|
||||||
return tool_result({"profile": profile, "canonical": True, "query": query, "matches": matches})
|
return tool_result({"profile": profile, "canonical": True, "query": query, "matches": matches})
|
||||||
|
|
||||||
|
|
||||||
def index_path(profile: str) -> Path:
|
def index_path(profile: str) -> Path:
|
||||||
return INDEX_ROOT / profile / "project-knowledge.jsonl"
|
return aiw_profile.index_dir(profile, root=ROOT) / "project-knowledge.jsonl"
|
||||||
|
|
||||||
|
|
||||||
def index_manifest_path(profile: str) -> Path:
|
def index_manifest_path(profile: str) -> Path:
|
||||||
return INDEX_ROOT / profile / "manifest.json"
|
return aiw_profile.index_dir(profile, root=ROOT) / "manifest.json"
|
||||||
|
|
||||||
|
|
||||||
def search_tokens(text: str) -> set[str]:
|
def search_tokens(text: str) -> set[str]:
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ class ContextMCPTests(unittest.TestCase):
|
|||||||
}) + "\n", encoding="utf-8")
|
}) + "\n", encoding="utf-8")
|
||||||
manifest.write_text(json.dumps({"chunk_count": 1}), encoding="utf-8")
|
manifest.write_text(json.dumps({"chunk_count": 1}), encoding="utf-8")
|
||||||
|
|
||||||
with patch.object(server, "ROOT", root), patch.object(server, "INDEX_ROOT", root / ".aiw" / "indexes"):
|
with patch.object(server, "ROOT", root):
|
||||||
result = server.memory_hybrid_search({"profile": "fidelity", "query": "dismissal lifecycle"})["structuredContent"]
|
result = server.memory_hybrid_search({"profile": "fidelity", "query": "dismissal lifecycle"})["structuredContent"]
|
||||||
|
|
||||||
self.assertTrue(result["index_available"])
|
self.assertTrue(result["index_available"])
|
||||||
@@ -189,13 +189,31 @@ class ContextMCPTests(unittest.TestCase):
|
|||||||
real.parent.mkdir(parents=True)
|
real.parent.mkdir(parents=True)
|
||||||
real.write_text("Important XFlow context", encoding="utf-8")
|
real.write_text("Important XFlow context", encoding="utf-8")
|
||||||
|
|
||||||
with patch.object(server, "ROOT", root), patch.object(server, "INDEX_ROOT", root / ".aiw" / "indexes"):
|
with patch.object(server, "ROOT", root):
|
||||||
result = server.memory_hybrid_search({"profile": "fidelity", "query": "XFlow"})["structuredContent"]
|
result = server.memory_hybrid_search({"profile": "fidelity", "query": "XFlow"})["structuredContent"]
|
||||||
|
|
||||||
self.assertFalse(result["index_available"])
|
self.assertFalse(result["index_available"])
|
||||||
self.assertEqual(result["source"], "live-project-knowledge-fallback")
|
self.assertEqual(result["source"], "live-project-knowledge-fallback")
|
||||||
self.assertEqual(len(result["matches"]), 1)
|
self.assertEqual(len(result["matches"]), 1)
|
||||||
|
|
||||||
|
def test_project_context_uses_workspace_json_paths(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
config = root / "profiles" / "demo" / "workspace.json"
|
||||||
|
current = root / "workspaces" / "demo" / "project-knowledge" / "01-current" / "current-work.md"
|
||||||
|
work_items = root / "workspaces" / "demo" / "project-knowledge" / "01-current" / "work-items.md"
|
||||||
|
config.parent.mkdir(parents=True)
|
||||||
|
current.parent.mkdir(parents=True)
|
||||||
|
config.write_text(json.dumps({"knowledge_dir": "workspaces/demo/project-knowledge"}), encoding="utf-8")
|
||||||
|
current.write_text("# Current\nDemo current work", encoding="utf-8")
|
||||||
|
work_items.write_text("# Work Items", encoding="utf-8")
|
||||||
|
|
||||||
|
with patch.object(server, "ROOT", root):
|
||||||
|
result = server.project_current_context({"profile": "demo"})["structuredContent"]
|
||||||
|
|
||||||
|
self.assertEqual(result["files"][0]["path"], "workspaces/demo/project-knowledge/01-current/current-work.md")
|
||||||
|
self.assertIn("Demo current work", result["files"][0]["text"])
|
||||||
|
|
||||||
def test_previous_workday_skips_weekend(self) -> None:
|
def test_previous_workday_skips_weekend(self) -> None:
|
||||||
monday = date(2026, 5, 18)
|
monday = date(2026, 5, 18)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user