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,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from dataclasses import asdict, is_dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProviderFormatter:
|
|
9
|
+
def format(self, payload: Any) -> dict[str, Any]:
|
|
10
|
+
if hasattr(payload, "to_dict"):
|
|
11
|
+
payload = payload.to_dict()
|
|
12
|
+
elif is_dataclass(payload):
|
|
13
|
+
payload = asdict(payload)
|
|
14
|
+
if not isinstance(payload, dict):
|
|
15
|
+
raise TypeError("provider payload must be a mapping or dataclass")
|
|
16
|
+
return deepcopy(payload)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CodexFormatter(ProviderFormatter):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ClaudeCodeFormatter(ProviderFormatter):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CursorFormatter(ProviderFormatter):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GenericMcpFormatter(ProviderFormatter):
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
FORMATTERS = {
|
|
36
|
+
"codex": CodexFormatter,
|
|
37
|
+
"claude-code": ClaudeCodeFormatter,
|
|
38
|
+
"cursor": CursorFormatter,
|
|
39
|
+
"generic-mcp": GenericMcpFormatter,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_formatter(name: str) -> ProviderFormatter:
|
|
44
|
+
try:
|
|
45
|
+
return FORMATTERS[name]()
|
|
46
|
+
except KeyError as exc:
|
|
47
|
+
raise ValueError(f"unknown provider formatter: {name}") from exc
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from harness_context.branding import MCP_SERVER_KEY
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ClientProfile:
|
|
11
|
+
name: str
|
|
12
|
+
config_format: str
|
|
13
|
+
default_config: Path
|
|
14
|
+
server_key: str = MCP_SERVER_KEY
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
PROFILES = {
|
|
18
|
+
"codex": ClientProfile("codex", "toml", Path("~/.codex/config.toml")),
|
|
19
|
+
"claude-code": ClientProfile("claude-code", "json", Path("~/.claude.json")),
|
|
20
|
+
"cursor": ClientProfile("cursor", "json", Path("~/.cursor/mcp.json")),
|
|
21
|
+
"generic-mcp": ClientProfile("generic-mcp", "json", Path("~/.config/mcp/servers.json")),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_profile(name: str) -> ClientProfile:
|
|
26
|
+
try:
|
|
27
|
+
return PROFILES[name]
|
|
28
|
+
except KeyError as exc:
|
|
29
|
+
raise ValueError(f"unknown client profile: {name}") from exc
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class EccInstallation:
|
|
10
|
+
project_memory_root: Path | None
|
|
11
|
+
user_memory_root: Path | None
|
|
12
|
+
allow_user_scope: bool
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def available(self) -> bool:
|
|
16
|
+
return self.project_memory_root is not None or self.user_memory_root is not None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _existing_directory(path: str | Path) -> Path | None:
|
|
20
|
+
candidate = Path(path).expanduser()
|
|
21
|
+
if candidate.is_symlink() or not candidate.is_dir():
|
|
22
|
+
return None
|
|
23
|
+
return candidate.resolve()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def detect_ecc(
|
|
27
|
+
workspace_root: str | Path,
|
|
28
|
+
*,
|
|
29
|
+
allow_user_scope: bool = False,
|
|
30
|
+
environment: dict[str, str] | None = None,
|
|
31
|
+
) -> EccInstallation:
|
|
32
|
+
"""Detect existing ECC memory vaults without installing or cloning ECC."""
|
|
33
|
+
env = environment if environment is not None else os.environ
|
|
34
|
+
workspace = Path(workspace_root).expanduser().resolve(strict=True)
|
|
35
|
+
project_override = env.get("ECC_MEMORY_PROJECT_ROOT")
|
|
36
|
+
user_override = env.get("ECC_MEMORY_USER_ROOT")
|
|
37
|
+
project_root = _existing_directory(project_override or workspace / ".ecc" / "memory")
|
|
38
|
+
user_root = None
|
|
39
|
+
if allow_user_scope:
|
|
40
|
+
user_root = _existing_directory(user_override or Path.home() / ".ecc" / "memory")
|
|
41
|
+
return EccInstallation(project_root, user_root, allow_user_scope)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from harness_context.adapters.ecc.provenance import ecc_provenance
|
|
4
|
+
|
|
5
|
+
_TYPE_MAP = {
|
|
6
|
+
"handoff": "episodic",
|
|
7
|
+
"lesson": "procedural",
|
|
8
|
+
"runbook": "procedural",
|
|
9
|
+
"context": "semantic",
|
|
10
|
+
"decision": "semantic",
|
|
11
|
+
"fact": "semantic",
|
|
12
|
+
"note": "semantic",
|
|
13
|
+
"preference": "semantic",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def map_ecc_memory(memory: dict, source_path) -> dict:
|
|
18
|
+
"""Map ECC context into Harness's provider-neutral read-only memory shape."""
|
|
19
|
+
return {
|
|
20
|
+
"workspace_id": "external:ecc",
|
|
21
|
+
"type": _TYPE_MAP[memory["kind"]],
|
|
22
|
+
"key": memory["id"],
|
|
23
|
+
"value": memory["body"],
|
|
24
|
+
"title": memory["title"],
|
|
25
|
+
"tags": ",".join(memory["tags"]),
|
|
26
|
+
"scope": memory["scope"],
|
|
27
|
+
"source": f"ecc:{memory['sourceHarness']}",
|
|
28
|
+
"confidence": 0.5,
|
|
29
|
+
"status": memory["status"],
|
|
30
|
+
"links": list(memory["links"]),
|
|
31
|
+
"provenance": ecc_provenance(memory, source_path),
|
|
32
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from harness_context.adapters.ecc.detection import EccInstallation
|
|
8
|
+
from harness_context.adapters.ecc.mapping import map_ecc_memory
|
|
9
|
+
from harness_context.tokenize import tokens
|
|
10
|
+
|
|
11
|
+
_FIELDS = {
|
|
12
|
+
"schema": "schema", "id": "id", "title": "title", "kind": "kind",
|
|
13
|
+
"scope": "scope", "trust": "trust", "status": "status",
|
|
14
|
+
"source_harness": "sourceHarness", "target_harnesses": "targetHarnesses",
|
|
15
|
+
"tags": "tags", "links": "links", "created_at": "createdAt",
|
|
16
|
+
"updated_at": "updatedAt",
|
|
17
|
+
}
|
|
18
|
+
_KINDS = {"context", "decision", "fact", "handoff", "lesson", "note", "preference", "runbook"}
|
|
19
|
+
_SCOPES = {"project", "team", "user"}
|
|
20
|
+
_STATUSES = {"active", "rejected", "superseded"}
|
|
21
|
+
_ID = re.compile(r"^mem_[a-z0-9][a-z0-9_-]{2,127}$")
|
|
22
|
+
_SLUG = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
|
|
23
|
+
_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$")
|
|
24
|
+
_SECRETS = (
|
|
25
|
+
re.compile(r"\bsk-[A-Za-z0-9_-]{16,}\b", re.IGNORECASE),
|
|
26
|
+
re.compile(r"\b(?:sk|rk)_live_[A-Za-z0-9]{16,}\b"),
|
|
27
|
+
re.compile(r"\bnpm_[A-Za-z0-9]{20,}\b"),
|
|
28
|
+
re.compile(r"\bhf_[A-Za-z0-9]{20,}\b"),
|
|
29
|
+
re.compile(r"\bgh[pors]_[A-Za-z0-9]{16,}\b"),
|
|
30
|
+
re.compile(r"\bgithub_pat_[A-Za-z0-9_]{16,}\b"),
|
|
31
|
+
re.compile(r"\bAIza[A-Za-z0-9_-]{16,}\b"),
|
|
32
|
+
re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"),
|
|
33
|
+
re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b"),
|
|
34
|
+
re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----"),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _has_unsafe_characters(value: str, allow_whitespace: bool = False) -> bool:
|
|
39
|
+
for character in value:
|
|
40
|
+
code = ord(character)
|
|
41
|
+
if allow_whitespace and character in "\t\n\r":
|
|
42
|
+
continue
|
|
43
|
+
if code <= 0x1F or 0x7F <= code <= 0x9F or 0x202A <= code <= 0x202E or 0x2066 <= code <= 0x2069:
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class EccMemoryReader:
|
|
49
|
+
"""Bounded, read-only reader for ECC's ecc.memory.v1 Markdown vault."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, installation: EccInstallation, target_harness: str = "ctxora"):
|
|
52
|
+
self.installation = installation
|
|
53
|
+
self.target_harness = target_harness
|
|
54
|
+
self.target_harnesses = {target_harness, "harness-context"}
|
|
55
|
+
|
|
56
|
+
def status(self) -> dict:
|
|
57
|
+
roots = [root for root in (self.installation.project_memory_root, self.installation.user_memory_root) if root]
|
|
58
|
+
return {
|
|
59
|
+
"available": self.installation.available,
|
|
60
|
+
"read_only": True,
|
|
61
|
+
"project_memory_root": str(self.installation.project_memory_root) if self.installation.project_memory_root else None,
|
|
62
|
+
"user_memory_root": str(self.installation.user_memory_root) if self.installation.user_memory_root else None,
|
|
63
|
+
"roots": len(roots),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _parse(path: Path) -> dict:
|
|
68
|
+
if path.is_symlink() or path.stat().st_size > 128 * 1024:
|
|
69
|
+
raise ValueError("ECC memory file is unsafe or oversized")
|
|
70
|
+
source = path.read_text("utf-8")
|
|
71
|
+
if not source.startswith("---\n"):
|
|
72
|
+
raise ValueError("ECC memory document must start with frontmatter")
|
|
73
|
+
frontmatter, separator, body = source[4:].partition("\n---\n")
|
|
74
|
+
if not separator:
|
|
75
|
+
raise ValueError("ECC memory document has no closing frontmatter")
|
|
76
|
+
parsed = {}
|
|
77
|
+
for line in frontmatter.splitlines():
|
|
78
|
+
key, marker, value = line.partition(":")
|
|
79
|
+
if not marker or key not in _FIELDS or _FIELDS[key] in parsed:
|
|
80
|
+
raise ValueError("invalid ECC memory frontmatter")
|
|
81
|
+
parsed[_FIELDS[key]] = json.loads(value.strip())
|
|
82
|
+
required = set(_FIELDS.values())
|
|
83
|
+
if set(parsed) != required:
|
|
84
|
+
raise ValueError("incomplete ECC memory frontmatter")
|
|
85
|
+
parsed["body"] = body.strip()
|
|
86
|
+
if parsed["schema"] != "ecc.memory.v1" or not _ID.fullmatch(parsed["id"]):
|
|
87
|
+
raise ValueError("unsupported ECC memory schema or id")
|
|
88
|
+
if not isinstance(parsed["title"], str) or not parsed["title"].strip() or len(parsed["title"]) > 200:
|
|
89
|
+
raise ValueError("invalid ECC memory title")
|
|
90
|
+
if _has_unsafe_characters(parsed["title"]):
|
|
91
|
+
raise ValueError("unsafe ECC memory title")
|
|
92
|
+
if parsed["kind"] not in _KINDS or parsed["scope"] not in _SCOPES:
|
|
93
|
+
raise ValueError("unsupported ECC memory kind or scope")
|
|
94
|
+
if parsed["trust"] != "unreviewed" or parsed["status"] not in _STATUSES:
|
|
95
|
+
raise ValueError("unsupported ECC memory trust or status")
|
|
96
|
+
if not _SLUG.fullmatch(parsed["sourceHarness"]):
|
|
97
|
+
raise ValueError("invalid ECC source harness")
|
|
98
|
+
if not _TIMESTAMP.fullmatch(parsed["createdAt"]) or not _TIMESTAMP.fullmatch(parsed["updatedAt"]):
|
|
99
|
+
raise ValueError("invalid ECC timestamp")
|
|
100
|
+
if not parsed["body"] or len(parsed["body"].encode("utf-8")) > 64 * 1024:
|
|
101
|
+
raise ValueError("invalid ECC memory body")
|
|
102
|
+
if _has_unsafe_characters(parsed["body"], allow_whitespace=True):
|
|
103
|
+
raise ValueError("unsafe ECC memory body")
|
|
104
|
+
if any(pattern.search(parsed["body"]) for pattern in _SECRETS):
|
|
105
|
+
raise ValueError("ECC memory contains a secret-like value")
|
|
106
|
+
for field in ("targetHarnesses", "tags"):
|
|
107
|
+
if not isinstance(parsed[field], list) or not all(isinstance(value, str) and _SLUG.fullmatch(value) for value in parsed[field]):
|
|
108
|
+
raise ValueError(f"invalid ECC {field}")
|
|
109
|
+
if len(parsed[field]) != len(set(parsed[field])):
|
|
110
|
+
raise ValueError(f"duplicate ECC {field}")
|
|
111
|
+
if not parsed["targetHarnesses"] or len(parsed["targetHarnesses"]) > 32 or len(parsed["tags"]) > 32:
|
|
112
|
+
raise ValueError("invalid ECC target or tag count")
|
|
113
|
+
if not isinstance(parsed["links"], list) or not all(_ID.fullmatch(value) for value in parsed["links"]):
|
|
114
|
+
raise ValueError("invalid ECC links")
|
|
115
|
+
if len(parsed["links"]) > 64:
|
|
116
|
+
raise ValueError("too many ECC links")
|
|
117
|
+
if len(parsed["links"]) != len(set(parsed["links"])):
|
|
118
|
+
raise ValueError("duplicate ECC links")
|
|
119
|
+
return parsed
|
|
120
|
+
|
|
121
|
+
def read(self) -> tuple[list[dict], list[dict]]:
|
|
122
|
+
entries, diagnostics = [], []
|
|
123
|
+
roots = [root for root in (self.installation.project_memory_root, self.installation.user_memory_root) if root]
|
|
124
|
+
for root in roots:
|
|
125
|
+
for path in sorted(root.rglob("*.md"))[:1_000]:
|
|
126
|
+
try:
|
|
127
|
+
memory = self._parse(path)
|
|
128
|
+
is_user_root = root == self.installation.user_memory_root
|
|
129
|
+
allowed_scopes = {"user"} if is_user_root else {"project", "team"}
|
|
130
|
+
if memory["scope"] not in allowed_scopes:
|
|
131
|
+
raise ValueError("ECC memory scope does not match its vault location")
|
|
132
|
+
if memory["status"] != "active":
|
|
133
|
+
continue
|
|
134
|
+
if not self.target_harnesses.intersection(memory["targetHarnesses"]) and "all" not in memory["targetHarnesses"]:
|
|
135
|
+
continue
|
|
136
|
+
entries.append(map_ecc_memory(memory, path))
|
|
137
|
+
except (OSError, UnicodeError, ValueError) as error:
|
|
138
|
+
diagnostics.append({"path": str(path), "error": str(error)})
|
|
139
|
+
counts = {}
|
|
140
|
+
for entry in entries:
|
|
141
|
+
counts[entry["key"]] = counts.get(entry["key"], 0) + 1
|
|
142
|
+
duplicates = {key for key, count in counts.items() if count > 1}
|
|
143
|
+
if duplicates:
|
|
144
|
+
entries = [entry for entry in entries if entry["key"] not in duplicates]
|
|
145
|
+
diagnostics.extend({"memory_id": key, "error": "duplicate ECC memory id"} for key in sorted(duplicates))
|
|
146
|
+
return entries, diagnostics[:100]
|
|
147
|
+
|
|
148
|
+
def search(self, query: str, limit: int = 4) -> dict:
|
|
149
|
+
entries, diagnostics = self.read()
|
|
150
|
+
query_tokens = set(tokens(query))
|
|
151
|
+
ranked = []
|
|
152
|
+
for entry in entries:
|
|
153
|
+
document = set(tokens(f"{entry['title']} {entry['tags']} {entry['value']}"))
|
|
154
|
+
score = len(query_tokens & document) / max(len(query_tokens), 1)
|
|
155
|
+
if not query_tokens or score > 0:
|
|
156
|
+
ranked.append((score, entry))
|
|
157
|
+
ranked.sort(key=lambda item: (-item[0], item[1]["key"]))
|
|
158
|
+
return {
|
|
159
|
+
"entries": [{**entry, "score": score} for score, entry in ranked[:max(0, limit)]],
|
|
160
|
+
"diagnostics": diagnostics,
|
|
161
|
+
"read_only": True,
|
|
162
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def ecc_provenance(memory: dict, source_path: Path) -> dict:
|
|
7
|
+
return {
|
|
8
|
+
"adapter": "ecc",
|
|
9
|
+
"schema": memory["schema"],
|
|
10
|
+
"ecc_memory_id": memory["id"],
|
|
11
|
+
"source_path": str(source_path),
|
|
12
|
+
"source_harness": memory["sourceHarness"],
|
|
13
|
+
"updated_at": memory["updatedAt"],
|
|
14
|
+
"trust": memory["trust"],
|
|
15
|
+
"read_only": True,
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Public API schemas."""
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from harness_context.api.v2.contracts import export_json_schemas, validate_response, validate_schema
|
|
2
|
+
from harness_context.api.v2.diagnostics import ContextDiagnostics
|
|
3
|
+
from harness_context.api.v2.enums import ContextStrategy, ErrorCode, Freshness
|
|
4
|
+
from harness_context.api.v2.errors import ErrorDetail, ErrorResponse, map_exception
|
|
5
|
+
from harness_context.api.v2.requests import PrepareContextRequest
|
|
6
|
+
from harness_context.api.v2.responses import ContextPackageV2
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ContextDiagnostics", "ContextPackageV2", "ContextStrategy", "ErrorCode",
|
|
10
|
+
"ErrorDetail", "ErrorResponse", "Freshness", "PrepareContextRequest",
|
|
11
|
+
"export_json_schemas", "map_exception", "validate_response", "validate_schema",
|
|
12
|
+
]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
OBJECT = {"type": "object", "additionalProperties": True}
|
|
8
|
+
ARRAY_OBJECT = {"type": "array", "items": OBJECT}
|
|
9
|
+
ARRAY_STRING = {"type": "array", "items": {"type": "string"}}
|
|
10
|
+
SCHEMA_VERSION = "https://json-schema.org/draft/2020-12/schema"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _object(properties: dict[str, Any], required: tuple[str, ...] = ()) -> dict[str, Any]:
|
|
14
|
+
schema: dict[str, Any] = {
|
|
15
|
+
"$schema": SCHEMA_VERSION,
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": properties,
|
|
18
|
+
"additionalProperties": False,
|
|
19
|
+
}
|
|
20
|
+
if required:
|
|
21
|
+
schema["required"] = list(required)
|
|
22
|
+
return schema
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
S = {"type": "string"}
|
|
26
|
+
I = {"type": "integer"}
|
|
27
|
+
N = {"type": "number"}
|
|
28
|
+
B = {"type": "boolean"}
|
|
29
|
+
NULLABLE_S = {"type": ["string", "null"]}
|
|
30
|
+
NULLABLE_N = {"type": ["number", "null"]}
|
|
31
|
+
|
|
32
|
+
REQUEST_SCHEMAS: dict[str, dict[str, Any]] = {
|
|
33
|
+
"register_workspace": _object({"workspace_id": S, "roots": ARRAY_STRING, "initial_refresh": B}, ("workspace_id", "roots")),
|
|
34
|
+
"refresh_workspace": _object({"workspace_id": S, "paths": {"type": ["array", "null"], "items": S}}),
|
|
35
|
+
"plan_context": _object({"workspace_id": S, "query": S, "available_input_tokens": I, "strategy_override": S}, ("workspace_id", "query", "available_input_tokens")),
|
|
36
|
+
"retrieve_context": _object({"workspace_id": S, "query": S, "top_k": I, "graph_expand": B, "token_budget": I}, ("workspace_id", "query")),
|
|
37
|
+
"prepare_context": _object({"workspace_id": S, "query": S, "available_input_tokens": I, "preferred_strategy": S, "include_memory": B, "include_handoff": B, "handoff_id": S, "include_ecc": B, "freshness": S, "deadline_ms": I}, ("workspace_id", "query", "available_input_tokens")),
|
|
38
|
+
"context_stats": _object({"workspace_id": S}),
|
|
39
|
+
"invalidate_context": _object({"workspace_id": S, "target": S}, ("workspace_id", "target")),
|
|
40
|
+
"memory_save": _object({"workspace_id": S, "key": S, "value": S, "mtype": S, "tags": S, "scope": S, "source": S, "confidence": N, "expires_at": NULLABLE_N}, ("workspace_id", "key", "value")),
|
|
41
|
+
"memory_search": _object({"workspace_id": S, "query": S, "mtype": S, "top_k": I, "min_sim": N}, ("workspace_id", "query")),
|
|
42
|
+
"memory_delete": _object({"workspace_id": S, "key": S, "mtype": S}, ("workspace_id", "key")),
|
|
43
|
+
"memory_list": _object({"workspace_id": S, "mtype": S, "limit": I}, ("workspace_id",)),
|
|
44
|
+
"handoff_conversation": _object({"workspace_id": S, "messages_json": S, "threshold_tokens": I, "label": S, "retention_seconds": I, "consent": B}, ("workspace_id", "messages_json")),
|
|
45
|
+
"restore_conversation_handoff": _object({"workspace_id": S, "handoff_id": S}, ("workspace_id", "handoff_id")),
|
|
46
|
+
"list_conversation_handoffs": _object({"workspace_id": S, "limit": I}, ("workspace_id",)),
|
|
47
|
+
"delete_conversation_handoff": _object({"workspace_id": S, "handoff_id": S}, ("workspace_id", "handoff_id")),
|
|
48
|
+
"purge_expired_handoffs": _object({}),
|
|
49
|
+
"ecc_status": _object({}),
|
|
50
|
+
"ecc_search": _object({"query": S, "limit": I}, ("query",)),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
RESPONSE_SCHEMAS = {name: {"$schema": SCHEMA_VERSION, **OBJECT} for name in REQUEST_SCHEMAS}
|
|
54
|
+
RESPONSE_SCHEMAS.update({
|
|
55
|
+
"memory_search": {"$schema": SCHEMA_VERSION, **ARRAY_OBJECT},
|
|
56
|
+
"memory_list": {"$schema": SCHEMA_VERSION, **ARRAY_STRING},
|
|
57
|
+
"list_conversation_handoffs": {"$schema": SCHEMA_VERSION, **ARRAY_OBJECT},
|
|
58
|
+
"prepare_context": _object({
|
|
59
|
+
"api_version": S, "workspace_id": S, "snapshot_id": S, "snapshot_version": I,
|
|
60
|
+
"plan": OBJECT, "stable_context": {"type": ["object", "null"]},
|
|
61
|
+
"evidence": {"type": ["object", "null"]}, "memory": ARRAY_OBJECT,
|
|
62
|
+
"handoff": {"type": ["object", "null"]}, "external_context": ARRAY_OBJECT,
|
|
63
|
+
"diagnostics": OBJECT,
|
|
64
|
+
}, ("api_version", "workspace_id", "snapshot_id", "snapshot_version", "plan", "stable_context", "evidence", "memory", "handoff", "external_context", "diagnostics")),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
ERROR_SCHEMA = _object({
|
|
68
|
+
"api_version": S,
|
|
69
|
+
"error": _object({"code": S, "message": S, "retryable": B, "details": OBJECT}, ("code", "message", "retryable", "details")),
|
|
70
|
+
}, ("api_version", "error"))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _matches_type(value: Any, expected: str) -> bool:
|
|
74
|
+
return {"object": isinstance(value, dict), "array": isinstance(value, list), "string": isinstance(value, str), "integer": isinstance(value, int) and not isinstance(value, bool), "number": isinstance(value, (int, float)) and not isinstance(value, bool), "boolean": isinstance(value, bool), "null": value is None}[expected]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def validate_schema(value: Any, schema: dict[str, Any], path: str = "$") -> None:
|
|
78
|
+
expected = schema.get("type")
|
|
79
|
+
expected_types = expected if isinstance(expected, list) else [expected]
|
|
80
|
+
if expected and not any(_matches_type(value, item) for item in expected_types):
|
|
81
|
+
raise TypeError(f"{path} must be {expected}")
|
|
82
|
+
if isinstance(value, dict):
|
|
83
|
+
for key in schema.get("required", []):
|
|
84
|
+
if key not in value:
|
|
85
|
+
raise ValueError(f"{path}.{key} is required")
|
|
86
|
+
properties = schema.get("properties", {})
|
|
87
|
+
if schema.get("additionalProperties") is False:
|
|
88
|
+
unknown = set(value) - set(properties)
|
|
89
|
+
if unknown:
|
|
90
|
+
raise ValueError(f"{path} contains unknown properties: {sorted(unknown)}")
|
|
91
|
+
for key, item in value.items():
|
|
92
|
+
if key in properties:
|
|
93
|
+
validate_schema(item, properties[key], f"{path}.{key}")
|
|
94
|
+
if isinstance(value, list) and "items" in schema:
|
|
95
|
+
for index, item in enumerate(value):
|
|
96
|
+
validate_schema(item, schema["items"], f"{path}[{index}]")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def validate_response(contract: str, value: Any) -> Any:
|
|
100
|
+
try:
|
|
101
|
+
schema = RESPONSE_SCHEMAS[contract]
|
|
102
|
+
except KeyError as error:
|
|
103
|
+
raise KeyError(f"unknown MCP v2 response contract: {contract}") from error
|
|
104
|
+
validate_schema(value, schema)
|
|
105
|
+
return value
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def export_json_schemas(directory: str | Path) -> list[Path]:
|
|
109
|
+
destination = Path(directory)
|
|
110
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
documents = {f"{name}.request.schema.json": schema for name, schema in REQUEST_SCHEMAS.items()}
|
|
112
|
+
documents.update({f"{name}.response.schema.json": schema for name, schema in RESPONSE_SCHEMAS.items()})
|
|
113
|
+
documents["error.response.schema.json"] = ERROR_SCHEMA
|
|
114
|
+
written = []
|
|
115
|
+
for filename, schema in sorted(documents.items()):
|
|
116
|
+
path = destination / filename
|
|
117
|
+
path.write_text(json.dumps(schema, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
118
|
+
written.append(path)
|
|
119
|
+
return written
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class ContextDiagnostics:
|
|
9
|
+
coverage: str = "not_applicable"
|
|
10
|
+
freshness: str = "current"
|
|
11
|
+
untrusted_content: bool = True
|
|
12
|
+
latency_ms: float = 0.0
|
|
13
|
+
ecc: dict[str, Any] = field(default_factory=dict)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ContextStrategy(str, Enum):
|
|
7
|
+
AUTO = "auto"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Freshness(str, Enum):
|
|
11
|
+
CURRENT = "current"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ErrorCode(str, Enum):
|
|
15
|
+
INTERNAL_ERROR = "internal_error"
|
|
16
|
+
INVALID_REQUEST = "invalid_request"
|
|
17
|
+
NOT_FOUND = "not_found"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from harness_context.api.v2.enums import ErrorCode
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ErrorDetail:
|
|
11
|
+
code: str
|
|
12
|
+
message: str
|
|
13
|
+
retryable: bool = False
|
|
14
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ErrorResponse:
|
|
19
|
+
error: ErrorDetail
|
|
20
|
+
api_version: str = "2.0"
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict[str, Any]:
|
|
23
|
+
return asdict(self)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def map_exception(error: Exception) -> ErrorResponse:
|
|
27
|
+
code = getattr(error, "code", None)
|
|
28
|
+
if isinstance(code, str):
|
|
29
|
+
return ErrorResponse(ErrorDetail(code=code, message=str(error)))
|
|
30
|
+
if isinstance(error, (TypeError, ValueError)):
|
|
31
|
+
return ErrorResponse(ErrorDetail(code=ErrorCode.INVALID_REQUEST.value, message=str(error)))
|
|
32
|
+
return ErrorResponse(ErrorDetail(code=ErrorCode.INTERNAL_ERROR.value, message=str(error)))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class PrepareContextRequest:
|
|
8
|
+
workspace_id: str
|
|
9
|
+
query: str
|
|
10
|
+
available_input_tokens: int
|
|
11
|
+
preferred_strategy: str = "auto"
|
|
12
|
+
include_memory: bool = True
|
|
13
|
+
include_handoff: bool = False
|
|
14
|
+
handoff_id: str = ""
|
|
15
|
+
include_ecc: bool = False
|
|
16
|
+
freshness: str = "current"
|
|
17
|
+
deadline_ms: int = 5_000
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class ContextPackageV2:
|
|
9
|
+
api_version: str
|
|
10
|
+
workspace_id: str
|
|
11
|
+
snapshot_id: str
|
|
12
|
+
snapshot_version: int
|
|
13
|
+
plan: dict[str, Any]
|
|
14
|
+
stable_context: dict[str, Any] | None
|
|
15
|
+
evidence: dict[str, Any] | None
|
|
16
|
+
memory: list[dict[str, Any]] = field(default_factory=list)
|
|
17
|
+
handoff: dict[str, Any] | None = None
|
|
18
|
+
external_context: list[dict[str, Any]] = field(default_factory=list)
|
|
19
|
+
diagnostics: dict[str, Any] = field(default_factory=dict)
|
|
20
|
+
|
|
21
|
+
def to_dict(self) -> dict[str, Any]:
|
|
22
|
+
return asdict(self)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from compact.handoff import ConversationHandoffStore
|
|
6
|
+
from harness_context.adapters.ecc import EccMemoryReader
|
|
7
|
+
from harness_context.application.ecc_service import EccService
|
|
8
|
+
from harness_context.application.handoff_service import HandoffService
|
|
9
|
+
from harness_context.application.memory_service import MemoryService
|
|
10
|
+
from harness_context.application.retrieval_service import RetrievalService
|
|
11
|
+
from harness_context.application.services import ContextService, RefreshService
|
|
12
|
+
from harness_context.application.workspace_service import WorkspaceService
|
|
13
|
+
from harness_context.engine import ContextEngine
|
|
14
|
+
from harness_context.storage import SnapshotStore
|
|
15
|
+
from memory.episodic import MemoryStore
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ApplicationContainer:
|
|
20
|
+
engine: ContextEngine
|
|
21
|
+
snapshots: SnapshotStore
|
|
22
|
+
refresh: RefreshService
|
|
23
|
+
context: ContextService
|
|
24
|
+
memory: MemoryStore
|
|
25
|
+
handoffs: ConversationHandoffStore
|
|
26
|
+
ecc: EccMemoryReader | None
|
|
27
|
+
workspace: WorkspaceService
|
|
28
|
+
retrieval: RetrievalService
|
|
29
|
+
memories: MemoryService
|
|
30
|
+
handoff: HandoffService
|
|
31
|
+
ecc_queries: EccService
|