ags-cli 0.1.0__py3-none-any.whl

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.
Files changed (156) hide show
  1. ags_cli-0.1.0.dist-info/METADATA +332 -0
  2. ags_cli-0.1.0.dist-info/RECORD +156 -0
  3. ags_cli-0.1.0.dist-info/WHEEL +5 -0
  4. ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. ags_cli-0.1.0.dist-info/top_level.txt +2 -0
  6. cli/__init__.py +0 -0
  7. cli/__main__.py +4 -0
  8. cli/client.py +145 -0
  9. cli/commands/__init__.py +0 -0
  10. cli/commands/adapter.py +33 -0
  11. cli/commands/artifact.py +21 -0
  12. cli/commands/ask.py +212 -0
  13. cli/commands/chat.py +649 -0
  14. cli/commands/diff.py +49 -0
  15. cli/commands/doctor.py +93 -0
  16. cli/commands/gui.py +45 -0
  17. cli/commands/init.py +20 -0
  18. cli/commands/mcp.py +50 -0
  19. cli/commands/memory.py +65 -0
  20. cli/commands/model.py +73 -0
  21. cli/commands/policy.py +52 -0
  22. cli/commands/project.py +122 -0
  23. cli/commands/quota.py +107 -0
  24. cli/commands/run.py +84 -0
  25. cli/commands/serve.py +171 -0
  26. cli/commands/service.py +236 -0
  27. cli/commands/session.py +219 -0
  28. cli/commands/stats.py +96 -0
  29. cli/commands/status.py +36 -0
  30. cli/commands/task.py +37 -0
  31. cli/commands/usage.py +86 -0
  32. cli/local.py +84 -0
  33. cli/main.py +149 -0
  34. harness/__init__.py +4 -0
  35. harness/adapters/__init__.py +26 -0
  36. harness/adapters/antigravity.py +301 -0
  37. harness/adapters/claude_code.py +522 -0
  38. harness/adapters/local_openai.py +534 -0
  39. harness/adapters/mock.py +112 -0
  40. harness/adapters/probe.py +65 -0
  41. harness/adapters/registry.py +56 -0
  42. harness/adapters/warm_pool.py +255 -0
  43. harness/api/__init__.py +0 -0
  44. harness/api/router.py +38 -0
  45. harness/api/v1/__init__.py +0 -0
  46. harness/api/v1/adapters.py +105 -0
  47. harness/api/v1/approvals.py +173 -0
  48. harness/api/v1/artifacts.py +44 -0
  49. harness/api/v1/attachments.py +48 -0
  50. harness/api/v1/doctor.py +22 -0
  51. harness/api/v1/health.py +37 -0
  52. harness/api/v1/mcp.py +131 -0
  53. harness/api/v1/memory.py +80 -0
  54. harness/api/v1/policies.py +49 -0
  55. harness/api/v1/project.py +302 -0
  56. harness/api/v1/runs.py +98 -0
  57. harness/api/v1/sessions.py +248 -0
  58. harness/api/v1/stream.py +110 -0
  59. harness/api/v1/tasks.py +30 -0
  60. harness/api/v1/usage.py +58 -0
  61. harness/bootstrap.py +216 -0
  62. harness/db.py +164 -0
  63. harness/deps.py +58 -0
  64. harness/eventbus/__init__.py +20 -0
  65. harness/eventbus/inprocess_bus.py +85 -0
  66. harness/main.py +144 -0
  67. harness/mcp/__init__.py +22 -0
  68. harness/mcp/client.py +139 -0
  69. harness/mcp/config_gen.py +77 -0
  70. harness/mcp/registry.py +156 -0
  71. harness/mcp/tools.py +81 -0
  72. harness/memory/__init__.py +13 -0
  73. harness/memory/context_budget.py +116 -0
  74. harness/memory/embed.py +217 -0
  75. harness/memory/extract.py +74 -0
  76. harness/memory/ingest.py +106 -0
  77. harness/memory/namespaces.py +57 -0
  78. harness/memory/ranking.py +31 -0
  79. harness/memory/redact.py +37 -0
  80. harness/memory/service.py +320 -0
  81. harness/memory/sqlite_vec_backend.py +266 -0
  82. harness/memory/summarize.py +68 -0
  83. harness/models/__init__.py +35 -0
  84. harness/models/adapter_health.py +27 -0
  85. harness/models/approval.py +37 -0
  86. harness/models/artifact.py +35 -0
  87. harness/models/audit_log.py +33 -0
  88. harness/models/comparison.py +33 -0
  89. harness/models/enums.py +146 -0
  90. harness/models/mcp_server_config.py +49 -0
  91. harness/models/memory.py +76 -0
  92. harness/models/policy.py +29 -0
  93. harness/models/provider_config.py +35 -0
  94. harness/models/run.py +46 -0
  95. harness/models/run_event.py +35 -0
  96. harness/models/session.py +50 -0
  97. harness/models/task.py +30 -0
  98. harness/models/types.py +63 -0
  99. harness/models/workspace.py +27 -0
  100. harness/observability/__init__.py +4 -0
  101. harness/observability/audit.py +88 -0
  102. harness/observability/logging.py +47 -0
  103. harness/observability/metrics.py +43 -0
  104. harness/observability/tracing.py +38 -0
  105. harness/orchestrator/__init__.py +19 -0
  106. harness/orchestrator/engine.py +821 -0
  107. harness/orchestrator/handoff.py +33 -0
  108. harness/orchestrator/lifecycle.py +69 -0
  109. harness/orchestrator/native_resume.py +93 -0
  110. harness/orchestrator/policies.py +101 -0
  111. harness/orchestrator/reconcile.py +39 -0
  112. harness/orchestrator/run_graph.py +62 -0
  113. harness/orchestrator/snapshot.py +49 -0
  114. harness/schemas/__init__.py +1 -0
  115. harness/schemas/adapter.py +26 -0
  116. harness/schemas/approval.py +25 -0
  117. harness/schemas/artifact.py +17 -0
  118. harness/schemas/common.py +20 -0
  119. harness/schemas/memory.py +50 -0
  120. harness/schemas/policy.py +39 -0
  121. harness/schemas/run.py +116 -0
  122. harness/schemas/session.py +93 -0
  123. harness/schemas/task.py +24 -0
  124. harness/security/__init__.py +5 -0
  125. harness/security/approval_policy.py +107 -0
  126. harness/security/auth.py +106 -0
  127. harness/security/permissions.py +59 -0
  128. harness/security/risk.py +59 -0
  129. harness/security/secrets.py +49 -0
  130. harness/services/__init__.py +1 -0
  131. harness/services/adapters_health.py +212 -0
  132. harness/services/approvals.py +84 -0
  133. harness/services/artifacts.py +78 -0
  134. harness/services/attachments.py +228 -0
  135. harness/services/comparison.py +345 -0
  136. harness/services/doctor.py +156 -0
  137. harness/services/files.py +287 -0
  138. harness/services/mcp_servers.py +179 -0
  139. harness/services/project_git.py +101 -0
  140. harness/services/retention.py +97 -0
  141. harness/services/runs.py +155 -0
  142. harness/services/runs_diff.py +201 -0
  143. harness/services/sessions.py +242 -0
  144. harness/services/ssh.py +300 -0
  145. harness/services/stats.py +132 -0
  146. harness/services/tasks.py +41 -0
  147. harness/services/workspaces.py +184 -0
  148. harness/services/worktrees.py +439 -0
  149. harness/settings.py +186 -0
  150. harness/skills/__init__.py +11 -0
  151. harness/skills/manager.py +141 -0
  152. harness/tools/__init__.py +1 -0
  153. harness/tools/fs_tools.py +295 -0
  154. harness/workers/__init__.py +0 -0
  155. harness/workers/dispatch.py +26 -0
  156. harness/workers/inprocess.py +135 -0
harness/mcp/tools.py ADDED
@@ -0,0 +1,81 @@
1
+ """Pure helpers for turning MCP server tools into the harness's tool protocol.
2
+
3
+ No I/O and no MCP SDK import here — this is the testable core shared by both
4
+ integration paths:
5
+
6
+ * the OpenAI-compatible adapter, which needs OpenAI ``tools`` specs and a way to
7
+ route a tool call back to the owning server;
8
+ * the CLI adapters, which reuse the namespacing convention in their generated
9
+ native MCP config and ``--allowedTools`` lists.
10
+
11
+ Tools are namespaced ``mcp__<server>__<tool>`` — the same scheme the Claude CLI
12
+ uses — so a tool call reads identically across every adapter and in the ledger.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ PREFIX = "mcp"
20
+ SEP = "__"
21
+
22
+
23
+ def namespaced_name(server: str, tool: str) -> str:
24
+ """``("github", "create_issue") -> "mcp__github__create_issue"``."""
25
+ return f"{PREFIX}{SEP}{server}{SEP}{tool}"
26
+
27
+
28
+ def parse_namespaced(name: str) -> tuple[str, str] | None:
29
+ """Inverse of :func:`namespaced_name`. Returns ``(server, tool)`` or ``None`` if
30
+ ``name`` is not an MCP-namespaced tool. The tool segment may itself contain the
31
+ separator, so we split off only the prefix and server."""
32
+ if not name.startswith(f"{PREFIX}{SEP}"):
33
+ return None
34
+ rest = name[len(PREFIX) + len(SEP):]
35
+ server, sep, tool = rest.partition(SEP)
36
+ if not sep or not server or not tool:
37
+ return None
38
+ return server, tool
39
+
40
+
41
+ def is_mcp_tool(name: str) -> bool:
42
+ return parse_namespaced(name) is not None
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class McpTool:
47
+ """A tool advertised by an MCP server (transport-agnostic projection)."""
48
+
49
+ server: str
50
+ name: str
51
+ description: str = ""
52
+ input_schema: dict | None = None
53
+
54
+ def openai_spec(self) -> dict:
55
+ """OpenAI ``tools`` function spec with a namespaced name."""
56
+ schema = self.input_schema or {"type": "object", "properties": {}}
57
+ return {
58
+ "type": "function",
59
+ "function": {
60
+ "name": namespaced_name(self.server, self.name),
61
+ "description": self.description or f"{self.name} (via MCP server {self.server})",
62
+ "parameters": schema,
63
+ },
64
+ }
65
+
66
+
67
+ def allowed(tool_name: str, allowlist: list[str] | None) -> bool:
68
+ """A tool is allowed if the allowlist is empty/absent or names it (bare name)."""
69
+ return not allowlist or tool_name in allowlist
70
+
71
+
72
+ def to_openai_specs(
73
+ tools: list[McpTool], allowlist_by_server: dict[str, list[str]] | None = None
74
+ ) -> list[dict]:
75
+ """Convert tools to OpenAI specs, applying each server's allowlist."""
76
+ allowlist_by_server = allowlist_by_server or {}
77
+ specs: list[dict] = []
78
+ for t in tools:
79
+ if allowed(t.name, allowlist_by_server.get(t.server)):
80
+ specs.append(t.openai_spec())
81
+ return specs
@@ -0,0 +1,13 @@
1
+ from harness.memory.extract import extract_session_memory
2
+ from harness.memory.ingest import ingest_memory
3
+ from harness.memory.service import MemoryService, RetrievalResult, RetrievedItem
4
+ from harness.memory.summarize import summarize_session
5
+
6
+ __all__ = [
7
+ "MemoryService",
8
+ "RetrievalResult",
9
+ "RetrievedItem",
10
+ "extract_session_memory",
11
+ "ingest_memory",
12
+ "summarize_session",
13
+ ]
@@ -0,0 +1,116 @@
1
+ """Per-model context-window budgeting for the injected conversation context.
2
+
3
+ Adapters are stateless, so the orchestrator re-injects the session history into every
4
+ turn's prompt. This module trims that history to the **active model's** context window —
5
+ keeping recent turns verbatim and falling back to the session summary for older ones —
6
+ so small-window models don't overflow. Large (1M) models effectively never trim.
7
+
8
+ Token counts are estimated by a char heuristic (no tokenizer dependency); a reserve is
9
+ kept for the model's response, and the estimate intentionally over-counts so we trim a
10
+ little early rather than overflow.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+
17
+ from harness.models.enums import AdapterKind
18
+
19
+ # Known model context windows (tokens), keyed by a normalized substring of the model
20
+ # name — first match wins, so list more specific keys first. Override per provider with
21
+ # ``params.max_context``.
22
+ MODEL_CONTEXT_WINDOWS: dict[str, int] = {
23
+ "claude-opus-4-8": 1_000_000,
24
+ "claude-sonnet-4-6": 1_000_000,
25
+ "claude-sonnet-4": 1_000_000,
26
+ "claude-fable-5": 1_000_000,
27
+ "claude-haiku": 200_000,
28
+ "gemini": 1_000_000, # 3-pro / 3.1-pro / 3.5-flash all 1M
29
+ "gemma": 131_072,
30
+ "gpt-4o": 128_000,
31
+ "gpt-4.1": 1_000_000,
32
+ "llama": 131_072,
33
+ "qwen": 131_072,
34
+ "mock": 8_192,
35
+ }
36
+
37
+ _KIND_DEFAULT: dict[AdapterKind, int] = {
38
+ AdapterKind.claude_code: 200_000,
39
+ AdapterKind.antigravity: 1_000_000,
40
+ AdapterKind.openai_local: 8_192,
41
+ AdapterKind.mock: 8_192,
42
+ }
43
+
44
+ _CHARS_PER_TOKEN = 3.5 # conservative: over-counts tokens so we trim early, never overflow
45
+
46
+ _HISTORY_HEADER = "## Session History"
47
+ _SUMMARY_HEADER = "## Earlier in this session (summary)"
48
+ _SEP = "\n\n"
49
+
50
+
51
+ def estimate_tokens(text: str) -> int:
52
+ return math.ceil(len(text) / _CHARS_PER_TOKEN) if text else 0
53
+
54
+
55
+ def context_window(kind: AdapterKind, model: str | None, params: dict | None = None) -> int:
56
+ """Resolve a provider's active-model context window (tokens):
57
+ explicit ``params.max_context`` → per-model registry → per-kind default."""
58
+ params = params or {}
59
+ mc = params.get("max_context")
60
+ if mc is not None:
61
+ try:
62
+ return int(mc)
63
+ except (TypeError, ValueError):
64
+ pass
65
+ name = (model or "").lower()
66
+ for key, win in MODEL_CONTEXT_WINDOWS.items():
67
+ if key in name:
68
+ return win
69
+ return _KIND_DEFAULT.get(kind, 8_192)
70
+
71
+
72
+ def _join_history(blocks: list[str], summary: str | None) -> str:
73
+ body = _SEP.join(blocks)
74
+ if summary:
75
+ if body:
76
+ return f"{_SUMMARY_HEADER}\n{summary}{_SEP}{_HISTORY_HEADER}\n{body}"
77
+ return f"{_SUMMARY_HEADER}\n{summary}"
78
+ return f"{_HISTORY_HEADER}\n{body}" if body else ""
79
+
80
+
81
+ def build_memory_context(
82
+ semantic_block: str,
83
+ transcript_blocks: list[str],
84
+ summary: str | None,
85
+ *,
86
+ window: int,
87
+ message_tokens: int = 0,
88
+ reserve_tokens: int | None = None,
89
+ ) -> str:
90
+ """Assemble the injected context (semantic memory + session history), trimmed to
91
+ ``window``. Keeps the newest transcript blocks verbatim; if older blocks must be
92
+ dropped, prepends the session ``summary``. When everything fits, the full transcript
93
+ is returned UNCHANGED — so 1M-window models never trim."""
94
+ reserve = reserve_tokens if reserve_tokens is not None else min(window // 5, 32_000)
95
+ budget = window - reserve - message_tokens - estimate_tokens(semantic_block)
96
+
97
+ full = _join_history(transcript_blocks, None)
98
+ if not transcript_blocks or budget <= 0 or estimate_tokens(full) <= budget:
99
+ out = full # fits (or can't meaningfully budget) → no trim
100
+ else:
101
+ overhead = estimate_tokens(f"{_SUMMARY_HEADER}\n{summary}") if summary else 0
102
+ overhead += estimate_tokens(f"\n{_HISTORY_HEADER}\n")
103
+ kept: list[str] = []
104
+ used = overhead
105
+ for block in reversed(transcript_blocks):
106
+ t = estimate_tokens(block + _SEP)
107
+ if used + t > budget and kept:
108
+ break
109
+ kept.insert(0, block)
110
+ used += t
111
+ dropped = len(kept) < len(transcript_blocks)
112
+ out = _join_history(kept, summary if (dropped and summary) else None)
113
+
114
+ if not out:
115
+ return semantic_block
116
+ return f"{semantic_block}{_SEP}{out}".strip() if semantic_block else out
@@ -0,0 +1,217 @@
1
+ """Pluggable embedding providers.
2
+
3
+ - ``HashEmbedder``: deterministic, offline, dependency-free. Default for dev/CI so
4
+ the whole memory pipeline works without a model server. Not semantically strong,
5
+ but stable and fast — good enough to validate retrieval plumbing.
6
+ - ``OpenAICompatibleEmbedder``: any ``/v1/embeddings`` endpoint (Ollama, LM Studio,
7
+ vLLM, LocalAI). Used in real deployments.
8
+ - ``FastEmbedEmbedder``: offline, local-model embedder via ``fastembed``.
9
+ Install the optional extra to enable: ``pip install 'ags-cli[embed]'``.
10
+
11
+ Resolution for ``embedding_provider = "auto"`` (the default):
12
+ 1. ``fastembed`` importable → ``FastEmbedEmbedder`` (BAAI/bge-small-en-v1.5, dim 384)
13
+ 2. A provider config named ``local`` with an enabled entry + ``base_url``
14
+ → ``OpenAICompatibleEmbedder`` with ``base_url`` stripped of any trailing
15
+ slash; ``.embed()`` appends ``/embeddings`` at call time, so the final
16
+ request URL is ``{base_url}/embeddings``
17
+ 3. ``HashEmbedder`` + one log warning about the degraded path.
18
+
19
+ Explicit values (``hash`` / ``openai_compatible`` / ``fastembed``) bypass resolution.
20
+ ``fastembed`` without the package → ``RuntimeError``.
21
+
22
+ The effective dimension comes from the chosen embedder's ``.dim`` attribute;
23
+ callers should not rely on ``settings.embedding_dim`` when using auto-resolution.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import hashlib
30
+ import importlib.util
31
+ import logging
32
+ import math
33
+ from typing import Protocol
34
+
35
+ import httpx
36
+
37
+ from harness.settings import Settings, get_settings
38
+
39
+ log = logging.getLogger(__name__)
40
+
41
+
42
+ class Embedder(Protocol):
43
+ model: str
44
+ async def embed(self, texts: list[str]) -> list[list[float]]: ...
45
+
46
+
47
+ class HashEmbedder:
48
+ """Deterministic hashing embedder: feature-hash tokens into ``dim`` buckets,
49
+ then L2-normalize. Cosine similarity is meaningful for lexical overlap."""
50
+
51
+ def __init__(self, dim: int) -> None:
52
+ self.dim = dim
53
+ self.model = f"hash-{dim}"
54
+
55
+ async def embed(self, texts: list[str]) -> list[list[float]]:
56
+ return [self._embed_one(t) for t in texts]
57
+
58
+ def _embed_one(self, text: str) -> list[float]:
59
+ vec = [0.0] * self.dim
60
+ for token in text.lower().split():
61
+ digest = hashlib.sha1(token.encode()).digest()
62
+ idx = int.from_bytes(digest[:4], "big") % self.dim
63
+ sign = 1.0 if digest[4] & 1 else -1.0
64
+ vec[idx] += sign
65
+ norm = math.sqrt(sum(v * v for v in vec)) or 1.0
66
+ return [v / norm for v in vec]
67
+
68
+
69
+ class OpenAICompatibleEmbedder:
70
+ def __init__(self, base_url: str, api_key: str, model: str, dim: int) -> None:
71
+ self.base_url = base_url.rstrip("/")
72
+ self.api_key = api_key
73
+ self.model = model
74
+ self.dim = dim
75
+
76
+ async def embed(self, texts: list[str]) -> list[list[float]]:
77
+ headers = {"Content-Type": "application/json"}
78
+ if self.api_key:
79
+ headers["Authorization"] = f"Bearer {self.api_key}"
80
+ # Fast-fail timeout: protects callers from slow-network hangs on unreachable endpoints.
81
+ async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as client:
82
+ resp = await client.post(
83
+ f"{self.base_url}/embeddings",
84
+ headers=headers,
85
+ json={"model": self.model, "input": texts},
86
+ )
87
+ resp.raise_for_status()
88
+ data = resp.json()["data"]
89
+ return [item["embedding"] for item in data]
90
+
91
+
92
+ class FastEmbedEmbedder:
93
+ """Local offline embedder backed by ``fastembed``.
94
+
95
+ The ``fastembed`` package is an optional dependency — install with
96
+ ``pip install 'ags-cli[embed]'`` or ``uv tool install 'ags-cli[embed]'``.
97
+ The import is deferred to ``__init__`` so the class can be defined and
98
+ imported without the package installed (used for branch selection in
99
+ ``build_embedder``).
100
+ """
101
+
102
+ MODEL = "BAAI/bge-small-en-v1.5"
103
+ DIM = 384
104
+
105
+ def __init__(self, model: str = MODEL) -> None:
106
+ from fastembed import TextEmbedding # lazy import — package optional
107
+ self._te = TextEmbedding(model_name=model)
108
+ self.model = model
109
+ self.dim = self.DIM
110
+
111
+ async def embed(self, texts: list[str]) -> list[list[float]]:
112
+ """Embed ``texts`` using the fastembed model (runs sync model in a thread)."""
113
+ def _sync_embed() -> list[list[float]]:
114
+ return [v.tolist() for v in self._te.embed(texts)]
115
+
116
+ return await asyncio.to_thread(_sync_embed)
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Factory
121
+ # ---------------------------------------------------------------------------
122
+
123
+ def build_embedder(
124
+ settings: Settings,
125
+ provider_configs: dict | None = None,
126
+ ) -> Embedder:
127
+ """Build the embedder dictated by ``settings.embedding_provider``.
128
+
129
+ Parameters
130
+ ----------
131
+ settings:
132
+ Application settings. ``embedding_provider`` controls selection.
133
+ provider_configs:
134
+ Optional dict of provider config dicts keyed by provider name.
135
+ Only consulted when ``embedding_provider == "auto"`` for the
136
+ ``local`` provider fallback. Expected shape::
137
+
138
+ {
139
+ "local": {
140
+ "name": "local",
141
+ "enabled": True,
142
+ "base_url": "http://localhost:11434/v1",
143
+ },
144
+ }
145
+ """
146
+ provider = settings.embedding_provider
147
+
148
+ # --- Explicit "hash" --------------------------------------------------
149
+ if provider == "hash":
150
+ return HashEmbedder(settings.embedding_dim)
151
+
152
+ # --- Explicit "openai_compatible" -------------------------------------
153
+ if provider == "openai_compatible":
154
+ return OpenAICompatibleEmbedder(
155
+ base_url=settings.embedding_base_url,
156
+ api_key=settings.embedding_api_key,
157
+ model=settings.embedding_model,
158
+ dim=settings.embedding_dim,
159
+ )
160
+
161
+ # --- Explicit "fastembed" ---------------------------------------------
162
+ if provider == "fastembed":
163
+ if importlib.util.find_spec("fastembed") is None:
164
+ raise RuntimeError(
165
+ "embedding_provider='fastembed' but the fastembed package is not "
166
+ "installed. Install it with: pip install 'ags-cli[embed]'"
167
+ )
168
+ # Explicit user choice — they own any migration; honour it.
169
+ return FastEmbedEmbedder()
170
+
171
+ # --- "auto" resolution ------------------------------------------------
172
+ # (a) fastembed importable → prefer it.
173
+ if importlib.util.find_spec("fastembed") is not None:
174
+ return FastEmbedEmbedder()
175
+
176
+ # (b) enabled local provider with a base_url
177
+ if provider_configs:
178
+ local_cfg = provider_configs.get("local")
179
+ if (
180
+ local_cfg
181
+ and local_cfg.get("enabled", False)
182
+ and local_cfg.get("base_url")
183
+ ):
184
+ base_url = local_cfg["base_url"]
185
+ return OpenAICompatibleEmbedder(
186
+ base_url=base_url.rstrip("/"),
187
+ api_key=local_cfg.get("api_key", ""),
188
+ model=settings.embedding_model,
189
+ dim=settings.embedding_dim,
190
+ )
191
+
192
+ # (c) last resort: hash embedder with a degradation warning
193
+ log.warning(
194
+ "semantic memory degraded: hash embedder in use — pip install 'ags-cli[embed]'"
195
+ )
196
+ return HashEmbedder(settings.embedding_dim)
197
+
198
+
199
+ def get_embedder(settings: Settings | None = None) -> Embedder:
200
+ """Backwards-compatible accessor.
201
+
202
+ Resolves ``provider_configs`` from the settings config() so that the
203
+ ``local`` provider's ``base_url`` is available for auto-resolution.
204
+ Callers that need explicit provider_configs should call ``build_embedder``
205
+ directly.
206
+ """
207
+ settings = settings or get_settings()
208
+ # Extract a provider_configs dict from the loaded config (if any).
209
+ provider_configs: dict | None = None
210
+ try:
211
+ cfg = settings.config()
212
+ providers = cfg.get("providers") or []
213
+ if providers:
214
+ provider_configs = {p["name"]: p for p in providers if p.get("name")}
215
+ except Exception:
216
+ pass # config file absent in tests / minimal environments
217
+ return build_embedder(settings, provider_configs=provider_configs)
@@ -0,0 +1,74 @@
1
+ """Memory extraction pipeline.
2
+
3
+ After a session completes, distill its runs into durable, provider-independent
4
+ memory: each succeeded run's output becomes an **episodic** memory item (what was
5
+ produced, by whom, with outcome), and the canonical session summary becomes a
6
+ **summary** item. Secrets are redacted by the ingest layer before storage/embed;
7
+ dedup keeps re-runs idempotent. This is what lets a later session — on any
8
+ provider — retrieve "what we already concluded" via the shared memory seam.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import uuid
14
+
15
+ from sqlalchemy import select
16
+ from sqlalchemy.ext.asyncio import AsyncSession
17
+
18
+ from harness.memory.ingest import ingest_memory
19
+ from harness.memory.namespaces import provider_ns, workspace_ns
20
+ from harness.models.enums import MemoryScope, MemoryType, RunStatus
21
+ from harness.models.run import Run
22
+ from harness.models.session import Session
23
+ from harness.models.workspace import Workspace
24
+ from harness.observability.logging import get_logger
25
+
26
+ log = get_logger("memory-extract")
27
+
28
+
29
+ async def extract_session_memory(db: AsyncSession, session_id: uuid.UUID) -> int:
30
+ """Ingest episodic + summary memory for a completed session. Returns the number
31
+ of memory items written (existing duplicates are not double-counted)."""
32
+ sess = (
33
+ await db.execute(select(Session).where(Session.id == session_id))
34
+ ).scalar_one()
35
+ workspace = (
36
+ await db.execute(select(Workspace).where(Workspace.id == sess.workspace_id))
37
+ ).scalar_one()
38
+ runs = (
39
+ await db.execute(select(Run).where(Run.session_id == session_id))
40
+ ).scalars().all()
41
+
42
+ written = 0
43
+ objective_tag = sess.canonical_objective[:40]
44
+
45
+ for run in runs:
46
+ if run.status != RunStatus.succeeded or not run.final_output:
47
+ continue
48
+ item = await ingest_memory(
49
+ db,
50
+ namespace=provider_ns(workspace.slug, run.adapter_kind.value),
51
+ scope=MemoryScope.workspace,
52
+ mem_type=MemoryType.episodic,
53
+ content=(
54
+ f"[{run.adapter_kind.value}/{run.role.value}] for objective "
55
+ f"'{objective_tag}':\n{run.final_output}"
56
+ ),
57
+ tags=["episodic", run.adapter_kind.value, objective_tag],
58
+ source_run_id=run.id,
59
+ )
60
+ written += 1 if item is not None else 0
61
+
62
+ if sess.summary:
63
+ await ingest_memory(
64
+ db,
65
+ namespace=workspace_ns(workspace.slug),
66
+ scope=MemoryScope.workspace,
67
+ mem_type=MemoryType.summary,
68
+ content=sess.summary,
69
+ tags=["session_summary", objective_tag],
70
+ )
71
+ written += 1
72
+
73
+ log.info("memory_extracted", session_id=str(session_id), items=written)
74
+ return written
@@ -0,0 +1,106 @@
1
+ """Memory ingestion pipeline: redact → dedup → store item → chunk → embed.
2
+
3
+ Idempotent on ``(namespace, dedup_hash)`` — re-ingesting identical content is a
4
+ no-op. Secrets are redacted before anything is stored or embedded."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import uuid
10
+
11
+ from sqlalchemy import select
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+
14
+ from harness.memory.embed import Embedder, get_embedder
15
+ from harness.memory.namespaces import ttl_for
16
+ from harness.memory.redact import redact
17
+ from harness.memory.sqlite_vec_backend import SqliteVecIndex
18
+ from harness.models.enums import MemoryScope, MemoryType
19
+ from harness.models.memory import MemoryEmbedding, MemoryItem
20
+
21
+
22
+ def _dedup_hash(namespace: str, content: str) -> str:
23
+ return hashlib.sha256(f"{namespace}\x00{content}".encode()).hexdigest()
24
+
25
+
26
+ def _chunk(text: str, *, size: int = 800, overlap: int = 100) -> list[str]:
27
+ if len(text) <= size:
28
+ return [text]
29
+ chunks, start = [], 0
30
+ while start < len(text):
31
+ chunks.append(text[start : start + size])
32
+ start += size - overlap
33
+ return chunks
34
+
35
+
36
+ async def ingest_memory(
37
+ session: AsyncSession,
38
+ *,
39
+ namespace: str,
40
+ scope: MemoryScope,
41
+ mem_type: MemoryType,
42
+ content: str,
43
+ tags: list[str] | None = None,
44
+ source_run_id: uuid.UUID | None = None,
45
+ embedder: Embedder | None = None,
46
+ ttl_hours_session: int | None = 72,
47
+ ttl_hours_workspace: int | None = None,
48
+ ) -> MemoryItem:
49
+ """Ingest one memory item. Returns the existing item if a duplicate."""
50
+ redacted_content, was_redacted = redact(content)
51
+ dedup = _dedup_hash(namespace, redacted_content)
52
+
53
+ existing = (
54
+ await session.execute(
55
+ select(MemoryItem).where(
56
+ MemoryItem.namespace == namespace, MemoryItem.dedup_hash == dedup
57
+ )
58
+ )
59
+ ).scalar_one_or_none()
60
+ if existing is not None:
61
+ return existing
62
+
63
+ # Embed BEFORE touching the DB — network I/O must never run inside the
64
+ # SQLite write transaction (holds the write lock and starves concurrent writers).
65
+ embedder = embedder or get_embedder()
66
+ chunks = _chunk(redacted_content)
67
+ vectors = await embedder.embed(chunks)
68
+
69
+ item = MemoryItem(
70
+ namespace=namespace,
71
+ scope=scope,
72
+ type=mem_type,
73
+ content=redacted_content,
74
+ tags=tags or [],
75
+ source_run_id=source_run_id,
76
+ dedup_hash=dedup,
77
+ redacted=was_redacted,
78
+ ttl_expires_at=ttl_for(
79
+ scope,
80
+ session_volatile_hours=ttl_hours_session,
81
+ workspace_durable_hours=ttl_hours_workspace,
82
+ ),
83
+ )
84
+ session.add(item)
85
+ await session.flush() # assign item.id
86
+
87
+ vec_loaded = await SqliteVecIndex.loaded_for_session(session)
88
+ if vec_loaded:
89
+ await SqliteVecIndex.ensure_table(session, embedder.dim)
90
+
91
+ for idx, (chunk, vec) in enumerate(zip(chunks, vectors, strict=True)):
92
+ session.add(
93
+ MemoryEmbedding(
94
+ memory_item_id=item.id,
95
+ chunk_idx=idx,
96
+ model=embedder.model,
97
+ embedding=vec,
98
+ meta={"chars": len(chunk)},
99
+ )
100
+ )
101
+ if vec_loaded:
102
+ # Mirror into the vec0 ANN table; item.id is a UUID, stored as str.
103
+ await SqliteVecIndex.upsert(session, str(item.id), idx, vec)
104
+
105
+ await session.flush()
106
+ return item
@@ -0,0 +1,57 @@
1
+ """Memory namespaces and TTL policy.
2
+
3
+ A namespace is a hierarchical key that enforces isolation and enables targeted
4
+ retrieval. Format:
5
+
6
+ ws/<workspace_slug>[/project/<name>][/provider/<name>][/session/<id>]
7
+
8
+ Retrieval is namespace-bounded by default — no implicit cross-project leakage.
9
+ Workspace-scoped durable memory uses the ``ws/<slug>`` prefix; session-scoped
10
+ volatile memory appends ``/session/<id>`` and carries a TTL.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from datetime import UTC, datetime, timedelta
16
+
17
+ from harness.models.enums import MemoryScope
18
+
19
+
20
+ def workspace_ns(slug: str, *, project: str | None = None) -> str:
21
+ ns = f"ws/{slug}"
22
+ if project:
23
+ ns += f"/project/{project}"
24
+ return ns
25
+
26
+
27
+ def session_ns(slug: str, session_id: str, *, project: str | None = None) -> str:
28
+ return f"{workspace_ns(slug, project=project)}/session/{session_id}"
29
+
30
+
31
+ def provider_ns(slug: str, provider: str, *, project: str | None = None) -> str:
32
+ return f"{workspace_ns(slug, project=project)}/provider/{provider}"
33
+
34
+
35
+ def namespace_prefixes_for_retrieval(
36
+ slug: str, *, session_id: str | None = None, project: str | None = None
37
+ ) -> list[str]:
38
+ """Namespaces a retrieval should search: the session (if any) plus the
39
+ workspace, most specific first. Never crosses into another workspace."""
40
+ prefixes = [workspace_ns(slug, project=project)]
41
+ if session_id:
42
+ prefixes.insert(0, session_ns(slug, session_id, project=project))
43
+ return prefixes
44
+
45
+
46
+ def ttl_for(
47
+ scope: MemoryScope,
48
+ *,
49
+ session_volatile_hours: int | None = 72,
50
+ workspace_durable_hours: int | None = None,
51
+ ) -> datetime | None:
52
+ """Compute an absolute expiry from policy. Workspace memory is durable
53
+ (no expiry) by default; session memory is volatile."""
54
+ hours = session_volatile_hours if scope == MemoryScope.session else workspace_durable_hours
55
+ if hours is None:
56
+ return None
57
+ return datetime.now(UTC) + timedelta(hours=hours)