Compare commits

..

2 Commits

27 changed files with 331 additions and 65 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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`.

View 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"
}

View 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"
}

View File

@@ -41,7 +41,7 @@ bash scripts/memory/memory.sh search "PDIAP-15765"
bash scripts/memory/memory.sh create work-item pdiap-15999 "Example title" bash scripts/memory/memory.sh create work-item pdiap-15999 "Example title"
``` ```
This interface defaults to Markdown files under `project-knowledge/`, uses Obsidian CLI when useful and available, and falls back to direct file operations. This interface resolves the canonical Markdown directory from `profiles/<profile>/workspace.json`, uses Obsidian CLI when useful and available, and falls back to direct file operations.
The default workspace Mattermost extractor now lives in: The default workspace Mattermost extractor now lives in:
@@ -79,7 +79,7 @@ Expected behavior:
- avoid interactive prompts - avoid interactive prompts
- return a non-zero exit code on failure - return a non-zero exit code on failure
OpenCode can then use that output to refresh `ai/inbox/mattermost-latest.md` proactively. When the local Mattermost proxy mirror is running, commands should prefer `ai/inbox/mattermost-mirror/` through `scripts/mattermost-proxy/read-context.py` and use legacy sync output as fallback evidence. OpenCode can then use that output to refresh the active profile inbox proactively. When the local Mattermost proxy mirror is running, commands should prefer `<profile inbox>/mattermost-mirror/` through `scripts/mattermost-proxy/read-context.py --profile <profile>` and use legacy sync output only as fallback evidence.
Historical Slack exports can also be imported through: Historical Slack exports can also be imported through:

View File

@@ -34,7 +34,7 @@ The service manager unifies startup and status. It does not move capture behavio
## Local project-knowledge index ## Local project-knowledge index
The workspace includes a dependency-free local indexer for canonical Markdown memory. The index is derived from `project-knowledge/` and written under `.aiw/indexes/<profile>/`; it is safe to delete and rebuild. The workspace includes a dependency-free local indexer for canonical Markdown memory. The index is derived from the profile's configured `knowledge_dir` and written under the profile's configured `index_dir`; it is safe to delete and rebuild.
```bash ```bash
python3 scripts/aiw/indexer.py build --profile fidelity python3 scripts/aiw/indexer.py build --profile 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 memory or 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
``` ```

View File

@@ -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()

107
scripts/aiw/profile.py Normal file
View File

@@ -0,0 +1,107 @@
#!/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
import argparse
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)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
path_parser = subparsers.add_parser("path", help="Print a resolved profile path.")
path_parser.add_argument("kind", choices=["knowledge", "inbox", "index"])
path_parser.add_argument("--profile", default="fidelity")
config_parser = subparsers.add_parser("config", help="Print resolved workspace configuration as JSON.")
config_parser.add_argument("--profile", default="fidelity")
args = parser.parse_args()
if args.command == "path":
if args.kind == "knowledge":
print(knowledge_dir(args.profile))
elif args.kind == "inbox":
print(inbox_dir(args.profile))
else:
print(index_dir(args.profile))
return
config = load_workspace_config(args.profile)
config["resolved"] = {
"knowledge_dir": str(knowledge_dir(args.profile)),
"inbox_dir": str(inbox_dir(args.profile)),
"index_dir": str(index_dir(args.profile)),
}
print(json.dumps(config, ensure_ascii=False, indent=2, sort_keys=True))
if __name__ == "__main__":
main()

View File

@@ -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()

View File

@@ -0,0 +1,53 @@
#!/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")
def test_relative_to_root_handles_external_paths(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
self.assertEqual(profile.relative_to_root(root / "a" / "b", root=root), "a/b")
self.assertEqual(profile.relative_to_root(Path("/external/path"), root=root), "/external/path")
if __name__ == "__main__":
unittest.main()

View File

@@ -152,17 +152,18 @@ Keep this utility as an isolated image mailbox. If a project wants easy access,
link the project inbox to the mailbox instead of making this utility know about link the project inbox to the mailbox instead of making this utility know about
the project. the project.
Example: Example using the active profile inbox:
```bash ```bash
mkdir -p ai/inbox PROFILE_INBOX="$(python3 scripts/aiw/profile.py path inbox --profile fidelity)"
ln -s "$HOME/Pictures/Photo Inbox" ai/inbox/photos mkdir -p "$PROFILE_INBOX"
ln -s "$HOME/Pictures/Photo Inbox" "$PROFILE_INBOX/photos"
``` ```
Or point the receiver at a project-owned folder from `.env`: Or point the receiver at a project-owned folder from `.env`:
```bash ```bash
PHOTO_INBOX_DIR=/absolute/path/to/project/ai/inbox/photos PHOTO_INBOX_DIR=/absolute/path/to/profile/inbox/photos
``` ```
The symlink approach keeps this utility reusable across projects and devices. The symlink approach keeps this utility reusable across projects and devices.

View File

@@ -7,7 +7,9 @@
MATTERMOST_MIRROR_HOST_ALLOW= MATTERMOST_MIRROR_HOST_ALLOW=
# Output directory for raw evidence and normalized AI-readable context. # Output directory for raw evidence and normalized AI-readable context.
MATTERMOST_MIRROR_DIR=ai/inbox/mattermost-mirror # Optional. If omitted, run-mirror.sh writes to the active profile inbox:
# <inbox_dir from profiles/<profile>/workspace.json>/mattermost-mirror
# MATTERMOST_MIRROR_DIR=/absolute/path/to/mattermost-mirror
# mitmproxy listener used by launch-mattermost.sh. # mitmproxy listener used by launch-mattermost.sh.
MATTERMOST_MIRROR_LISTEN_HOST=127.0.0.1 MATTERMOST_MIRROR_LISTEN_HOST=127.0.0.1

View File

@@ -2,7 +2,7 @@
Local read-only Mattermost Desktop mirror for AI workspace context. Local read-only Mattermost Desktop mirror for AI workspace context.
This is for **raw evidence only**. It writes under `ai/inbox/mattermost-mirror/`; durable project memory still belongs in `project-knowledge/` after normal promotion rules. This is for **raw evidence only**. By default it writes under the active profile inbox at `<inbox_dir>/mattermost-mirror/`; durable project memory still belongs in the profile's canonical project knowledge vault after normal promotion rules.
## Why this exists ## Why this exists
@@ -48,7 +48,7 @@ missing.
## Output layout ## Output layout
```text ```text
ai/inbox/mattermost-mirror/ <profile inbox>/mattermost-mirror/
latest.jsonl # bounded AI-readable window latest.jsonl # bounded AI-readable window
latest.md # bounded Markdown view latest.md # bounded Markdown view
state.json # last seen by channel and user cache state.json # last seen by channel and user cache
@@ -138,7 +138,7 @@ Each line in the normalized JSONL contains:
## Useful environment variables ## Useful environment variables
- `MATTERMOST_MIRROR_HOST_ALLOW`: exact host or parent domain to capture. - `MATTERMOST_MIRROR_HOST_ALLOW`: exact host or parent domain to capture.
- `MATTERMOST_MIRROR_DIR`: output directory, default `ai/inbox/mattermost-mirror`. - `MATTERMOST_MIRROR_DIR`: optional output directory. If omitted, `run-mirror.sh` uses `<inbox_dir from profiles/<profile>/workspace.json>/mattermost-mirror`.
- `MATTERMOST_MIRROR_LATEST_LIMIT`: number of messages in `latest.*`, default `200`. - `MATTERMOST_MIRROR_LATEST_LIMIT`: number of messages in `latest.*`, default `200`.
- `MATTERMOST_MIRROR_CHANNEL_IDS`: optional comma-separated channel ID allowlist. - `MATTERMOST_MIRROR_CHANNEL_IDS`: optional comma-separated channel ID allowlist.
- `MATTERMOST_MIRROR_WRITE_RAW`: set to `1` to save compact raw REST/WebSocket evidence. - `MATTERMOST_MIRROR_WRITE_RAW`: set to `1` to save compact raw REST/WebSocket evidence.
@@ -170,7 +170,7 @@ do not create message files. Open a channel, open a thread, scroll slightly in
history, or wait for/send a new message. Then check: history, or wait for/send a new message. Then check:
```text ```text
ai/inbox/mattermost-mirror/latest.md <profile inbox>/mattermost-mirror/latest.md
ai/inbox/mattermost-mirror/channels/<channel-name>/YYYY/MM/YYYY-MM-DD.jsonl <profile inbox>/mattermost-mirror/channels/<channel-name>/YYYY/MM/YYYY-MM-DD.jsonl
ai/inbox/mattermost-mirror/by-date/YYYY/MM/YYYY-MM-DD.jsonl <profile inbox>/mattermost-mirror/by-date/YYYY/MM/YYYY-MM-DD.jsonl
``` ```

View File

@@ -6,7 +6,7 @@ This addon is intentionally narrow:
- redact secrets - redact secrets
- normalize posts into date-rotated JSONL files for AI context - normalize posts into date-rotated JSONL files for AI context
The output under ai/inbox/ is raw evidence, not canonical project memory. The output under the profile inbox is raw evidence, not canonical project memory.
""" """
from __future__ import annotations from __future__ import annotations
@@ -23,7 +23,7 @@ from urllib.parse import urlparse
from mitmproxy import http from mitmproxy import http
DEFAULT_OUT_DIR = "ai/inbox/mattermost-mirror" DEFAULT_OUT_DIR = "mattermost-mirror"
POST_ID_RE = re.compile(r"^[a-z0-9]{26}$") POST_ID_RE = re.compile(r"^[a-z0-9]{26}$")
SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9._-]+") SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9._-]+")

View File

@@ -13,17 +13,29 @@ import argparse
import json import json
import os import os
import shlex import shlex
import sys
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
MIRROR_DIR = ROOT / "ai" / "inbox" / "mattermost-mirror" sys.path.insert(0, str(ROOT / "scripts" / "aiw"))
LEGACY_LATEST = ROOT / "ai" / "inbox" / "mattermost-latest.md" import profile as aiw_profile # noqa: E402
DEFAULT_PROFILE = os.getenv("AIW_PROJECT_PROFILE", "fidelity")
MIRROR_DIR = aiw_profile.inbox_dir(DEFAULT_PROFILE, root=ROOT) / "mattermost-mirror"
LEGACY_LATEST = aiw_profile.inbox_dir(DEFAULT_PROFILE, root=ROOT) / "mattermost-latest.md"
LEGACY_GENERATED = ROOT / "scripts" / "mattermost" / "generated" / "mattermost_context.jsonl" LEGACY_GENERATED = ROOT / "scripts" / "mattermost" / "generated" / "mattermost_context.jsonl"
LOCAL_ENV = Path(__file__).resolve().parent / ".env" LOCAL_ENV = Path(__file__).resolve().parent / ".env"
def configure_profile_paths(profile: str) -> None:
global MIRROR_DIR, LEGACY_LATEST
inbox = aiw_profile.inbox_dir(profile, root=ROOT)
MIRROR_DIR = inbox / "mattermost-mirror"
LEGACY_LATEST = inbox / "mattermost-latest.md"
def load_local_env(path: Path = LOCAL_ENV) -> None: def load_local_env(path: Path = LOCAL_ENV) -> None:
"""Load simple KEY=VALUE pairs from the connector-local .env. """Load simple KEY=VALUE pairs from the connector-local .env.
@@ -201,6 +213,7 @@ def main() -> None:
load_local_env() load_local_env()
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--profile", default=DEFAULT_PROFILE)
parser.add_argument("--mode", choices=["latest", "previous-workday", "standup", "focused"], default="latest") parser.add_argument("--mode", choices=["latest", "previous-workday", "standup", "focused"], default="latest")
parser.add_argument("--today", default="") parser.add_argument("--today", default="")
parser.add_argument( parser.add_argument(
@@ -210,6 +223,7 @@ def main() -> None:
) )
parser.add_argument("--limit", type=int, default=80, help="Max records per section; use 0 for no limit.") parser.add_argument("--limit", type=int, default=80, help="Max records per section; use 0 for no limit.")
args = parser.parse_args() args = parser.parse_args()
configure_profile_paths(args.profile)
channels = parse_channels(args.channels or None) channels = parse_channels(args.channels or None)
limit = args.limit if args.limit > 0 else None limit = args.limit if args.limit > 0 else None

View File

@@ -11,7 +11,9 @@ if [ -f "$SCRIPT_DIR/.env" ]; then
set +a set +a
fi fi
export MATTERMOST_MIRROR_DIR="${MATTERMOST_MIRROR_DIR:-$WORKSPACE_ROOT/ai/inbox/mattermost-mirror}" PROFILE="${AIW_PROJECT_PROFILE:-fidelity}"
PROFILE_INBOX_DIR="$(python3 "$WORKSPACE_ROOT/scripts/aiw/profile.py" path inbox --profile "$PROFILE")"
export MATTERMOST_MIRROR_DIR="${MATTERMOST_MIRROR_DIR:-$PROFILE_INBOX_DIR/mattermost-mirror}"
export MATTERMOST_MIRROR_LISTEN_HOST="${MATTERMOST_MIRROR_LISTEN_HOST:-127.0.0.1}" export MATTERMOST_MIRROR_LISTEN_HOST="${MATTERMOST_MIRROR_LISTEN_HOST:-127.0.0.1}"
export MATTERMOST_MIRROR_LISTEN_PORT="${MATTERMOST_MIRROR_LISTEN_PORT:-8080}" export MATTERMOST_MIRROR_LISTEN_PORT="${MATTERMOST_MIRROR_LISTEN_PORT:-8080}"

View File

@@ -3,7 +3,7 @@
This directory contains the workspace-local Mattermost extractor used by OpenCode to refresh communication context. This directory contains the workspace-local Mattermost extractor used by OpenCode to refresh communication context.
The preferred live Mattermost evidence source is now the proxy mirror under The preferred live Mattermost evidence source is now the proxy mirror under
`ai/inbox/mattermost-mirror/` when it is running. This legacy extractor remains the active profile inbox, `<profile inbox>/mattermost-mirror/`, when it is running. This legacy extractor remains
the fallback and explicit refresh path for commands that need a fresh pull from the fallback and explicit refresh path for commands that need a fresh pull from
the Mattermost API. the Mattermost API.
@@ -72,7 +72,7 @@ Generic workspace variables are preferred for reusable projects:
- `AIW_MATTERMOST_SYNC_CMD` - `AIW_MATTERMOST_SYNC_CMD`
- `AIW_MATTERMOST_SYNC_INTERVAL_MINUTES` - `AIW_MATTERMOST_SYNC_INTERVAL_MINUTES`
The older `FIDELITY_*` variables remain supported for this project profile. Profile-specific compatibility variables may exist for older project setups, but new reusable workflows should prefer the generic `AIW_*` variables.
Previous workday mode for standups: Previous workday mode for standups:

View File

@@ -45,7 +45,7 @@ python3 scripts/mcp/aiw-context-mcp/server.py --transport stdio
- `memory_hybrid_search` - `memory_hybrid_search`
- `photos_latest` - `photos_latest`
All tools are read-only. Mattermost tools read `ai/inbox/mattermost-mirror/`; photo tools list local Photo Inbox files without embedding image data; project tools read canonical Markdown under `project-knowledge/`. All tools are read-only. Mattermost tools read the active profile's `mattermost-mirror/` inbox; photo tools list local Photo Inbox files without embedding image data; project tools read canonical Markdown from the profile's configured `knowledge_dir`.
`memory_hybrid_search` reads the derived local index built by: `memory_hybrid_search` reads the derived local index built by:
@@ -53,7 +53,7 @@ All tools are read-only. Mattermost tools read `ai/inbox/mattermost-mirror/`; ph
python3 scripts/aiw/indexer.py build --profile fidelity python3 scripts/aiw/indexer.py build --profile fidelity
``` ```
If the index is missing, it falls back to bounded live Markdown search over `project-knowledge/`. The index is not canonical memory; `project-knowledge/` remains the source of truth. If the index is missing, it falls back to bounded live Markdown search over the profile's configured knowledge directory. The index is not canonical memory; Markdown remains the source of truth.
Mattermost latest/date/standup tools filter to the active profile's context channels by default. For Fidelity, that list lives in `profiles/fidelity/context-sources.json`. Pass explicit `channels` to override the profile list, or `include_all_channels: true` when broad unfiltered mirror evidence is intentionally needed. Mattermost latest/date/standup tools filter to the active profile's context channels by default. For Fidelity, that list lives in `profiles/fidelity/context-sources.json`. Pass explicit `channels` to override the profile list, or `include_all_channels: true` when broad unfiltered mirror evidence is intentionally needed.

View File

@@ -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]:

View File

@@ -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)

View File

@@ -2,12 +2,13 @@
This directory exposes a project-agnostic interface for canonical project knowledge. This directory exposes a project-agnostic interface for canonical project knowledge.
The current implementation uses Markdown files under `project-knowledge/` and can optionally delegate to the Obsidian CLI when it is available. The agent should depend on this memory interface, not on Obsidian-specific behavior, so the backing tool can be replaced later. The current implementation resolves the canonical Markdown directory from `profiles/<profile>/workspace.json` and can optionally delegate to the Obsidian CLI when it is available. The agent should depend on this memory interface, not on Obsidian-specific behavior, so the backing tool can be replaced later.
## Backend Model ## Backend Model
- `AIW_PROJECT_KNOWLEDGE_DIR` points to the canonical Markdown project knowledge directory. - `AIW_PROJECT_PROFILE` selects the profile, defaulting to `fidelity` in this repository.
- `AIW_MEMORY_VAULT_DIR` and `AIW_OBSIDIAN_VAULT_DIR` are transition aliases. - `profiles/<profile>/workspace.json` defines the canonical Markdown project knowledge directory.
- `AIW_PROJECT_KNOWLEDGE_DIR` can override the resolved directory for local experiments.
- `AIW_MEMORY_BACKEND` defaults to `auto`. - `AIW_MEMORY_BACKEND` defaults to `auto`.
- `auto` uses Obsidian CLI when it is useful and available, then falls back to direct Markdown operations. - `auto` uses Obsidian CLI when it is useful and available, then falls back to direct Markdown operations.
- `files` forces direct Markdown operations. - `files` forces direct Markdown operations.

View File

@@ -4,7 +4,8 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
PROJECT_KNOWLEDGE_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-${AIW_MEMORY_VAULT_DIR:-${AIW_OBSIDIAN_VAULT_DIR:-$WORKSPACE_ROOT/project-knowledge}}}" PROFILE="${AIW_PROJECT_PROFILE:-fidelity}"
PROJECT_KNOWLEDGE_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-$(python3 "$WORKSPACE_ROOT/scripts/aiw/profile.py" path knowledge --profile "$PROFILE")}"
VAULT_DIR="$PROJECT_KNOWLEDGE_DIR" VAULT_DIR="$PROJECT_KNOWLEDGE_DIR"
BACKEND="${AIW_MEMORY_BACKEND:-auto}" BACKEND="${AIW_MEMORY_BACKEND:-auto}"
OBSIDIAN_CLI="$WORKSPACE_ROOT/scripts/obsidian/cli.sh" OBSIDIAN_CLI="$WORKSPACE_ROOT/scripts/obsidian/cli.sh"

View File

@@ -4,7 +4,8 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-${AIW_MEMORY_VAULT_DIR:-${AIW_OBSIDIAN_VAULT_DIR:-$WORKSPACE_ROOT/project-knowledge}}}" PROFILE="${AIW_PROJECT_PROFILE:-fidelity}"
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-$(python3 "$WORKSPACE_ROOT/scripts/aiw/profile.py" path knowledge --profile "$PROFILE")}"
if ! command -v obsidian >/dev/null 2>&1; then if ! command -v obsidian >/dev/null 2>&1; then
echo "obsidian CLI is not installed or not in PATH" >&2 echo "obsidian CLI is not installed or not in PATH" >&2

View File

@@ -4,7 +4,8 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-${AIW_MEMORY_VAULT_DIR:-${AIW_OBSIDIAN_VAULT_DIR:-$WORKSPACE_ROOT/project-knowledge}}}" PROFILE="${AIW_PROJECT_PROFILE:-fidelity}"
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-$(python3 "$WORKSPACE_ROOT/scripts/aiw/profile.py" path knowledge --profile "$PROFILE")}"
VAULT_NAME="${AIW_OBSIDIAN_VAULT_NAME:-$(basename "$VAULT_DIR")}" VAULT_NAME="${AIW_OBSIDIAN_VAULT_NAME:-$(basename "$VAULT_DIR")}"
URI="$("$SCRIPT_DIR/uri.sh" daily "vault=$VAULT_NAME")" URI="$("$SCRIPT_DIR/uri.sh" daily "vault=$VAULT_NAME")"

View File

@@ -4,7 +4,8 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-${AIW_MEMORY_VAULT_DIR:-${AIW_OBSIDIAN_VAULT_DIR:-$WORKSPACE_ROOT/project-knowledge}}}" PROFILE="${AIW_PROJECT_PROFILE:-fidelity}"
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-$(python3 "$WORKSPACE_ROOT/scripts/aiw/profile.py" path knowledge --profile "$PROFILE")}"
VAULT_NAME="${AIW_OBSIDIAN_VAULT_NAME:-$(basename "$VAULT_DIR")}" VAULT_NAME="${AIW_OBSIDIAN_VAULT_NAME:-$(basename "$VAULT_DIR")}"
if [[ $# -lt 1 ]]; then if [[ $# -lt 1 ]]; then

View File

@@ -4,7 +4,8 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-${AIW_MEMORY_VAULT_DIR:-${AIW_OBSIDIAN_VAULT_DIR:-$WORKSPACE_ROOT/project-knowledge}}}" PROFILE="${AIW_PROJECT_PROFILE:-fidelity}"
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-$(python3 "$WORKSPACE_ROOT/scripts/aiw/profile.py" path knowledge --profile "$PROFILE")}"
VAULT_NAME="${AIW_OBSIDIAN_VAULT_NAME:-$(basename "$VAULT_DIR")}" VAULT_NAME="${AIW_OBSIDIAN_VAULT_NAME:-$(basename "$VAULT_DIR")}"
if [[ $# -lt 1 ]]; then if [[ $# -lt 1 ]]; then