Compare commits
2 Commits
fb8a6ba2d9
...
7cbb49134a
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cbb49134a | |||
| f0d3cd4ce9 |
@@ -180,6 +180,7 @@ Indexes live under `.aiw/indexes/` and are ignored because they are rebuildable
|
||||
|
||||
```bash
|
||||
python3 scripts/aiw/test_services.py
|
||||
python3 scripts/aiw/test_profile.py
|
||||
python3 scripts/aiw/test_indexer.py
|
||||
python3 scripts/mcp/aiw-context-mcp/test_server.py
|
||||
python3 scripts/iphone-photo-inbox/test_receiver.py
|
||||
|
||||
@@ -36,7 +36,7 @@ AI clients and agent workflows
|
||||
|
||||
## 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:
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ Human-readable summary for agents and developers:
|
||||
|
||||
### `workspace.json`
|
||||
|
||||
Planned path configuration. Initial versions can point to current paths:
|
||||
Profile path configuration. Initial versions can point to current paths:
|
||||
|
||||
```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`
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -41,7 +41,7 @@ bash scripts/memory/memory.sh search "PDIAP-15765"
|
||||
bash scripts/memory/memory.sh create work-item pdiap-15999 "Example title"
|
||||
```
|
||||
|
||||
This interface defaults to Markdown files under `project-knowledge/`, uses Obsidian CLI when useful and available, and falls back to direct file operations.
|
||||
This interface resolves the canonical Markdown directory from `profiles/<profile>/workspace.json`, uses Obsidian CLI when useful and available, and falls back to direct file operations.
|
||||
|
||||
The default workspace Mattermost extractor now lives in:
|
||||
|
||||
@@ -79,7 +79,7 @@ Expected behavior:
|
||||
- avoid interactive prompts
|
||||
- return a non-zero exit code on failure
|
||||
|
||||
OpenCode can then use that output to refresh `ai/inbox/mattermost-latest.md` proactively. When the local Mattermost proxy mirror is running, commands should prefer `ai/inbox/mattermost-mirror/` through `scripts/mattermost-proxy/read-context.py` and use legacy sync output as fallback evidence.
|
||||
OpenCode can then use that output to refresh the active profile inbox proactively. When the local Mattermost proxy mirror is running, commands should prefer `<profile inbox>/mattermost-mirror/` through `scripts/mattermost-proxy/read-context.py --profile <profile>` and use legacy sync output only as fallback evidence.
|
||||
|
||||
Historical Slack exports can also be imported through:
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ The service manager unifies startup and status. It does not move capture behavio
|
||||
|
||||
## Local project-knowledge index
|
||||
|
||||
The workspace includes a dependency-free local indexer for canonical Markdown memory. The index is derived from `project-knowledge/` and written under `.aiw/indexes/<profile>/`; it is safe to delete and rebuild.
|
||||
The workspace includes a dependency-free local indexer for canonical Markdown memory. The index is derived from the profile's configured `knowledge_dir` and written under the profile's configured `index_dir`; it is safe to delete and rebuild.
|
||||
|
||||
```bash
|
||||
python3 scripts/aiw/indexer.py build --profile fidelity
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
- Manifest validation before lifecycle actions.
|
||||
@@ -59,5 +79,6 @@ python3 scripts/aiw/indexer.py search "dismissal lifecycle" --profile fidelity
|
||||
|
||||
```bash
|
||||
python3 scripts/aiw/test_services.py
|
||||
python3 scripts/aiw/test_profile.py
|
||||
python3 scripts/aiw/test_indexer.py
|
||||
```
|
||||
|
||||
@@ -12,6 +12,7 @@ import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
@@ -20,11 +21,13 @@ from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
INDEX_ROOT = ROOT / ".aiw" / "indexes"
|
||||
DEFAULT_PROFILE = "fidelity"
|
||||
MAX_CHARS = 1800
|
||||
OVERLAP_CHARS = 180
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import profile as aiw_profile # noqa: E402
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Chunk:
|
||||
@@ -37,15 +40,15 @@ class Chunk:
|
||||
|
||||
|
||||
def project_knowledge_dir(profile: str) -> Path:
|
||||
profile_base = ROOT / "profiles" / profile
|
||||
candidate = profile_base / "project-knowledge"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return ROOT / "project-knowledge"
|
||||
return aiw_profile.knowledge_dir(profile, root=ROOT)
|
||||
|
||||
|
||||
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:
|
||||
@@ -120,13 +123,13 @@ def build_chunks(profile: str) -> list[Chunk]:
|
||||
chunks: list[Chunk] = []
|
||||
for path in iter_markdown_files(base):
|
||||
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()
|
||||
mtime = path.stat().st_mtime
|
||||
for section_index, (heading, section) in enumerate(split_sections(raw)):
|
||||
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]
|
||||
chunks.append(Chunk(chunk_id=chunk_digest, path=rel, heading=heading, text=chunk, mtime=mtime, sha256=digest))
|
||||
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_path, heading=heading, text=chunk, mtime=mtime, sha256=digest))
|
||||
return chunks
|
||||
|
||||
|
||||
@@ -140,14 +143,14 @@ def write_index(profile: str) -> dict[str, Any]:
|
||||
files = sorted({chunk.path for chunk in chunks})
|
||||
manifest = {
|
||||
"profile": profile,
|
||||
"source": str(project_knowledge_dir(profile).relative_to(ROOT)),
|
||||
"source": rel(project_knowledge_dir(profile)),
|
||||
"canonical": False,
|
||||
"derived_from": "project-knowledge",
|
||||
"index_type": "lexical-markdown-chunks",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"file_count": len(files),
|
||||
"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")
|
||||
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]:
|
||||
manifest_file = manifest_path(profile)
|
||||
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"))
|
||||
path = index_path(profile)
|
||||
manifest["indexed"] = path.is_file()
|
||||
|
||||
107
scripts/aiw/profile.py
Normal file
107
scripts/aiw/profile.py
Normal 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()
|
||||
@@ -30,7 +30,7 @@ class IndexerTests(unittest.TestCase):
|
||||
real.write_text("# XFlow\nDismissal lifecycle context", 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")
|
||||
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:
|
||||
with tempfile.TemporaryDirectory() as 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")
|
||||
|
||||
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}]}
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
53
scripts/aiw/test_profile.py
Normal file
53
scripts/aiw/test_profile.py
Normal 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()
|
||||
@@ -152,17 +152,18 @@ Keep this utility as an isolated image mailbox. If a project wants easy access,
|
||||
link the project inbox to the mailbox instead of making this utility know about
|
||||
the project.
|
||||
|
||||
Example:
|
||||
Example using the active profile inbox:
|
||||
|
||||
```bash
|
||||
mkdir -p ai/inbox
|
||||
ln -s "$HOME/Pictures/Photo Inbox" ai/inbox/photos
|
||||
PROFILE_INBOX="$(python3 scripts/aiw/profile.py path inbox --profile fidelity)"
|
||||
mkdir -p "$PROFILE_INBOX"
|
||||
ln -s "$HOME/Pictures/Photo Inbox" "$PROFILE_INBOX/photos"
|
||||
```
|
||||
|
||||
Or point the receiver at a project-owned folder from `.env`:
|
||||
|
||||
```bash
|
||||
PHOTO_INBOX_DIR=/absolute/path/to/project/ai/inbox/photos
|
||||
PHOTO_INBOX_DIR=/absolute/path/to/profile/inbox/photos
|
||||
```
|
||||
|
||||
The symlink approach keeps this utility reusable across projects and devices.
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
MATTERMOST_MIRROR_HOST_ALLOW=
|
||||
|
||||
# Output directory for raw evidence and normalized AI-readable context.
|
||||
MATTERMOST_MIRROR_DIR=ai/inbox/mattermost-mirror
|
||||
# Optional. If omitted, run-mirror.sh writes to the active profile inbox:
|
||||
# <inbox_dir from profiles/<profile>/workspace.json>/mattermost-mirror
|
||||
# MATTERMOST_MIRROR_DIR=/absolute/path/to/mattermost-mirror
|
||||
|
||||
# mitmproxy listener used by launch-mattermost.sh.
|
||||
MATTERMOST_MIRROR_LISTEN_HOST=127.0.0.1
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Local read-only Mattermost Desktop mirror for AI workspace context.
|
||||
|
||||
This is for **raw evidence only**. It writes under `ai/inbox/mattermost-mirror/`; durable project memory still belongs in `project-knowledge/` after normal promotion rules.
|
||||
This is for **raw evidence only**. By default it writes under the active profile inbox at `<inbox_dir>/mattermost-mirror/`; durable project memory still belongs in the profile's canonical project knowledge vault after normal promotion rules.
|
||||
|
||||
## Why this exists
|
||||
|
||||
@@ -48,7 +48,7 @@ missing.
|
||||
## Output layout
|
||||
|
||||
```text
|
||||
ai/inbox/mattermost-mirror/
|
||||
<profile inbox>/mattermost-mirror/
|
||||
latest.jsonl # bounded AI-readable window
|
||||
latest.md # bounded Markdown view
|
||||
state.json # last seen by channel and user cache
|
||||
@@ -138,7 +138,7 @@ Each line in the normalized JSONL contains:
|
||||
## Useful environment variables
|
||||
|
||||
- `MATTERMOST_MIRROR_HOST_ALLOW`: exact host or parent domain to capture.
|
||||
- `MATTERMOST_MIRROR_DIR`: output directory, default `ai/inbox/mattermost-mirror`.
|
||||
- `MATTERMOST_MIRROR_DIR`: optional output directory. If omitted, `run-mirror.sh` uses `<inbox_dir from profiles/<profile>/workspace.json>/mattermost-mirror`.
|
||||
- `MATTERMOST_MIRROR_LATEST_LIMIT`: number of messages in `latest.*`, default `200`.
|
||||
- `MATTERMOST_MIRROR_CHANNEL_IDS`: optional comma-separated channel ID allowlist.
|
||||
- `MATTERMOST_MIRROR_WRITE_RAW`: set to `1` to save compact raw REST/WebSocket evidence.
|
||||
@@ -170,7 +170,7 @@ do not create message files. Open a channel, open a thread, scroll slightly in
|
||||
history, or wait for/send a new message. Then check:
|
||||
|
||||
```text
|
||||
ai/inbox/mattermost-mirror/latest.md
|
||||
ai/inbox/mattermost-mirror/channels/<channel-name>/YYYY/MM/YYYY-MM-DD.jsonl
|
||||
ai/inbox/mattermost-mirror/by-date/YYYY/MM/YYYY-MM-DD.jsonl
|
||||
<profile inbox>/mattermost-mirror/latest.md
|
||||
<profile inbox>/mattermost-mirror/channels/<channel-name>/YYYY/MM/YYYY-MM-DD.jsonl
|
||||
<profile inbox>/mattermost-mirror/by-date/YYYY/MM/YYYY-MM-DD.jsonl
|
||||
```
|
||||
|
||||
@@ -6,7 +6,7 @@ This addon is intentionally narrow:
|
||||
- redact secrets
|
||||
- normalize posts into date-rotated JSONL files for AI context
|
||||
|
||||
The output under ai/inbox/ is raw evidence, not canonical project memory.
|
||||
The output under the profile inbox is raw evidence, not canonical project memory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -23,7 +23,7 @@ from urllib.parse import urlparse
|
||||
from mitmproxy import http
|
||||
|
||||
|
||||
DEFAULT_OUT_DIR = "ai/inbox/mattermost-mirror"
|
||||
DEFAULT_OUT_DIR = "mattermost-mirror"
|
||||
POST_ID_RE = re.compile(r"^[a-z0-9]{26}$")
|
||||
SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9._-]+")
|
||||
|
||||
|
||||
@@ -13,17 +13,29 @@ import argparse
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MIRROR_DIR = ROOT / "ai" / "inbox" / "mattermost-mirror"
|
||||
LEGACY_LATEST = ROOT / "ai" / "inbox" / "mattermost-latest.md"
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "aiw"))
|
||||
import profile as aiw_profile # noqa: E402
|
||||
|
||||
DEFAULT_PROFILE = os.getenv("AIW_PROJECT_PROFILE", "fidelity")
|
||||
MIRROR_DIR = aiw_profile.inbox_dir(DEFAULT_PROFILE, root=ROOT) / "mattermost-mirror"
|
||||
LEGACY_LATEST = aiw_profile.inbox_dir(DEFAULT_PROFILE, root=ROOT) / "mattermost-latest.md"
|
||||
LEGACY_GENERATED = ROOT / "scripts" / "mattermost" / "generated" / "mattermost_context.jsonl"
|
||||
LOCAL_ENV = Path(__file__).resolve().parent / ".env"
|
||||
|
||||
|
||||
def configure_profile_paths(profile: str) -> None:
|
||||
global MIRROR_DIR, LEGACY_LATEST
|
||||
inbox = aiw_profile.inbox_dir(profile, root=ROOT)
|
||||
MIRROR_DIR = inbox / "mattermost-mirror"
|
||||
LEGACY_LATEST = inbox / "mattermost-latest.md"
|
||||
|
||||
|
||||
def load_local_env(path: Path = LOCAL_ENV) -> None:
|
||||
"""Load simple KEY=VALUE pairs from the connector-local .env.
|
||||
|
||||
@@ -201,6 +213,7 @@ def main() -> None:
|
||||
load_local_env()
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--profile", default=DEFAULT_PROFILE)
|
||||
parser.add_argument("--mode", choices=["latest", "previous-workday", "standup", "focused"], default="latest")
|
||||
parser.add_argument("--today", default="")
|
||||
parser.add_argument(
|
||||
@@ -210,6 +223,7 @@ def main() -> None:
|
||||
)
|
||||
parser.add_argument("--limit", type=int, default=80, help="Max records per section; use 0 for no limit.")
|
||||
args = parser.parse_args()
|
||||
configure_profile_paths(args.profile)
|
||||
channels = parse_channels(args.channels or None)
|
||||
limit = args.limit if args.limit > 0 else None
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ if [ -f "$SCRIPT_DIR/.env" ]; then
|
||||
set +a
|
||||
fi
|
||||
|
||||
export MATTERMOST_MIRROR_DIR="${MATTERMOST_MIRROR_DIR:-$WORKSPACE_ROOT/ai/inbox/mattermost-mirror}"
|
||||
PROFILE="${AIW_PROJECT_PROFILE:-fidelity}"
|
||||
PROFILE_INBOX_DIR="$(python3 "$WORKSPACE_ROOT/scripts/aiw/profile.py" path inbox --profile "$PROFILE")"
|
||||
export MATTERMOST_MIRROR_DIR="${MATTERMOST_MIRROR_DIR:-$PROFILE_INBOX_DIR/mattermost-mirror}"
|
||||
export MATTERMOST_MIRROR_LISTEN_HOST="${MATTERMOST_MIRROR_LISTEN_HOST:-127.0.0.1}"
|
||||
export MATTERMOST_MIRROR_LISTEN_PORT="${MATTERMOST_MIRROR_LISTEN_PORT:-8080}"
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
This directory contains the workspace-local Mattermost extractor used by OpenCode to refresh communication context.
|
||||
|
||||
The preferred live Mattermost evidence source is now the proxy mirror under
|
||||
`ai/inbox/mattermost-mirror/` when it is running. This legacy extractor remains
|
||||
the active profile inbox, `<profile inbox>/mattermost-mirror/`, when it is running. This legacy extractor remains
|
||||
the fallback and explicit refresh path for commands that need a fresh pull from
|
||||
the Mattermost API.
|
||||
|
||||
@@ -72,7 +72,7 @@ Generic workspace variables are preferred for reusable projects:
|
||||
- `AIW_MATTERMOST_SYNC_CMD`
|
||||
- `AIW_MATTERMOST_SYNC_INTERVAL_MINUTES`
|
||||
|
||||
The older `FIDELITY_*` variables remain supported for this project profile.
|
||||
Profile-specific compatibility variables may exist for older project setups, but new reusable workflows should prefer the generic `AIW_*` variables.
|
||||
|
||||
Previous workday mode for standups:
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ python3 scripts/mcp/aiw-context-mcp/server.py --transport stdio
|
||||
- `memory_hybrid_search`
|
||||
- `photos_latest`
|
||||
|
||||
All tools are read-only. Mattermost tools read `ai/inbox/mattermost-mirror/`; photo tools list local Photo Inbox files without embedding image data; project tools read canonical Markdown under `project-knowledge/`.
|
||||
All tools are read-only. Mattermost tools read the active profile's `mattermost-mirror/` inbox; photo tools list local Photo Inbox files without embedding image data; project tools read canonical Markdown from the profile's configured `knowledge_dir`.
|
||||
|
||||
`memory_hybrid_search` reads the derived local index built by:
|
||||
|
||||
@@ -53,7 +53,7 @@ All tools are read-only. Mattermost tools read `ai/inbox/mattermost-mirror/`; ph
|
||||
python3 scripts/aiw/indexer.py build --profile fidelity
|
||||
```
|
||||
|
||||
If the index is missing, it falls back to bounded live Markdown search over `project-knowledge/`. The index is not canonical memory; `project-knowledge/` remains the source of truth.
|
||||
If the index is missing, it falls back to bounded live Markdown search over the profile's configured knowledge directory. The index is not canonical memory; Markdown remains the source of truth.
|
||||
|
||||
Mattermost latest/date/standup tools filter to the active profile's context channels by default. For Fidelity, that list lives in `profiles/fidelity/context-sources.json`. Pass explicit `channels` to override the profile list, or `include_all_channels: true` when broad unfiltered mirror evidence is intentionally needed.
|
||||
|
||||
|
||||
@@ -27,7 +27,9 @@ PROTOCOL_VERSION = "2025-06-18"
|
||||
SERVER_NAME = "aiw-context-mcp"
|
||||
SERVER_VERSION = "0.1.0"
|
||||
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:
|
||||
@@ -47,22 +49,20 @@ def load_local_env(path: Path = LOCAL_ENV) -> None:
|
||||
|
||||
|
||||
def profile_dir(profile: str) -> Path:
|
||||
if profile == "fidelity":
|
||||
return ROOT
|
||||
candidate = ROOT / "profiles" / profile
|
||||
return candidate if candidate.exists() else ROOT
|
||||
|
||||
|
||||
def knowledge_dir(profile: str) -> Path:
|
||||
base = profile_dir(profile)
|
||||
candidate = base / "project-knowledge"
|
||||
return candidate if candidate.exists() else ROOT / "project-knowledge"
|
||||
return aiw_profile.knowledge_dir(profile, root=ROOT)
|
||||
|
||||
|
||||
def inbox_dir(profile: str) -> Path:
|
||||
base = profile_dir(profile)
|
||||
candidate = base / "ai" / "inbox"
|
||||
return candidate if candidate.exists() else ROOT / "ai" / "inbox"
|
||||
return aiw_profile.inbox_dir(profile, root=ROOT)
|
||||
|
||||
|
||||
def rel(path: Path) -> str:
|
||||
return aiw_profile.relative_to_root(path, root=ROOT)
|
||||
|
||||
|
||||
def mattermost_mirror_dir(profile: str) -> Path:
|
||||
@@ -257,7 +257,7 @@ def project_current_context(args: dict[str, Any]) -> dict[str, Any]:
|
||||
result = []
|
||||
for path in files:
|
||||
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})
|
||||
|
||||
|
||||
@@ -270,8 +270,8 @@ def project_search_memory(args: dict[str, Any]) -> dict[str, Any]:
|
||||
base = knowledge_dir(profile)
|
||||
matches: list[dict[str, Any]] = []
|
||||
for path in sorted(base.rglob("*.md")):
|
||||
rel = path.relative_to(base)
|
||||
if str(rel).startswith("09-templates/"):
|
||||
relative_to_base = path.relative_to(base)
|
||||
if str(relative_to_base).startswith("09-templates/"):
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
lowered = text.lower()
|
||||
@@ -280,18 +280,18 @@ def project_search_memory(args: dict[str, Any]) -> dict[str, Any]:
|
||||
continue
|
||||
start = max(0, index - 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:
|
||||
break
|
||||
return tool_result({"profile": profile, "canonical": True, "query": query, "matches": matches})
|
||||
|
||||
|
||||
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:
|
||||
return INDEX_ROOT / profile / "manifest.json"
|
||||
return aiw_profile.index_dir(profile, root=ROOT) / "manifest.json"
|
||||
|
||||
|
||||
def search_tokens(text: str) -> set[str]:
|
||||
|
||||
@@ -175,7 +175,7 @@ class ContextMCPTests(unittest.TestCase):
|
||||
}) + "\n", 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"]
|
||||
|
||||
self.assertTrue(result["index_available"])
|
||||
@@ -189,13 +189,31 @@ class ContextMCPTests(unittest.TestCase):
|
||||
real.parent.mkdir(parents=True)
|
||||
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"]
|
||||
|
||||
self.assertFalse(result["index_available"])
|
||||
self.assertEqual(result["source"], "live-project-knowledge-fallback")
|
||||
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:
|
||||
monday = date(2026, 5, 18)
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
This directory exposes a project-agnostic interface for canonical project knowledge.
|
||||
|
||||
The current implementation uses Markdown files under `project-knowledge/` and can optionally delegate to the Obsidian CLI when it is available. The agent should depend on this memory interface, not on Obsidian-specific behavior, so the backing tool can be replaced later.
|
||||
The current implementation resolves the canonical Markdown directory from `profiles/<profile>/workspace.json` and can optionally delegate to the Obsidian CLI when it is available. The agent should depend on this memory interface, not on Obsidian-specific behavior, so the backing tool can be replaced later.
|
||||
|
||||
## Backend Model
|
||||
|
||||
- `AIW_PROJECT_KNOWLEDGE_DIR` points to the canonical Markdown project knowledge directory.
|
||||
- `AIW_MEMORY_VAULT_DIR` and `AIW_OBSIDIAN_VAULT_DIR` are transition aliases.
|
||||
- `AIW_PROJECT_PROFILE` selects the profile, defaulting to `fidelity` in this repository.
|
||||
- `profiles/<profile>/workspace.json` defines the canonical Markdown project knowledge directory.
|
||||
- `AIW_PROJECT_KNOWLEDGE_DIR` can override the resolved directory for local experiments.
|
||||
- `AIW_MEMORY_BACKEND` defaults to `auto`.
|
||||
- `auto` uses Obsidian CLI when it is useful and available, then falls back to direct Markdown operations.
|
||||
- `files` forces direct Markdown operations.
|
||||
|
||||
@@ -4,7 +4,8 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
PROJECT_KNOWLEDGE_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-${AIW_MEMORY_VAULT_DIR:-${AIW_OBSIDIAN_VAULT_DIR:-$WORKSPACE_ROOT/project-knowledge}}}"
|
||||
PROFILE="${AIW_PROJECT_PROFILE:-fidelity}"
|
||||
PROJECT_KNOWLEDGE_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-$(python3 "$WORKSPACE_ROOT/scripts/aiw/profile.py" path knowledge --profile "$PROFILE")}"
|
||||
VAULT_DIR="$PROJECT_KNOWLEDGE_DIR"
|
||||
BACKEND="${AIW_MEMORY_BACKEND:-auto}"
|
||||
OBSIDIAN_CLI="$WORKSPACE_ROOT/scripts/obsidian/cli.sh"
|
||||
|
||||
@@ -4,7 +4,8 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-${AIW_MEMORY_VAULT_DIR:-${AIW_OBSIDIAN_VAULT_DIR:-$WORKSPACE_ROOT/project-knowledge}}}"
|
||||
PROFILE="${AIW_PROJECT_PROFILE:-fidelity}"
|
||||
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-$(python3 "$WORKSPACE_ROOT/scripts/aiw/profile.py" path knowledge --profile "$PROFILE")}"
|
||||
|
||||
if ! command -v obsidian >/dev/null 2>&1; then
|
||||
echo "obsidian CLI is not installed or not in PATH" >&2
|
||||
|
||||
@@ -4,7 +4,8 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-${AIW_MEMORY_VAULT_DIR:-${AIW_OBSIDIAN_VAULT_DIR:-$WORKSPACE_ROOT/project-knowledge}}}"
|
||||
PROFILE="${AIW_PROJECT_PROFILE:-fidelity}"
|
||||
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-$(python3 "$WORKSPACE_ROOT/scripts/aiw/profile.py" path knowledge --profile "$PROFILE")}"
|
||||
VAULT_NAME="${AIW_OBSIDIAN_VAULT_NAME:-$(basename "$VAULT_DIR")}"
|
||||
|
||||
URI="$("$SCRIPT_DIR/uri.sh" daily "vault=$VAULT_NAME")"
|
||||
|
||||
@@ -4,7 +4,8 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-${AIW_MEMORY_VAULT_DIR:-${AIW_OBSIDIAN_VAULT_DIR:-$WORKSPACE_ROOT/project-knowledge}}}"
|
||||
PROFILE="${AIW_PROJECT_PROFILE:-fidelity}"
|
||||
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-$(python3 "$WORKSPACE_ROOT/scripts/aiw/profile.py" path knowledge --profile "$PROFILE")}"
|
||||
VAULT_NAME="${AIW_OBSIDIAN_VAULT_NAME:-$(basename "$VAULT_DIR")}"
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
|
||||
@@ -4,7 +4,8 @@ set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORKSPACE_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-${AIW_MEMORY_VAULT_DIR:-${AIW_OBSIDIAN_VAULT_DIR:-$WORKSPACE_ROOT/project-knowledge}}}"
|
||||
PROFILE="${AIW_PROJECT_PROFILE:-fidelity}"
|
||||
VAULT_DIR="${AIW_PROJECT_KNOWLEDGE_DIR:-$(python3 "$WORKSPACE_ROOT/scripts/aiw/profile.py" path knowledge --profile "$PROFILE")}"
|
||||
VAULT_NAME="${AIW_OBSIDIAN_VAULT_NAME:-$(basename "$VAULT_DIR")}"
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
|
||||
Reference in New Issue
Block a user