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,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from compact.handoff import ConversationHandoffStore
|
|
6
|
+
from harness_context.adapters.ecc import EccMemoryReader
|
|
7
|
+
from harness_context.api.v2 import ContextPackageV2, PrepareContextRequest
|
|
8
|
+
from harness_context.engine import ContextEngine
|
|
9
|
+
from harness_context.schemas import HarnessError
|
|
10
|
+
from memory.episodic import MemoryStore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ContextService:
|
|
14
|
+
def __init__(self, engine: ContextEngine, memory: MemoryStore, handoffs: ConversationHandoffStore, ecc: EccMemoryReader | None = None):
|
|
15
|
+
self.engine = engine
|
|
16
|
+
self.memory = memory
|
|
17
|
+
self.handoffs = handoffs
|
|
18
|
+
self.ecc = ecc
|
|
19
|
+
|
|
20
|
+
def prepare(self, request: PrepareContextRequest) -> ContextPackageV2:
|
|
21
|
+
if request.deadline_ms <= 0:
|
|
22
|
+
raise HarnessError("invalid_deadline", "deadline_ms must be positive")
|
|
23
|
+
started = time.perf_counter()
|
|
24
|
+
preferred = "" if request.preferred_strategy == "auto" else request.preferred_strategy
|
|
25
|
+
with self.engine.snapshot_pin(request.workspace_id) as pin:
|
|
26
|
+
if request.freshness == "current" and not self.engine.snapshot_is_current(request.workspace_id, pin.state):
|
|
27
|
+
raise HarnessError("stale_snapshot", "active snapshot is stale; refresh_workspace is required")
|
|
28
|
+
prepared = self.engine.prepare_context(request.workspace_id, request.query, request.available_input_tokens, preferred, pin)
|
|
29
|
+
evidence = prepared["retrieval"]
|
|
30
|
+
memory = self.memory.search(request.query, top_k=4, workspace_id=request.workspace_id) if request.include_memory else []
|
|
31
|
+
handoff = None
|
|
32
|
+
if request.include_handoff:
|
|
33
|
+
if not request.handoff_id:
|
|
34
|
+
raise HarnessError("missing_handoff_id", "handoff_id is required when include_handoff is true")
|
|
35
|
+
handoff = self.handoffs.restore(request.handoff_id, request.workspace_id)
|
|
36
|
+
if handoff is None:
|
|
37
|
+
raise HarnessError("handoff_not_found", "handoff was not found in this workspace")
|
|
38
|
+
external_context, ecc_diagnostics = [], []
|
|
39
|
+
if request.include_ecc:
|
|
40
|
+
if self.ecc is None or not self.ecc.installation.available:
|
|
41
|
+
raise HarnessError("ecc_not_available", "ECC adapter is not enabled or no local ECC vault was detected")
|
|
42
|
+
ecc_result = self.ecc.search(request.query, limit=4)
|
|
43
|
+
external_context, ecc_diagnostics = ecc_result["entries"], ecc_result["diagnostics"]
|
|
44
|
+
elapsed_ms = (time.perf_counter() - started) * 1000
|
|
45
|
+
if elapsed_ms > request.deadline_ms:
|
|
46
|
+
raise HarnessError("deadline_exceeded", "context preparation exceeded deadline_ms")
|
|
47
|
+
diagnostics = {"coverage": evidence.get("coverage", "not_applicable") if evidence else "not_applicable", "freshness": request.freshness, "untrusted_content": True, "latency_ms": round(elapsed_ms, 3), "ecc": {"enabled": self.ecc is not None, "diagnostics": ecc_diagnostics}}
|
|
48
|
+
return ContextPackageV2(api_version="2.0", workspace_id=request.workspace_id, snapshot_id=prepared["snapshot_id"], snapshot_version=prepared["snapshot_version"], plan=prepared["plan"], stable_context=prepared["bundle"], evidence=evidence, memory=memory, handoff=handoff, external_context=external_context, diagnostics=diagnostics)
|
|
49
|
+
|
|
50
|
+
def prepare_values(self, **values) -> dict:
|
|
51
|
+
return self.prepare(PrepareContextRequest(**values)).to_dict()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class EccService:
|
|
2
|
+
def __init__(self, reader): self.reader = reader
|
|
3
|
+
def status(self): return self.reader.status() if self.reader else {"enabled": False, "available": False, "read_only": True}
|
|
4
|
+
def search(self, query, limit=4):
|
|
5
|
+
if self.reader is None or not self.reader.installation.available:
|
|
6
|
+
raise ValueError("ECC adapter is not enabled or no local ECC vault was detected")
|
|
7
|
+
return self.reader.search(query, limit)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
class HandoffService:
|
|
2
|
+
def __init__(self, store): self.store = store
|
|
3
|
+
def prepare(self, workspace_id, messages_json, threshold_tokens=30_000, label="", retention_seconds=604_800, consent=False): return self.store.prepare(messages_json, threshold_tokens, label, workspace_id, retention_seconds, consent)
|
|
4
|
+
def restore(self, workspace_id, handoff_id):
|
|
5
|
+
result = self.store.restore(handoff_id, workspace_id)
|
|
6
|
+
if result is None:
|
|
7
|
+
raise ValueError("handoff not found")
|
|
8
|
+
return result
|
|
9
|
+
def list(self, workspace_id, limit=30): return self.store.list(workspace_id, limit)
|
|
10
|
+
def delete(self, workspace_id, handoff_id): return {"deleted": self.store.delete(handoff_id, workspace_id)}
|
|
11
|
+
def purge(self): return {"purged": self.store.purge_expired()}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from memory.episodic import MemoryType
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MemoryService:
|
|
5
|
+
def __init__(self, store): self.store = store
|
|
6
|
+
def save(self, workspace_id, key, value, mtype="semantic", tags="", scope="workspace", source="user", confidence=1.0, expires_at=None): return self.store.save(MemoryType(mtype), key, value, tags, workspace_id=workspace_id, scope=scope, source=source, confidence=confidence, expires_at=expires_at)
|
|
7
|
+
def search(self, workspace_id, query, mtype="", top_k=5, min_sim=0.12): return self.store.search(query, MemoryType(mtype) if mtype else None, top_k, min_sim, workspace_id)
|
|
8
|
+
def delete(self, workspace_id, key, mtype="semantic"): return {"deleted": self.store.delete(MemoryType(mtype), key, workspace_id)}
|
|
9
|
+
def list(self, workspace_id, mtype="", limit=30): return self.store.list_keys(MemoryType(mtype) if mtype else None, limit, workspace_id)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from harness_context.api.v2 import ContextPackageV2, PrepareContextRequest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RefreshCommands(Protocol):
|
|
9
|
+
def execute(self, workspace_id: str, paths: list[str] | None = None) -> dict: ...
|
|
10
|
+
def invalidate(self, workspace_id: str, target: str) -> dict: ...
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ContextQueries(Protocol):
|
|
14
|
+
def prepare(self, request: PrepareContextRequest) -> ContextPackageV2: ...
|
|
15
|
+
def prepare_values(self, **values) -> dict: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WorkspaceCommands(Protocol):
|
|
19
|
+
def register(self, workspace_id: str, roots: list[str], initial_refresh: bool = False) -> dict: ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RetrievalQueries(Protocol):
|
|
23
|
+
def plan(self, workspace_id: str, query: str, available_input_tokens: int, strategy_override: str = "") -> dict: ...
|
|
24
|
+
def retrieve(self, workspace_id: str, query: str, top_k: int = 12, graph_expand: bool = True, token_budget: int = 4_000) -> dict: ...
|
|
25
|
+
def stats(self, workspace_id: str) -> dict: ...
|
|
26
|
+
def snapshot_is_current(self, workspace_id: str) -> bool: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MemoryCommandsQueries(Protocol):
|
|
30
|
+
def save(self, workspace_id: str, key: str, value: str, **options) -> dict: ...
|
|
31
|
+
def search(self, workspace_id: str, query: str, **options) -> list[dict]: ...
|
|
32
|
+
def delete(self, workspace_id: str, key: str, mtype: str = "semantic") -> dict: ...
|
|
33
|
+
def list(self, workspace_id: str, mtype: str = "", limit: int = 30) -> list[str]: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HandoffCommandsQueries(Protocol):
|
|
37
|
+
def prepare(self, workspace_id: str, messages_json: str, **options) -> dict: ...
|
|
38
|
+
def restore(self, workspace_id: str, handoff_id: str) -> dict: ...
|
|
39
|
+
def list(self, workspace_id: str, limit: int = 30) -> list[dict]: ...
|
|
40
|
+
def delete(self, workspace_id: str, handoff_id: str) -> dict: ...
|
|
41
|
+
def purge(self) -> dict: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class EccQueries(Protocol):
|
|
45
|
+
def status(self) -> dict: ...
|
|
46
|
+
def search(self, query: str, limit: int = 4) -> dict: ...
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from harness_context.engine import ContextEngine
|
|
2
|
+
from harness_context.storage import SnapshotStore
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RefreshService:
|
|
6
|
+
def __init__(self, engine: ContextEngine, snapshots: SnapshotStore):
|
|
7
|
+
self.engine, self.snapshots = engine, snapshots
|
|
8
|
+
|
|
9
|
+
def execute(self, workspace_id: str, paths: list[str] | None = None) -> dict:
|
|
10
|
+
candidate_id = self.snapshots.begin_candidate(workspace_id)
|
|
11
|
+
try:
|
|
12
|
+
candidate, result = self.engine.build_workspace_snapshot(workspace_id, paths)
|
|
13
|
+
self.engine.prepare_bundle(workspace_id, _state=candidate, _record=True)
|
|
14
|
+
self.snapshots.validate_candidate(candidate_id, self.engine.export_state(workspace_id, candidate))
|
|
15
|
+
self.snapshots.promote_candidate(candidate_id)
|
|
16
|
+
self.engine.activate_snapshot(workspace_id, candidate)
|
|
17
|
+
return result
|
|
18
|
+
except Exception as error:
|
|
19
|
+
self.snapshots.fail_candidate(candidate_id, error)
|
|
20
|
+
raise
|
|
21
|
+
|
|
22
|
+
def invalidate(self, workspace_id: str, target: str) -> dict:
|
|
23
|
+
result = self.engine.invalidate(workspace_id, target)
|
|
24
|
+
self.snapshots.promote(self.engine.export_snapshot(workspace_id))
|
|
25
|
+
return result
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class RetrievalService:
|
|
2
|
+
def __init__(self, engine, ecc=None):
|
|
3
|
+
self.engine, self.ecc = engine, ecc
|
|
4
|
+
|
|
5
|
+
def plan(self, workspace_id, query, available_input_tokens, strategy_override=""):
|
|
6
|
+
with self.engine.snapshot_pin(workspace_id) as pin:
|
|
7
|
+
return self.engine.plan_context(workspace_id, query, available_input_tokens, strategy_override, pin.state)
|
|
8
|
+
|
|
9
|
+
def retrieve(self, workspace_id, query, top_k=12, graph_expand=True, token_budget=4_000):
|
|
10
|
+
with self.engine.snapshot_pin(workspace_id) as pin:
|
|
11
|
+
result = self.engine.retrieve_context(workspace_id, query, top_k, graph_expand, token_budget, pin.state)
|
|
12
|
+
result["snapshot_id"] = pin.snapshot_id
|
|
13
|
+
result["snapshot_version"] = pin.snapshot_version
|
|
14
|
+
return result
|
|
15
|
+
|
|
16
|
+
def stats(self, workspace_id):
|
|
17
|
+
result = self.engine.stats(workspace_id)
|
|
18
|
+
result["adapters"] = {"ecc": self.ecc.status() if self.ecc else {"enabled": False, "available": False, "read_only": True}}
|
|
19
|
+
return result
|
|
20
|
+
|
|
21
|
+
def snapshot_is_current(self, workspace_id):
|
|
22
|
+
return self.engine.snapshot_is_current(workspace_id)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class WorkspaceService:
|
|
5
|
+
def __init__(self, engine, refresh, default_workspace_id):
|
|
6
|
+
self.engine, self.refresh, self.default_workspace_id = engine, refresh, default_workspace_id
|
|
7
|
+
|
|
8
|
+
def register(self, workspace_id, roots, initial_refresh=False):
|
|
9
|
+
if workspace_id != self.default_workspace_id:
|
|
10
|
+
raise ValueError("one runtime serves exactly one workspace")
|
|
11
|
+
policy = self.engine.registry.get(self.default_workspace_id)
|
|
12
|
+
requested = tuple(sorted(str(Path(root).resolve()) for root in roots))
|
|
13
|
+
if requested != policy.roots:
|
|
14
|
+
raise ValueError("workspace roots cannot change after runtime startup")
|
|
15
|
+
result = {"workspace_id": workspace_id, "roots": list(policy.roots), "status": self.engine.stats(workspace_id)["status"]}
|
|
16
|
+
if initial_refresh:
|
|
17
|
+
result["refresh"] = self.refresh.execute(workspace_id)
|
|
18
|
+
return result
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from compact.handoff import ConversationHandoffStore
|
|
6
|
+
from harness_context.adapters.ecc import EccMemoryReader, detect_ecc
|
|
7
|
+
from harness_context.application.container import ApplicationContainer
|
|
8
|
+
from harness_context.application.ecc_service import EccService
|
|
9
|
+
from harness_context.application.handoff_service import HandoffService
|
|
10
|
+
from harness_context.application.memory_service import MemoryService
|
|
11
|
+
from harness_context.application.retrieval_service import RetrievalService
|
|
12
|
+
from harness_context.application.services import ContextService, RefreshService
|
|
13
|
+
from harness_context.application.workspace_service import WorkspaceService
|
|
14
|
+
from harness_context.engine import ContextEngine
|
|
15
|
+
from harness_context.paths import workspace_state_dir
|
|
16
|
+
from harness_context.storage import SnapshotStore
|
|
17
|
+
from harness_context.workspace.identity import workspace_identity
|
|
18
|
+
from memory.episodic import MemoryStore
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_container(root: str | Path, workspace_id: str = "", *, ecc_enabled: bool = False, ecc_allow_user_scope: bool = False) -> ApplicationContainer:
|
|
22
|
+
canonical = Path(root).expanduser().resolve(strict=True)
|
|
23
|
+
identity = workspace_id or workspace_identity(canonical)
|
|
24
|
+
engine = ContextEngine()
|
|
25
|
+
engine.register_workspace(identity, [str(canonical)])
|
|
26
|
+
snapshots = SnapshotStore(canonical)
|
|
27
|
+
snapshots.prepare()
|
|
28
|
+
active = snapshots.load_active(identity)
|
|
29
|
+
if active:
|
|
30
|
+
engine.load_snapshot(active)
|
|
31
|
+
data_dir = workspace_state_dir(canonical)
|
|
32
|
+
memory = MemoryStore(str(data_dir / "memory.sqlite3"))
|
|
33
|
+
handoffs = ConversationHandoffStore(str(data_dir / "handoffs.sqlite3"))
|
|
34
|
+
ecc = EccMemoryReader(detect_ecc(canonical, allow_user_scope=ecc_allow_user_scope)) if ecc_enabled else None
|
|
35
|
+
refresh = RefreshService(engine, snapshots)
|
|
36
|
+
return ApplicationContainer(
|
|
37
|
+
engine=engine, snapshots=snapshots,
|
|
38
|
+
refresh=refresh,
|
|
39
|
+
context=ContextService(engine, memory, handoffs, ecc),
|
|
40
|
+
memory=memory, handoffs=handoffs,
|
|
41
|
+
ecc=ecc,
|
|
42
|
+
workspace=WorkspaceService(engine, refresh, identity),
|
|
43
|
+
retrieval=RetrievalService(engine, ecc),
|
|
44
|
+
memories=MemoryService(memory),
|
|
45
|
+
handoff=HandoffService(handoffs),
|
|
46
|
+
ecc_queries=EccService(ecc),
|
|
47
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Public CTXORA product metadata."""
|
|
2
|
+
|
|
3
|
+
BRAND_NAME = "CTXORA"
|
|
4
|
+
PRODUCT_NAME = "CTXORA Engine"
|
|
5
|
+
MCP_SERVER_NAME = "CTXORA MCP"
|
|
6
|
+
CLI_NAME = "ctxora"
|
|
7
|
+
MCP_CLI_NAME = "ctxora-mcp"
|
|
8
|
+
DISTRIBUTION_NAME = "ctxora-engine"
|
|
9
|
+
MCP_SERVER_KEY = "ctxora"
|
|
10
|
+
FREE_PLAN_NAME = "CTXORA Free"
|
|
11
|
+
PAID_PLAN_NAME = "CTXORA Pro"
|
|
12
|
+
PAID_PLAN_STATUS = "waitlist"
|
|
13
|
+
TAGLINE = "Index once. Ground every agent."
|
|
14
|
+
DESCRIPTION = "Local-first context engine for coding agents."
|
|
15
|
+
WORKSPACE_DIR_NAME = ".ctxora"
|
|
16
|
+
LEGACY_WORKSPACE_DIR_NAME = ".harness"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command-line transport."""
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from dataclasses import asdict
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from harness_context.adapters.clients import PROFILES
|
|
14
|
+
from harness_context.api.v2 import PrepareContextRequest
|
|
15
|
+
from harness_context.branding import CLI_NAME, PAID_PLAN_NAME, PAID_PLAN_STATUS
|
|
16
|
+
from harness_context.cli.exit_codes import error_report, exit_code_for
|
|
17
|
+
from harness_context.free_tools import (
|
|
18
|
+
context_score,
|
|
19
|
+
explain_context,
|
|
20
|
+
instruction_document,
|
|
21
|
+
repository_map,
|
|
22
|
+
write_instruction,
|
|
23
|
+
)
|
|
24
|
+
from harness_context.installer import ClientInstaller
|
|
25
|
+
from harness_context.paths import workspace_data_dir, workspace_data_dirs, workspace_runtime_dir
|
|
26
|
+
from harness_context.runtime import HarnessRuntime
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _runtime(args) -> HarnessRuntime:
|
|
30
|
+
if args.host and args.host not in {"127.0.0.1", "localhost", "::1"} and not args.allow_external:
|
|
31
|
+
raise SystemExit("external HTTP binding requires --allow-external")
|
|
32
|
+
return HarnessRuntime.for_workspace(
|
|
33
|
+
args.workspace, args.transport, args.host, args.port, args.watch,
|
|
34
|
+
args.ecc, args.ecc_user_scope,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _print(payload: dict) -> None:
|
|
39
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _pid_path(workspace: str) -> Path:
|
|
43
|
+
return workspace_runtime_dir(workspace) / "run.pid"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
47
|
+
parser = argparse.ArgumentParser(prog=CLI_NAME)
|
|
48
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
49
|
+
for name in (
|
|
50
|
+
"setup", "install", "profile", "run", "start", "index", "query", "explain",
|
|
51
|
+
"context-score", "repo-map", "generate-agents-md",
|
|
52
|
+
"generate-copilot-instructions", "generate-cursor-rules", "pro", "inspect",
|
|
53
|
+
"status", "stop", "doctor", "repair", "register", "export", "uninstall", "ci",
|
|
54
|
+
):
|
|
55
|
+
command = commands.add_parser(name)
|
|
56
|
+
command.add_argument("--workspace", default=".")
|
|
57
|
+
command.add_argument("--transport", choices=("stdio", "streamable-http"))
|
|
58
|
+
command.add_argument("--host")
|
|
59
|
+
command.add_argument("--port", type=int)
|
|
60
|
+
command.add_argument("--allow-external", action="store_true")
|
|
61
|
+
command.add_argument("--watch", action="store_true", default=None)
|
|
62
|
+
command.add_argument("--ecc", action="store_true", default=None)
|
|
63
|
+
command.add_argument("--ecc-user-scope", action="store_true", default=None)
|
|
64
|
+
if name in {"query", "explain"}:
|
|
65
|
+
command.add_argument("query")
|
|
66
|
+
command.add_argument("--tokens", type=int, default=8_000)
|
|
67
|
+
if name in {"generate-agents-md", "generate-copilot-instructions", "generate-cursor-rules"}:
|
|
68
|
+
command.add_argument("--output")
|
|
69
|
+
command.add_argument("--force", action="store_true")
|
|
70
|
+
if name == "inspect":
|
|
71
|
+
command.add_argument("target", choices=("workspace", "snapshot", "bundle", "ecc"), default="workspace", nargs="?")
|
|
72
|
+
if name == "index":
|
|
73
|
+
command.add_argument("--incremental", action="store_true")
|
|
74
|
+
if name == "ci":
|
|
75
|
+
command.add_argument("--base", default="origin/main")
|
|
76
|
+
command.add_argument("--head", default="HEAD")
|
|
77
|
+
if name == "export":
|
|
78
|
+
command.add_argument("--output", required=True)
|
|
79
|
+
if name in {"install", "uninstall"}:
|
|
80
|
+
command.add_argument("--profile", choices=tuple(PROFILES))
|
|
81
|
+
command.add_argument("--client-config")
|
|
82
|
+
command.add_argument("--dry-run", action="store_true")
|
|
83
|
+
if name == "uninstall":
|
|
84
|
+
command.add_argument("--delete-data", action="store_true")
|
|
85
|
+
return parser
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _main(argv: list[str] | None = None) -> int:
|
|
89
|
+
args = build_parser().parse_args(argv)
|
|
90
|
+
root = Path(args.workspace).expanduser().resolve(strict=True)
|
|
91
|
+
if args.command == "setup":
|
|
92
|
+
config = workspace_data_dir(root) / "config.toml"
|
|
93
|
+
config.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
if not config.exists():
|
|
95
|
+
config.write_text('transport = "stdio"\nstartup_policy = "block_until_ready"\nwatch = false\necc_enabled = false\necc_allow_user_scope = false\n', "utf-8")
|
|
96
|
+
_print({"status": "configured", "config": str(config)})
|
|
97
|
+
return 0
|
|
98
|
+
if args.command == "profile":
|
|
99
|
+
_print({"profiles": [asdict(profile) | {"default_config": str(profile.default_config)} for profile in PROFILES.values()]})
|
|
100
|
+
return 0
|
|
101
|
+
if args.command == "pro":
|
|
102
|
+
_print({
|
|
103
|
+
"plan": PAID_PLAN_NAME,
|
|
104
|
+
"status": PAID_PLAN_STATUS,
|
|
105
|
+
"features": ["managed automation", "private workflows", "team context"],
|
|
106
|
+
})
|
|
107
|
+
return 0
|
|
108
|
+
if args.command == "install":
|
|
109
|
+
if not args.profile:
|
|
110
|
+
raise SystemExit("install requires --profile")
|
|
111
|
+
plan = ClientInstaller(root).install(args.profile, args.client_config, args.dry_run)
|
|
112
|
+
_print({"status": "planned" if args.dry_run else "installed", "plan": plan.to_dict()})
|
|
113
|
+
return 0
|
|
114
|
+
if args.command == "register":
|
|
115
|
+
runtime = _runtime(args)
|
|
116
|
+
_print({"workspace_id": runtime.config.workspace_id, "root": str(root), "status": "registered"})
|
|
117
|
+
return 0
|
|
118
|
+
if args.command == "uninstall":
|
|
119
|
+
if args.profile:
|
|
120
|
+
plan = ClientInstaller(root).uninstall(args.profile, args.client_config, args.dry_run, args.delete_data)
|
|
121
|
+
_print({"status": "planned" if args.dry_run else "uninstalled", "plan": plan.to_dict(), "data_deleted": args.delete_data})
|
|
122
|
+
return 0
|
|
123
|
+
if args.delete_data:
|
|
124
|
+
for owned in workspace_data_dirs(root):
|
|
125
|
+
shutil.rmtree(owned, ignore_errors=True)
|
|
126
|
+
_print({"status": "uninstalled", "data_deleted": args.delete_data})
|
|
127
|
+
return 0
|
|
128
|
+
if args.command == "start":
|
|
129
|
+
if args.host and args.host not in {"127.0.0.1", "localhost", "::1"} and not args.allow_external:
|
|
130
|
+
raise SystemExit("external HTTP binding requires --allow-external")
|
|
131
|
+
pid_path = _pid_path(str(root))
|
|
132
|
+
pid_path.parent.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
log = (pid_path.parent / "runtime.log").open("a", encoding="utf-8")
|
|
134
|
+
command = [
|
|
135
|
+
sys.executable, "-m", "harness_context.cli.app", "run",
|
|
136
|
+
"--workspace", str(root), "--transport", "streamable-http",
|
|
137
|
+
"--host", args.host or "127.0.0.1", "--port", str(args.port or 8765),
|
|
138
|
+
"--watch",
|
|
139
|
+
]
|
|
140
|
+
if args.ecc:
|
|
141
|
+
command.append("--ecc")
|
|
142
|
+
if args.ecc_user_scope:
|
|
143
|
+
command.append("--ecc-user-scope")
|
|
144
|
+
process = subprocess.Popen(command, stdout=log, stderr=log, start_new_session=True)
|
|
145
|
+
pid_path.write_text(str(process.pid), "utf-8")
|
|
146
|
+
_print({"status": "started", "pid": process.pid})
|
|
147
|
+
return 0
|
|
148
|
+
if args.command in {"status", "stop"}:
|
|
149
|
+
pid_path = _pid_path(str(root))
|
|
150
|
+
pid = int(pid_path.read_text("utf-8")) if pid_path.exists() else 0
|
|
151
|
+
running = False
|
|
152
|
+
if pid:
|
|
153
|
+
try:
|
|
154
|
+
os.kill(pid, 0)
|
|
155
|
+
running = True
|
|
156
|
+
except OSError:
|
|
157
|
+
pass
|
|
158
|
+
if args.command == "stop" and running:
|
|
159
|
+
os.kill(pid, signal.SIGTERM)
|
|
160
|
+
pid_path.unlink(missing_ok=True)
|
|
161
|
+
running = False
|
|
162
|
+
_print({"status": "running" if running else "stopped", "pid": pid or None})
|
|
163
|
+
return 0
|
|
164
|
+
runtime = _runtime(args)
|
|
165
|
+
startup = runtime.startup()
|
|
166
|
+
if args.command == "run":
|
|
167
|
+
runtime.run()
|
|
168
|
+
return 0
|
|
169
|
+
if args.command == "index":
|
|
170
|
+
_print(startup)
|
|
171
|
+
return 0
|
|
172
|
+
if args.command == "repair":
|
|
173
|
+
repaired = runtime.container.refresh.execute(runtime.config.workspace_id)
|
|
174
|
+
_print({"status": "repaired", "workspace": str(root), "runtime": repaired})
|
|
175
|
+
return 0
|
|
176
|
+
if args.command == "doctor":
|
|
177
|
+
_print(runtime.health_report())
|
|
178
|
+
return 0
|
|
179
|
+
if args.command == "ci":
|
|
180
|
+
changed = subprocess.run(
|
|
181
|
+
["git", "diff", "--name-only", args.base, args.head],
|
|
182
|
+
cwd=root, check=True, capture_output=True, text=True,
|
|
183
|
+
).stdout.splitlines()
|
|
184
|
+
paths = [str(root / path) for path in changed]
|
|
185
|
+
result = runtime.container.refresh.execute(runtime.config.workspace_id, paths) if paths else startup
|
|
186
|
+
_print({"status": "ok", "changed_files": changed, "refresh": result})
|
|
187
|
+
return 0
|
|
188
|
+
assert runtime.container is not None
|
|
189
|
+
if args.command in {"query", "explain"}:
|
|
190
|
+
request = PrepareContextRequest(runtime.config.workspace_id, args.query, args.tokens)
|
|
191
|
+
package = runtime.container.context.prepare(request).to_dict()
|
|
192
|
+
_print(package if args.command == "query" else explain_context(args.query, package, root))
|
|
193
|
+
return 0
|
|
194
|
+
snapshot = runtime.container.engine.export_snapshot(runtime.config.workspace_id)
|
|
195
|
+
if args.command == "context-score":
|
|
196
|
+
_print(context_score(snapshot, root))
|
|
197
|
+
return 0
|
|
198
|
+
if args.command == "repo-map":
|
|
199
|
+
_print(repository_map(snapshot, root))
|
|
200
|
+
return 0
|
|
201
|
+
generators = {
|
|
202
|
+
"generate-agents-md": (root / "AGENTS.md", "AGENTS.md"),
|
|
203
|
+
"generate-copilot-instructions": (root / ".github" / "copilot-instructions.md", "GitHub Copilot"),
|
|
204
|
+
"generate-cursor-rules": (root / ".cursor" / "rules" / "ctxora.mdc", "Cursor"),
|
|
205
|
+
}
|
|
206
|
+
if args.command in generators:
|
|
207
|
+
default_output, target = generators[args.command]
|
|
208
|
+
output = Path(args.output).expanduser().resolve() if args.output else default_output
|
|
209
|
+
write_instruction(output, instruction_document(snapshot, root, target), args.force)
|
|
210
|
+
_print({"status": "generated", "target": target, "output": str(output), "snapshot_id": snapshot["snapshot_id"]})
|
|
211
|
+
return 0
|
|
212
|
+
stats = runtime.container.engine.stats(runtime.config.workspace_id)
|
|
213
|
+
if args.command == "export":
|
|
214
|
+
output = Path(args.output).expanduser().resolve()
|
|
215
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
output.write_text(json.dumps(runtime.container.engine.export_snapshot(runtime.config.workspace_id), ensure_ascii=False, indent=2), "utf-8")
|
|
217
|
+
_print({"status": "exported", "output": str(output), "snapshot_id": stats["snapshot_id"]})
|
|
218
|
+
return 0
|
|
219
|
+
if args.target == "bundle":
|
|
220
|
+
_print(runtime.container.engine.prepare_bundle(runtime.config.workspace_id))
|
|
221
|
+
elif args.target == "ecc":
|
|
222
|
+
_print(runtime.container.ecc.status() if runtime.container.ecc else {"available": False, "enabled": False, "read_only": True})
|
|
223
|
+
else:
|
|
224
|
+
_print(stats)
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def main(argv: list[str] | None = None) -> int:
|
|
229
|
+
try:
|
|
230
|
+
return _main(argv)
|
|
231
|
+
except SystemExit:
|
|
232
|
+
raise
|
|
233
|
+
except Exception as error: # noqa: BLE001 - CLI boundary maps all failures to stable exits.
|
|
234
|
+
print(json.dumps(error_report(error), sort_keys=True), file=sys.stderr)
|
|
235
|
+
return int(exit_code_for(error))
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ExitCode(IntEnum):
|
|
5
|
+
OK = 0
|
|
6
|
+
USAGE = 2
|
|
7
|
+
CONFIGURATION = 3
|
|
8
|
+
NOT_READY = 4
|
|
9
|
+
TEMPORARY_FAILURE = 5
|
|
10
|
+
INTERNAL_ERROR = 70
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def exit_code_for(error: BaseException) -> ExitCode:
|
|
14
|
+
if isinstance(error, (FileNotFoundError, PermissionError, ValueError)):
|
|
15
|
+
return ExitCode.CONFIGURATION
|
|
16
|
+
if getattr(error, "code", "") in {"workspace_not_ready", "stale_snapshot"}:
|
|
17
|
+
return ExitCode.NOT_READY
|
|
18
|
+
if isinstance(error, (TimeoutError, ConnectionError)):
|
|
19
|
+
return ExitCode.TEMPORARY_FAILURE
|
|
20
|
+
return ExitCode.INTERNAL_ERROR
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def error_report(error: BaseException) -> dict[str, object]:
|
|
24
|
+
code = exit_code_for(error)
|
|
25
|
+
return {"status": "error", "exit_code": int(code), "error": code.name.lower()}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from harness_context.domain.cag import CAGStore
|
|
2
|
+
from harness_context.domain.chunking import bounded_windows, stable_chunk_id
|
|
3
|
+
from harness_context.domain.planning import ContextPlanner
|
|
4
|
+
from harness_context.domain.retrieval import CoveragePolicy, GraphExpander, RankFusion, Selector
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"CAGStore", "ContextPlanner", "CoveragePolicy", "GraphExpander",
|
|
8
|
+
"RankFusion", "Selector", "bounded_windows", "stable_chunk_id",
|
|
9
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from harness_context.domain.planning import ContextPlanner
|
|
9
|
+
from harness_context.schemas import ContextItem
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CAGStore:
|
|
13
|
+
def build(self, workspace_id: str, items: Sequence[ContextItem], version: int, ttl_seconds: int) -> dict:
|
|
14
|
+
stable = [item for item in items if ContextPlanner.is_stable(item)]
|
|
15
|
+
sources = [{"path": item.path, "content_hash": item.content_hash, "start_line": item.start_line, "end_line": item.end_line} for item in sorted(stable, key=lambda item: (item.path, item.start_line))]
|
|
16
|
+
bundle_id = "sha256:" + hashlib.sha256(json.dumps(sources, sort_keys=True).encode()).hexdigest()
|
|
17
|
+
now = time.time()
|
|
18
|
+
return {"bundle_id": bundle_id, "workspace_id": workspace_id, "version": version, "bundle_type": "project_core", "model_family": "provider-neutral", "token_count": sum(item.tokens for item in stable), "created_at": now, "expires_at": now + ttl_seconds, "sources": sources, "items": [item.to_dict() for item in stable]}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def stable_chunk_id(workspace_id: str, path: Path, kind: str, symbol: str, start: int, end: int, content_hash: str) -> str:
|
|
9
|
+
identity = f"{workspace_id}\0{path}\0{kind}\0{symbol}\0{start}\0{end}\0{content_hash}"
|
|
10
|
+
return hashlib.sha256(identity.encode()).hexdigest()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def bounded_windows(lines: list[str], window: int = 160, overlap: int = 20) -> Iterator[tuple[int, int, str]]:
|
|
14
|
+
if window <= 0 or overlap < 0 or overlap >= window:
|
|
15
|
+
raise ValueError("window must be positive and overlap smaller than window")
|
|
16
|
+
for offset in range(0, max(1, len(lines)), window - overlap):
|
|
17
|
+
yield offset + 1, min(offset + window, len(lines)), "\n".join(lines[offset:offset + window])
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from harness_context.schemas import ContextItem
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ContextPlanner:
|
|
10
|
+
STABLE_FILES = {"AGENTS.md", "CLAUDE.md", "README.md", "pyproject.toml"}
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def is_stable(cls, item: ContextItem) -> bool:
|
|
14
|
+
return Path(item.path).name in cls.STABLE_FILES or "/docs/" in item.path
|
|
15
|
+
|
|
16
|
+
def plan(self, workspace_id: str, query: str, available: int, items: Sequence[ContextItem], override: str = "") -> dict:
|
|
17
|
+
corpus = sum(item.tokens for item in items)
|
|
18
|
+
stable = sum(item.tokens for item in items if self.is_stable(item))
|
|
19
|
+
repo_wide = any(word in query.casefold() for word in ("architecture", "kiến trúc", "repo", "toàn bộ", "cross-module"))
|
|
20
|
+
if override:
|
|
21
|
+
strategy, reason, confidence = override, "Caller override requested.", 1.0
|
|
22
|
+
elif corpus <= available * 0.7:
|
|
23
|
+
strategy, reason, confidence = "long_context", "Authorized corpus fits comfortably in the supplied budget.", 0.84
|
|
24
|
+
elif stable and repo_wide:
|
|
25
|
+
strategy, reason, confidence = "hybrid_cag_rag", "Reusable stable core plus fresh cross-file evidence is required.", 0.88
|
|
26
|
+
elif repo_wide:
|
|
27
|
+
strategy, reason, confidence = "graph_augmented", "Cross-module query benefits from parsed dependency expansion.", 0.78
|
|
28
|
+
else:
|
|
29
|
+
strategy, reason, confidence = "hybrid_rag", "Narrow task over a larger changing corpus.", 0.82
|
|
30
|
+
return {"workspace_id": workspace_id, "strategy": strategy, "reason": reason, "confidence": confidence, "estimated_context_tokens": min(corpus, available), "rag_budget_tokens": min(5000, available), "graph_expand": strategy in {"hybrid_cag_rag", "graph_augmented"}, "alternatives": ["hybrid_rag"] if strategy != "hybrid_rag" else ["long_context"], "override_allowed": True}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
from harness_context.schemas import ContextItem
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FileScanner(Protocol):
|
|
11
|
+
def scan(self, workspace_id: str, requested: Sequence[str]) -> list[Path]: ...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ChunkParser(Protocol):
|
|
15
|
+
def parse(self, workspace_id: str, path: Path) -> list[ContextItem]: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SearchIndex(Protocol):
|
|
19
|
+
def rebuild(self, items: Sequence[ContextItem]) -> None: ...
|
|
20
|
+
def scores(self, query: str, items: Sequence[ContextItem]) -> list[float]: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DependencyGraph(Protocol):
|
|
24
|
+
def build(self, items: Sequence[ContextItem]) -> dict[str, set[str]]: ...
|