ctxora 6.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +441 -0
- package/README.vi.md +441 -0
- package/bin/ctxora.mjs +147 -0
- package/package.json +45 -0
- package/pyproject.toml +59 -0
- package/src/chunking/compressor.py +104 -0
- package/src/chunking/treesitter_chunker.py +240 -0
- package/src/compact/anthropic.py +98 -0
- package/src/compact/gemini.py +88 -0
- package/src/compact/handoff.py +179 -0
- package/src/compact/openai.py +318 -0
- package/src/compact/summarizer.py +186 -0
- package/src/context/assembler.py +298 -0
- package/src/context/budgeting.py +137 -0
- package/src/context/sanitizer.py +23 -0
- package/src/evaluation/__init__.py +1 -0
- package/src/evaluation/gates.py +172 -0
- package/src/evaluation/metrics.py +41 -0
- package/src/harness_context/__init__.py +5 -0
- package/src/harness_context/adapters/__init__.py +1 -0
- package/src/harness_context/adapters/clients/__init__.py +4 -0
- package/src/harness_context/adapters/clients/formatters.py +47 -0
- package/src/harness_context/adapters/clients/profiles.py +29 -0
- package/src/harness_context/adapters/ecc/__init__.py +4 -0
- package/src/harness_context/adapters/ecc/detection.py +41 -0
- package/src/harness_context/adapters/ecc/mapping.py +32 -0
- package/src/harness_context/adapters/ecc/memory_reader.py +162 -0
- package/src/harness_context/adapters/ecc/provenance.py +16 -0
- package/src/harness_context/api/__init__.py +1 -0
- package/src/harness_context/api/v2/__init__.py +12 -0
- package/src/harness_context/api/v2/contracts.py +119 -0
- package/src/harness_context/api/v2/diagnostics.py +13 -0
- package/src/harness_context/api/v2/enums.py +17 -0
- package/src/harness_context/api/v2/errors.py +32 -0
- package/src/harness_context/api/v2/models.py +4 -0
- package/src/harness_context/api/v2/requests.py +17 -0
- package/src/harness_context/api/v2/responses.py +22 -0
- package/src/harness_context/application/__init__.py +3 -0
- package/src/harness_context/application/container.py +31 -0
- package/src/harness_context/application/context_service.py +51 -0
- package/src/harness_context/application/ecc_service.py +7 -0
- package/src/harness_context/application/handoff_service.py +11 -0
- package/src/harness_context/application/memory_service.py +9 -0
- package/src/harness_context/application/protocols.py +46 -0
- package/src/harness_context/application/refresh_service.py +25 -0
- package/src/harness_context/application/retrieval_service.py +22 -0
- package/src/harness_context/application/services.py +4 -0
- package/src/harness_context/application/workspace_service.py +18 -0
- package/src/harness_context/bootstrap.py +47 -0
- package/src/harness_context/branding.py +16 -0
- package/src/harness_context/cli/__init__.py +1 -0
- package/src/harness_context/cli/app.py +239 -0
- package/src/harness_context/cli/exit_codes.py +25 -0
- package/src/harness_context/domain/__init__.py +9 -0
- package/src/harness_context/domain/cag.py +18 -0
- package/src/harness_context/domain/chunking.py +17 -0
- package/src/harness_context/domain/planning.py +30 -0
- package/src/harness_context/domain/ports.py +24 -0
- package/src/harness_context/domain/retrieval.py +46 -0
- package/src/harness_context/engine.py +10 -0
- package/src/harness_context/free_tools.py +143 -0
- package/src/harness_context/infrastructure/__init__.py +10 -0
- package/src/harness_context/infrastructure/graph.py +26 -0
- package/src/harness_context/infrastructure/indexes.py +33 -0
- package/src/harness_context/infrastructure/local_engine.py +296 -0
- package/src/harness_context/infrastructure/parsing.py +38 -0
- package/src/harness_context/infrastructure/scanning.py +51 -0
- package/src/harness_context/installer/__init__.py +4 -0
- package/src/harness_context/installer/models.py +22 -0
- package/src/harness_context/installer/service.py +168 -0
- package/src/harness_context/mcp/__init__.py +3 -0
- package/src/harness_context/mcp/capabilities.py +11 -0
- package/src/harness_context/mcp/errors.py +8 -0
- package/src/harness_context/mcp/lifecycle.py +72 -0
- package/src/harness_context/mcp/middleware.py +57 -0
- package/src/harness_context/mcp/server.py +3 -0
- package/src/harness_context/mcp/tool_handlers/__init__.py +7 -0
- package/src/harness_context/mcp/tool_handlers/context.py +16 -0
- package/src/harness_context/mcp/tool_handlers/ecc.py +8 -0
- package/src/harness_context/mcp/tool_handlers/handoffs.py +20 -0
- package/src/harness_context/mcp/tool_handlers/memory.py +16 -0
- package/src/harness_context/mcp/tool_handlers/workspace.py +12 -0
- package/src/harness_context/mcp/tools.py +15 -0
- package/src/harness_context/observability/__init__.py +6 -0
- package/src/harness_context/observability/events.py +25 -0
- package/src/harness_context/observability/metrics.py +20 -0
- package/src/harness_context/paths.py +35 -0
- package/src/harness_context/runtime.py +127 -0
- package/src/harness_context/schemas.py +38 -0
- package/src/harness_context/security/__init__.py +3 -0
- package/src/harness_context/security/secret_patterns.py +15 -0
- package/src/harness_context/server.py +1077 -0
- package/src/harness_context/storage/__init__.py +6 -0
- package/src/harness_context/storage/migrations.py +24 -0
- package/src/harness_context/storage/pins.py +10 -0
- package/src/harness_context/storage/snapshots.py +149 -0
- package/src/harness_context/tokenize.py +12 -0
- package/src/harness_context/topology.py +65 -0
- package/src/harness_context/watcher/__init__.py +3 -0
- package/src/harness_context/watcher/service.py +32 -0
- package/src/harness_context/workspace/__init__.py +13 -0
- package/src/harness_context/workspace/identity.py +9 -0
- package/src/harness_context/workspace/lock.py +24 -0
- package/src/harness_context/workspace/policy.py +3 -0
- package/src/harness_context/workspace/roots.py +84 -0
- package/src/harness_context/workspace/state.py +35 -0
- package/src/memory/episodic.py +257 -0
- package/src/memory/vector_store.py +104 -0
- package/src/retrieval/bm25.py +23 -0
- package/src/retrieval/cache.py +76 -0
- package/src/retrieval/embeddings.py +75 -0
- package/src/retrieval/graph.py +45 -0
- package/src/retrieval/reranker.py +78 -0
- package/src/retrieval/tokenize.py +11 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Mutation:
|
|
9
|
+
operation: str
|
|
10
|
+
path: str
|
|
11
|
+
before: Any
|
|
12
|
+
after: Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class MutationPlan:
|
|
17
|
+
profile: str
|
|
18
|
+
config_path: str
|
|
19
|
+
mutations: tuple[Mutation, ...]
|
|
20
|
+
|
|
21
|
+
def to_dict(self) -> dict[str, Any]:
|
|
22
|
+
return asdict(self)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from harness_context.adapters.clients import ClientProfile, get_profile
|
|
12
|
+
from harness_context.branding import CLI_NAME
|
|
13
|
+
from harness_context.installer.models import Mutation, MutationPlan
|
|
14
|
+
from harness_context.paths import workspace_data_dirs, workspace_installer_dir
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ClientInstaller:
|
|
18
|
+
def __init__(self, workspace: str | Path):
|
|
19
|
+
self.workspace = Path(workspace).expanduser().resolve()
|
|
20
|
+
self.ownership_path = workspace_installer_dir(self.workspace) / "ownership.json"
|
|
21
|
+
|
|
22
|
+
def plan(self, profile_name: str, config_path: str | Path | None = None, uninstall: bool = False) -> MutationPlan:
|
|
23
|
+
profile = get_profile(profile_name)
|
|
24
|
+
path = self._config_path(profile, config_path)
|
|
25
|
+
current = self._read(profile, path)
|
|
26
|
+
before = self._owned_value(profile, current)
|
|
27
|
+
manifest = self._load_manifest().get(profile.name, {})
|
|
28
|
+
after = manifest.get("previous") if uninstall else self._server_config()
|
|
29
|
+
if uninstall and manifest and before != manifest.get("installed"):
|
|
30
|
+
return MutationPlan(profile.name, str(path), (Mutation("conflict", self._mutation_path(profile), before, after),))
|
|
31
|
+
operation = "remove" if uninstall and after is None else ("restore" if uninstall else ("replace" if before is not None else "add"))
|
|
32
|
+
mutations = () if before == after else (Mutation(operation, self._mutation_path(profile), before, after),)
|
|
33
|
+
return MutationPlan(profile.name, str(path), mutations)
|
|
34
|
+
|
|
35
|
+
def install(self, profile_name: str, config_path: str | Path | None = None, dry_run: bool = False) -> MutationPlan:
|
|
36
|
+
profile = get_profile(profile_name)
|
|
37
|
+
path = self._config_path(profile, config_path)
|
|
38
|
+
plan = self.plan(profile_name, path)
|
|
39
|
+
if dry_run or not plan.mutations:
|
|
40
|
+
return plan
|
|
41
|
+
current = self._read(profile, path)
|
|
42
|
+
updated = self._set_owned(profile, current, self._server_config())
|
|
43
|
+
self._backup(path)
|
|
44
|
+
self._write(profile, path, updated)
|
|
45
|
+
manifest = self._load_manifest()
|
|
46
|
+
manifest[profile.name] = {
|
|
47
|
+
"config_path": str(path),
|
|
48
|
+
"server_key": profile.server_key,
|
|
49
|
+
"previous": self._owned_value(profile, current),
|
|
50
|
+
"installed": self._owned_value(profile, updated),
|
|
51
|
+
}
|
|
52
|
+
self._write_json(self.ownership_path, manifest)
|
|
53
|
+
return plan
|
|
54
|
+
|
|
55
|
+
def uninstall(self, profile_name: str, config_path: str | Path | None = None, dry_run: bool = False, delete_data: bool = False) -> MutationPlan:
|
|
56
|
+
profile = get_profile(profile_name)
|
|
57
|
+
manifest = self._load_manifest()
|
|
58
|
+
owned = manifest.get(profile.name, {})
|
|
59
|
+
path = self._config_path(profile, config_path or owned.get("config_path"))
|
|
60
|
+
plan = self.plan(profile_name, path, uninstall=True)
|
|
61
|
+
conflict = any(mutation.operation == "conflict" for mutation in plan.mutations)
|
|
62
|
+
if not dry_run and plan.mutations and not conflict:
|
|
63
|
+
current = self._read(profile, path)
|
|
64
|
+
self._backup(path)
|
|
65
|
+
self._write(profile, path, self._set_owned(profile, current, owned.get("previous")))
|
|
66
|
+
if not dry_run and not conflict:
|
|
67
|
+
manifest.pop(profile.name, None)
|
|
68
|
+
if manifest:
|
|
69
|
+
self._write_json(self.ownership_path, manifest)
|
|
70
|
+
else:
|
|
71
|
+
self.ownership_path.unlink(missing_ok=True)
|
|
72
|
+
if delete_data:
|
|
73
|
+
for data_dir in workspace_data_dirs(self.workspace):
|
|
74
|
+
shutil.rmtree(data_dir, ignore_errors=True)
|
|
75
|
+
return plan
|
|
76
|
+
|
|
77
|
+
def _server_config(self) -> dict[str, Any]:
|
|
78
|
+
command = os.environ.get("CTXORA_MCP_COMMAND", CLI_NAME)
|
|
79
|
+
prefix = json.loads(os.environ.get("CTXORA_MCP_ARGS_PREFIX", "[]"))
|
|
80
|
+
if not isinstance(prefix, list) or not all(isinstance(item, str) for item in prefix):
|
|
81
|
+
raise ValueError("CTXORA_MCP_ARGS_PREFIX must be a JSON array of strings")
|
|
82
|
+
return {
|
|
83
|
+
"command": command,
|
|
84
|
+
"args": [*prefix, "run", "--workspace", str(self.workspace), "--transport", "stdio"],
|
|
85
|
+
"env": {"CTXORA_ALLOWED_ROOTS": str(self.workspace), "PYTHONUTF8": "1"},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _mutation_path(profile: ClientProfile) -> str:
|
|
90
|
+
return f"mcp_servers.{profile.server_key}" if profile.config_format == "toml" else f"mcpServers.{profile.server_key}"
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _config_path(profile: ClientProfile, config_path: str | Path | None) -> Path:
|
|
94
|
+
return Path(config_path or profile.default_config).expanduser().resolve()
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _read(profile: ClientProfile, path: Path) -> Any:
|
|
98
|
+
if not path.exists():
|
|
99
|
+
return "" if profile.config_format == "toml" else {}
|
|
100
|
+
text = path.read_text("utf-8")
|
|
101
|
+
return text if profile.config_format == "toml" else json.loads(text or "{}")
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _owned_value(profile: ClientProfile, current: Any) -> Any:
|
|
105
|
+
if profile.config_format == "json":
|
|
106
|
+
return current.get("mcpServers", {}).get(profile.server_key)
|
|
107
|
+
match = re.search(ClientInstaller._toml_pattern(profile), current)
|
|
108
|
+
return match.group(0).rstrip() if match else None
|
|
109
|
+
|
|
110
|
+
def _set_owned(self, profile: ClientProfile, current: Any, value: Any) -> Any:
|
|
111
|
+
if profile.config_format == "json":
|
|
112
|
+
updated = json.loads(json.dumps(current))
|
|
113
|
+
servers = updated.setdefault("mcpServers", {})
|
|
114
|
+
if value is None:
|
|
115
|
+
servers.pop(profile.server_key, None)
|
|
116
|
+
if not servers:
|
|
117
|
+
updated.pop("mcpServers", None)
|
|
118
|
+
else:
|
|
119
|
+
servers[profile.server_key] = value
|
|
120
|
+
return updated
|
|
121
|
+
pattern = self._toml_pattern(profile)
|
|
122
|
+
cleaned = re.sub(pattern, "", current).rstrip()
|
|
123
|
+
if value is None:
|
|
124
|
+
return cleaned + ("\n" if cleaned else "")
|
|
125
|
+
if isinstance(value, str):
|
|
126
|
+
return (cleaned + "\n\n" if cleaned else "") + value.rstrip() + "\n"
|
|
127
|
+
block = [f"[mcp_servers.{profile.server_key}]", f'command = {json.dumps(value["command"])}', f'args = {json.dumps(value["args"])}', ""]
|
|
128
|
+
block.append(f"[mcp_servers.{profile.server_key}.env]")
|
|
129
|
+
block.extend(f"{key} = {json.dumps(item)}" for key, item in sorted(value["env"].items()))
|
|
130
|
+
return (cleaned + "\n\n" if cleaned else "") + "\n".join(block) + "\n"
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def _toml_pattern(profile: ClientProfile) -> str:
|
|
134
|
+
key = re.escape(profile.server_key)
|
|
135
|
+
return rf"(?ms)^\[mcp_servers\.{key}\]\n.*?(?=^\[(?!mcp_servers\.{key}(?:\.|\]))|\Z)"
|
|
136
|
+
|
|
137
|
+
def _load_manifest(self) -> dict[str, Any]:
|
|
138
|
+
if not self.ownership_path.exists():
|
|
139
|
+
return {}
|
|
140
|
+
return json.loads(self.ownership_path.read_text("utf-8"))
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _backup(path: Path) -> None:
|
|
144
|
+
if not path.exists():
|
|
145
|
+
return
|
|
146
|
+
backup = path.with_suffix(path.suffix + ".ctxora.bak")
|
|
147
|
+
backup.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
with tempfile.NamedTemporaryFile(dir=backup.parent, delete=False) as handle:
|
|
149
|
+
handle.write(path.read_bytes())
|
|
150
|
+
temporary = Path(handle.name)
|
|
151
|
+
os.replace(temporary, backup)
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _write(profile: ClientProfile, path: Path, value: Any) -> None:
|
|
155
|
+
text = value if profile.config_format == "toml" else json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
|
156
|
+
ClientInstaller._write_text(path, text)
|
|
157
|
+
|
|
158
|
+
@staticmethod
|
|
159
|
+
def _write_json(path: Path, value: Any) -> None:
|
|
160
|
+
ClientInstaller._write_text(path, json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n")
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _write_text(path: Path, text: str) -> None:
|
|
164
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
|
|
166
|
+
handle.write(text)
|
|
167
|
+
temporary = Path(handle.name)
|
|
168
|
+
os.replace(temporary, path)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from harness_context.branding import MCP_SERVER_NAME
|
|
2
|
+
|
|
3
|
+
SERVER_NAME = MCP_SERVER_NAME
|
|
4
|
+
|
|
5
|
+
TOOL_NAMES = frozenset({
|
|
6
|
+
"register_workspace", "refresh_workspace", "plan_context", "retrieve_context",
|
|
7
|
+
"prepare_context", "context_stats", "invalidate_context", "memory_save",
|
|
8
|
+
"memory_search", "memory_delete", "memory_list", "handoff_conversation",
|
|
9
|
+
"restore_conversation_handoff", "list_conversation_handoffs",
|
|
10
|
+
"delete_conversation_handoff", "purge_expired_handoffs", "ecc_status", "ecc_search",
|
|
11
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from mcp.server.fastmcp import FastMCP
|
|
7
|
+
|
|
8
|
+
from harness_context.mcp.capabilities import SERVER_NAME
|
|
9
|
+
from harness_context.mcp.middleware import RequestMiddleware
|
|
10
|
+
from harness_context.mcp.tools import register_tools
|
|
11
|
+
from harness_context.observability import LocalMetrics, StructuredEventSink
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ServerLifecycle:
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self._condition = threading.Condition()
|
|
17
|
+
self._ready = False
|
|
18
|
+
self._draining = False
|
|
19
|
+
self._active_requests = 0
|
|
20
|
+
|
|
21
|
+
def mark_ready(self) -> None:
|
|
22
|
+
with self._condition:
|
|
23
|
+
self._ready = True
|
|
24
|
+
|
|
25
|
+
def begin_request(self) -> bool:
|
|
26
|
+
with self._condition:
|
|
27
|
+
if not self._ready or self._draining:
|
|
28
|
+
return False
|
|
29
|
+
self._active_requests += 1
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
def end_request(self) -> None:
|
|
33
|
+
with self._condition:
|
|
34
|
+
self._active_requests = max(0, self._active_requests - 1)
|
|
35
|
+
self._condition.notify_all()
|
|
36
|
+
|
|
37
|
+
def drain(self, timeout: float = 10.0) -> bool:
|
|
38
|
+
deadline = time.monotonic() + timeout
|
|
39
|
+
with self._condition:
|
|
40
|
+
self._draining = True
|
|
41
|
+
while self._active_requests and time.monotonic() < deadline:
|
|
42
|
+
self._condition.wait(max(0, deadline - time.monotonic()))
|
|
43
|
+
return self._active_requests == 0
|
|
44
|
+
|
|
45
|
+
def status(self) -> dict[str, object]:
|
|
46
|
+
with self._condition:
|
|
47
|
+
return {"ready": self._ready and not self._draining,
|
|
48
|
+
"draining": self._draining, "active_requests": self._active_requests}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def create_mcp_server(container, default_workspace_id: str, lifecycle=None,
|
|
52
|
+
events=None, metrics=None) -> FastMCP:
|
|
53
|
+
server = FastMCP(SERVER_NAME)
|
|
54
|
+
register_tools(server, container, default_workspace_id)
|
|
55
|
+
lifecycle = lifecycle or ServerLifecycle()
|
|
56
|
+
events = events or StructuredEventSink(container.snapshots.state_dir / "events.jsonl")
|
|
57
|
+
metrics = metrics or LocalMetrics()
|
|
58
|
+
middleware = RequestMiddleware(lifecycle, events, metrics)
|
|
59
|
+
target = getattr(server, "middleware", None)
|
|
60
|
+
if target is None:
|
|
61
|
+
target = getattr(getattr(server, "_mcp_server", None), "middleware", None)
|
|
62
|
+
if target is not None:
|
|
63
|
+
target.append(middleware)
|
|
64
|
+
else:
|
|
65
|
+
for tool in server._tool_manager._tools.values():
|
|
66
|
+
tool.fn = middleware.wrap(tool.name, tool.fn)
|
|
67
|
+
lifecycle.mark_ready()
|
|
68
|
+
server.ctxora_lifecycle = lifecycle
|
|
69
|
+
server.ctxora_metrics = metrics
|
|
70
|
+
server.harness_lifecycle = lifecycle
|
|
71
|
+
server.harness_metrics = metrics
|
|
72
|
+
return server
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from functools import wraps
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RequestMiddleware:
|
|
9
|
+
def __init__(self, lifecycle, events, metrics):
|
|
10
|
+
self.lifecycle = lifecycle
|
|
11
|
+
self.events = events
|
|
12
|
+
self.metrics = metrics
|
|
13
|
+
|
|
14
|
+
async def __call__(self, context, call_next):
|
|
15
|
+
request_id = uuid.uuid4().hex
|
|
16
|
+
method = str(getattr(context, "method", "unknown"))
|
|
17
|
+
started = time.monotonic()
|
|
18
|
+
if not self.lifecycle.begin_request():
|
|
19
|
+
self._complete(request_id, method, "rejected", started)
|
|
20
|
+
raise RuntimeError("server is draining")
|
|
21
|
+
try:
|
|
22
|
+
result = await call_next(context)
|
|
23
|
+
except Exception:
|
|
24
|
+
self._complete(request_id, method, "error", started)
|
|
25
|
+
raise
|
|
26
|
+
else:
|
|
27
|
+
self._complete(request_id, method, "ok", started)
|
|
28
|
+
return result
|
|
29
|
+
finally:
|
|
30
|
+
self.lifecycle.end_request()
|
|
31
|
+
|
|
32
|
+
def wrap(self, method: str, function):
|
|
33
|
+
@wraps(function)
|
|
34
|
+
def tracked(*args, **kwargs):
|
|
35
|
+
request_id = uuid.uuid4().hex
|
|
36
|
+
started = time.monotonic()
|
|
37
|
+
if not self.lifecycle.begin_request():
|
|
38
|
+
self._complete(request_id, method, "rejected", started)
|
|
39
|
+
raise RuntimeError("server is draining")
|
|
40
|
+
try:
|
|
41
|
+
result = function(*args, **kwargs)
|
|
42
|
+
except Exception:
|
|
43
|
+
self._complete(request_id, method, "error", started)
|
|
44
|
+
raise
|
|
45
|
+
else:
|
|
46
|
+
self._complete(request_id, method, "ok", started)
|
|
47
|
+
return result
|
|
48
|
+
finally:
|
|
49
|
+
self.lifecycle.end_request()
|
|
50
|
+
|
|
51
|
+
return tracked
|
|
52
|
+
|
|
53
|
+
def _complete(self, request_id: str, method: str, outcome: str, started: float) -> None:
|
|
54
|
+
duration = int((time.monotonic() - started) * 1000)
|
|
55
|
+
self.metrics.record_request(outcome, duration)
|
|
56
|
+
self.events.emit("request.completed", request_id=request_id, method=method,
|
|
57
|
+
outcome=outcome, duration_ms=duration)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from harness_context.mcp.tool_handlers.context import register_context_tools
|
|
2
|
+
from harness_context.mcp.tool_handlers.ecc import register_ecc_tools
|
|
3
|
+
from harness_context.mcp.tool_handlers.handoffs import register_handoff_tools
|
|
4
|
+
from harness_context.mcp.tool_handlers.memory import register_memory_tools
|
|
5
|
+
from harness_context.mcp.tool_handlers.workspace import register_workspace_tools
|
|
6
|
+
|
|
7
|
+
__all__ = ["register_context_tools", "register_ecc_tools", "register_handoff_tools", "register_memory_tools", "register_workspace_tools"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
def register_context_tools(mcp, container, default_workspace_id: str) -> None:
|
|
2
|
+
@mcp.tool()
|
|
3
|
+
def plan_context(workspace_id: str, query: str, available_input_tokens: int, strategy_override: str = "") -> dict:
|
|
4
|
+
return container.retrieval.plan(workspace_id, query, available_input_tokens, strategy_override)
|
|
5
|
+
|
|
6
|
+
@mcp.tool()
|
|
7
|
+
def retrieve_context(workspace_id: str, query: str, top_k: int = 12, graph_expand: bool = True, token_budget: int = 4_000) -> dict:
|
|
8
|
+
return container.retrieval.retrieve(workspace_id, query, top_k, graph_expand, token_budget)
|
|
9
|
+
|
|
10
|
+
@mcp.tool()
|
|
11
|
+
def prepare_context(workspace_id: str, query: str, available_input_tokens: int, preferred_strategy: str = "auto", include_memory: bool = True, include_handoff: bool = False, handoff_id: str = "", include_ecc: bool = False, freshness: str = "current", deadline_ms: int = 5_000) -> dict:
|
|
12
|
+
return container.context.prepare_values(workspace_id=workspace_id, query=query, available_input_tokens=available_input_tokens, preferred_strategy=preferred_strategy, include_memory=include_memory, include_handoff=include_handoff, handoff_id=handoff_id, include_ecc=include_ecc, freshness=freshness, deadline_ms=deadline_ms)
|
|
13
|
+
|
|
14
|
+
@mcp.tool()
|
|
15
|
+
def context_stats(workspace_id: str = default_workspace_id) -> dict:
|
|
16
|
+
return container.retrieval.stats(workspace_id)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
def register_handoff_tools(mcp, container) -> None:
|
|
2
|
+
@mcp.tool()
|
|
3
|
+
def handoff_conversation(workspace_id: str, messages_json: str, threshold_tokens: int = 30_000, label: str = "", retention_seconds: int = 604_800, consent: bool = False) -> dict:
|
|
4
|
+
return container.handoff.prepare(workspace_id, messages_json, threshold_tokens, label, retention_seconds, consent)
|
|
5
|
+
|
|
6
|
+
@mcp.tool()
|
|
7
|
+
def restore_conversation_handoff(workspace_id: str, handoff_id: str) -> dict:
|
|
8
|
+
return container.handoff.restore(workspace_id, handoff_id)
|
|
9
|
+
|
|
10
|
+
@mcp.tool()
|
|
11
|
+
def list_conversation_handoffs(workspace_id: str, limit: int = 30) -> list[dict]:
|
|
12
|
+
return container.handoff.list(workspace_id, limit)
|
|
13
|
+
|
|
14
|
+
@mcp.tool()
|
|
15
|
+
def delete_conversation_handoff(workspace_id: str, handoff_id: str) -> dict:
|
|
16
|
+
return container.handoff.delete(workspace_id, handoff_id)
|
|
17
|
+
|
|
18
|
+
@mcp.tool()
|
|
19
|
+
def purge_expired_handoffs() -> dict:
|
|
20
|
+
return container.handoff.purge()
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
def register_memory_tools(mcp, container) -> None:
|
|
2
|
+
@mcp.tool()
|
|
3
|
+
def memory_save(workspace_id: str, key: str, value: str, mtype: str = "semantic", tags: str = "", scope: str = "workspace", source: str = "user", confidence: float = 1.0, expires_at: float | None = None) -> dict:
|
|
4
|
+
return container.memories.save(workspace_id, key, value, mtype, tags, scope, source, confidence, expires_at)
|
|
5
|
+
|
|
6
|
+
@mcp.tool()
|
|
7
|
+
def memory_search(workspace_id: str, query: str, mtype: str = "", top_k: int = 5, min_sim: float = 0.12) -> list[dict]:
|
|
8
|
+
return container.memories.search(workspace_id, query, mtype, top_k, min_sim)
|
|
9
|
+
|
|
10
|
+
@mcp.tool()
|
|
11
|
+
def memory_delete(workspace_id: str, key: str, mtype: str = "semantic") -> dict:
|
|
12
|
+
return container.memories.delete(workspace_id, key, mtype)
|
|
13
|
+
|
|
14
|
+
@mcp.tool()
|
|
15
|
+
def memory_list(workspace_id: str, mtype: str = "", limit: int = 30) -> list[str]:
|
|
16
|
+
return container.memories.list(workspace_id, mtype, limit)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
def register_workspace_tools(mcp, container, default_workspace_id: str) -> None:
|
|
2
|
+
@mcp.tool()
|
|
3
|
+
def register_workspace(workspace_id: str, roots: list[str], initial_refresh: bool = False) -> dict:
|
|
4
|
+
return container.workspace.register(workspace_id, roots, initial_refresh)
|
|
5
|
+
|
|
6
|
+
@mcp.tool()
|
|
7
|
+
def refresh_workspace(workspace_id: str = default_workspace_id, paths: list[str] | None = None) -> dict:
|
|
8
|
+
return container.refresh.execute(workspace_id, paths)
|
|
9
|
+
|
|
10
|
+
@mcp.tool()
|
|
11
|
+
def invalidate_context(workspace_id: str, target: str) -> dict:
|
|
12
|
+
return container.refresh.invalidate(workspace_id, target)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from harness_context.mcp.tool_handlers import (
|
|
2
|
+
register_context_tools,
|
|
3
|
+
register_ecc_tools,
|
|
4
|
+
register_handoff_tools,
|
|
5
|
+
register_memory_tools,
|
|
6
|
+
register_workspace_tools,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register_tools(mcp, container, default_workspace_id: str) -> None:
|
|
11
|
+
register_workspace_tools(mcp, container, default_workspace_id)
|
|
12
|
+
register_context_tools(mcp, container, default_workspace_id)
|
|
13
|
+
register_memory_tools(mcp, container)
|
|
14
|
+
register_handoff_tools(mcp, container)
|
|
15
|
+
register_ecc_tools(mcp, container)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import ClassVar
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class StructuredEventSink:
|
|
11
|
+
_FIELDS: ClassVar = {"request_id", "method", "outcome", "duration_ms"}
|
|
12
|
+
|
|
13
|
+
def __init__(self, path: str | Path):
|
|
14
|
+
self.path = Path(path)
|
|
15
|
+
self._lock = threading.Lock()
|
|
16
|
+
|
|
17
|
+
def emit(self, event: str, **fields: object) -> dict[str, object]:
|
|
18
|
+
unknown = set(fields) - self._FIELDS
|
|
19
|
+
if unknown:
|
|
20
|
+
raise ValueError(f"unsupported event fields: {sorted(unknown)}")
|
|
21
|
+
payload = {"event": event, "timestamp": round(time.time(), 3), **fields}
|
|
22
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
with self._lock, self.path.open("a", encoding="utf-8") as handle:
|
|
24
|
+
handle.write(json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n")
|
|
25
|
+
return payload
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from collections import Counter
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class LocalMetrics:
|
|
6
|
+
def __init__(self):
|
|
7
|
+
self._values: Counter[str] = Counter()
|
|
8
|
+
self._lock = threading.Lock()
|
|
9
|
+
|
|
10
|
+
def record_request(self, outcome: str, duration_ms: int) -> None:
|
|
11
|
+
if outcome not in {"ok", "error", "rejected"}:
|
|
12
|
+
raise ValueError("unsupported request outcome")
|
|
13
|
+
with self._lock:
|
|
14
|
+
self._values["requests_total"] += 1
|
|
15
|
+
self._values[f"requests_{outcome}"] += 1
|
|
16
|
+
self._values["request_duration_ms_total"] += max(0, int(duration_ms))
|
|
17
|
+
|
|
18
|
+
def snapshot(self) -> dict[str, int]:
|
|
19
|
+
with self._lock:
|
|
20
|
+
return dict(sorted(self._values.items()))
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from harness_context.branding import LEGACY_WORKSPACE_DIR_NAME, WORKSPACE_DIR_NAME
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def workspace_data_dir(workspace: str | Path) -> Path:
|
|
9
|
+
return Path(workspace).resolve() / WORKSPACE_DIR_NAME
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def legacy_workspace_data_dir(workspace: str | Path) -> Path:
|
|
13
|
+
return Path(workspace).resolve() / LEGACY_WORKSPACE_DIR_NAME
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def workspace_state_dir(workspace: str | Path) -> Path:
|
|
17
|
+
preferred = workspace_data_dir(workspace) / "state"
|
|
18
|
+
legacy = legacy_workspace_data_dir(workspace) / "state"
|
|
19
|
+
return preferred if preferred.exists() or not legacy.exists() else legacy
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def workspace_runtime_dir(workspace: str | Path) -> Path:
|
|
23
|
+
preferred = workspace_data_dir(workspace)
|
|
24
|
+
legacy = legacy_workspace_data_dir(workspace)
|
|
25
|
+
return preferred if (preferred / "run.pid").exists() or not (legacy / "run.pid").exists() else legacy
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def workspace_installer_dir(workspace: str | Path) -> Path:
|
|
29
|
+
preferred = workspace_data_dir(workspace) / "installer"
|
|
30
|
+
legacy = legacy_workspace_data_dir(workspace) / "installer"
|
|
31
|
+
return preferred if preferred.exists() or not legacy.exists() else legacy
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def workspace_data_dirs(workspace: str | Path) -> tuple[Path, Path]:
|
|
35
|
+
return workspace_data_dir(workspace), legacy_workspace_data_dir(workspace)
|