@pentatonic-ai/ai-agent-sdk 0.9.5 → 0.10.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.
Files changed (131) hide show
  1. package/README.md +3 -3
  2. package/bin/cli.js +1 -1
  3. package/bin/commands/config.js +1 -1
  4. package/dist/index.cjs +39 -72
  5. package/dist/index.js +36 -69
  6. package/package.json +10 -3
  7. package/packages/doctor/src/checks/local-memory.js +2 -2
  8. package/packages/memory/README.md +2 -2
  9. package/packages/memory/openclaw-plugin/README.md +2 -2
  10. package/packages/memory/openclaw-plugin/openclaw.plugin.json +1 -1
  11. package/packages/memory/package-lock.json +49 -33
  12. package/packages/memory/package.json +4 -1
  13. package/packages/memory/src/__tests__/engine.test.js +40 -5
  14. package/packages/memory/src/engine.js +38 -3
  15. package/packages/memory/src/server.js +2 -2
  16. package/packages/memory-engine-v2/.env.example +30 -0
  17. package/packages/memory-engine-v2/README.md +125 -0
  18. package/packages/memory-engine-v2/compat/Dockerfile +11 -0
  19. package/packages/memory-engine-v2/compat/requirements.txt +6 -0
  20. package/packages/memory-engine-v2/compat/server.py +1047 -0
  21. package/packages/memory-engine-v2/docker-compose.aws.yml +78 -0
  22. package/packages/memory-engine-v2/docker-compose.yml +206 -0
  23. package/packages/memory-engine-v2/extractor-async/Dockerfile +14 -0
  24. package/packages/memory-engine-v2/extractor-async/confidence.py +62 -0
  25. package/packages/memory-engine-v2/extractor-async/noise_filter.py +144 -0
  26. package/packages/memory-engine-v2/extractor-async/requirements.txt +2 -0
  27. package/packages/memory-engine-v2/extractor-async/test_confidence.py +76 -0
  28. package/packages/memory-engine-v2/extractor-async/test_noise_filter.py +177 -0
  29. package/packages/memory-engine-v2/extractor-async/worker.py +797 -0
  30. package/packages/memory-engine-v2/extractor-sync/Dockerfile +11 -0
  31. package/packages/memory-engine-v2/extractor-sync/requirements.txt +4 -0
  32. package/packages/memory-engine-v2/extractor-sync/server.py +424 -0
  33. package/packages/memory-engine-v2/org-model/migrations/001_init.sql +390 -0
  34. package/packages/memory-engine-v2/tests/e2e_smoke.py +356 -0
  35. package/packages/memory-engine-v2/tests/fixtures/generate_synthetic_corpus.py +758 -0
  36. package/packages/memory-engine/.env.example +0 -13
  37. package/packages/memory-engine/MIGRATION.md +0 -219
  38. package/packages/memory-engine/README.md +0 -145
  39. package/packages/memory-engine/bench/README.md +0 -99
  40. package/packages/memory-engine/bench/scorecards-engine/agent-coding__pentatonic-baseline__20260427-142523.json +0 -1115
  41. package/packages/memory-engine/bench/scorecards-engine/chat-recall__pentatonic-baseline__20260427-142648.json +0 -819
  42. package/packages/memory-engine/bench/scorecards-engine/circular-economy__pentatonic-baseline__20260427-142757.json +0 -1278
  43. package/packages/memory-engine/bench/scorecards-engine/customer-support__pentatonic-baseline__20260427-142900.json +0 -1018
  44. package/packages/memory-engine/bench/scorecards-engine/marketplace-ops__pentatonic-baseline__20260427-142957.json +0 -1038
  45. package/packages/memory-engine/bench/scorecards-engine/product-catalogue__pentatonic-baseline__20260427-143122.json +0 -961
  46. package/packages/memory-engine/bench/scorecards-engine-via-docker/agent-coding__pentatonic-memory__20260427-161812.json +0 -1115
  47. package/packages/memory-engine/bench/scorecards-engine-via-docker/chat-recall__pentatonic-memory__20260427-161701.json +0 -819
  48. package/packages/memory-engine/bench/scorecards-engine-via-docker/circular-economy__pentatonic-memory__20260427-161713.json +0 -1278
  49. package/packages/memory-engine/bench/scorecards-engine-via-docker/customer-support__pentatonic-memory__20260427-161723.json +0 -1018
  50. package/packages/memory-engine/bench/scorecards-engine-via-docker/marketplace-ops__pentatonic-memory__20260427-161732.json +0 -1038
  51. package/packages/memory-engine/bench/scorecards-engine-via-docker/product-catalogue__pentatonic-memory__20260427-161741.json +0 -937
  52. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/agent-coding__pentatonic-memory__20260427-184718.json +0 -1115
  53. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/chat-recall__pentatonic-memory__20260427-184614.json +0 -819
  54. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/circular-economy__pentatonic-memory__20260427-184809.json +0 -1278
  55. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/customer-support__pentatonic-memory__20260427-184854.json +0 -1018
  56. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/marketplace-ops__pentatonic-memory__20260427-184929.json +0 -1038
  57. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/product-catalogue__pentatonic-memory__20260427-185015.json +0 -961
  58. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/agent-coding__pentatonic-memory__20260427-175252.json +0 -1115
  59. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/chat-recall__pentatonic-memory__20260427-175312.json +0 -819
  60. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/circular-economy__pentatonic-memory__20260427-175335.json +0 -1278
  61. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/customer-support__pentatonic-memory__20260427-175355.json +0 -1018
  62. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/marketplace-ops__pentatonic-memory__20260427-175413.json +0 -1038
  63. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/product-catalogue__pentatonic-memory__20260427-175430.json +0 -883
  64. package/packages/memory-engine/bench/scorecards-engine-via-shim/agent-coding__pentatonic-memory__20260427-155409.json +0 -1115
  65. package/packages/memory-engine/bench/scorecards-engine-via-shim/chat-recall__pentatonic-memory__20260427-155421.json +0 -819
  66. package/packages/memory-engine/bench/scorecards-engine-via-shim/circular-economy__pentatonic-memory__20260427-155433.json +0 -1278
  67. package/packages/memory-engine/bench/scorecards-engine-via-shim/customer-support__pentatonic-memory__20260427-155443.json +0 -1018
  68. package/packages/memory-engine/bench/scorecards-engine-via-shim/marketplace-ops__pentatonic-memory__20260427-155453.json +0 -1038
  69. package/packages/memory-engine/bench/scorecards-engine-via-shim/product-catalogue__pentatonic-memory__20260427-155503.json +0 -937
  70. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory-latest__20260427-145103.json +0 -1115
  71. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory__20260427-144909.json +0 -1115
  72. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory-latest__20260427-145153.json +0 -819
  73. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory__20260427-145120.json +0 -542
  74. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory-latest__20260427-145313.json +0 -1278
  75. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory__20260427-145207.json +0 -894
  76. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory-latest__20260427-145412.json +0 -1018
  77. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory__20260427-145327.json +0 -680
  78. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory-latest__20260427-145517.json +0 -1038
  79. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory__20260427-145422.json +0 -693
  80. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory-latest__20260427-145616.json +0 -961
  81. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory__20260427-145528.json +0 -727
  82. package/packages/memory-engine/compat/Dockerfile +0 -22
  83. package/packages/memory-engine/compat/server.py +0 -1255
  84. package/packages/memory-engine/docker-compose.test.yml +0 -59
  85. package/packages/memory-engine/docker-compose.yml +0 -240
  86. package/packages/memory-engine/engine/README.md +0 -52
  87. package/packages/memory-engine/engine/l2-hybridrag-proxy.py +0 -1543
  88. package/packages/memory-engine/engine/l5-comms-layer.py +0 -663
  89. package/packages/memory-engine/engine/l6-document-store.py +0 -1018
  90. package/packages/memory-engine/engine/services/_shared/__init__.py +0 -1
  91. package/packages/memory-engine/engine/services/_shared/embed_provider.py +0 -468
  92. package/packages/memory-engine/engine/services/l2/Dockerfile +0 -50
  93. package/packages/memory-engine/engine/services/l2/init_databases.py +0 -81
  94. package/packages/memory-engine/engine/services/l2/l2-hybridrag-proxy.py +0 -2721
  95. package/packages/memory-engine/engine/services/l5/Dockerfile +0 -11
  96. package/packages/memory-engine/engine/services/l5/l5-comms-layer.py +0 -808
  97. package/packages/memory-engine/engine/services/l6/Dockerfile +0 -30
  98. package/packages/memory-engine/engine/services/l6/l6-document-store.py +0 -1221
  99. package/packages/memory-engine/engine/services/nv-embed/Dockerfile +0 -28
  100. package/packages/memory-engine/engine/services/nv-embed/server.py +0 -152
  101. package/packages/memory-engine/pme_memory/__init__.py +0 -0
  102. package/packages/memory-engine/pme_memory/__main__.py +0 -129
  103. package/packages/memory-engine/pme_memory/artifacts.py +0 -95
  104. package/packages/memory-engine/pme_memory/embed.py +0 -74
  105. package/packages/memory-engine/pme_memory/health.py +0 -36
  106. package/packages/memory-engine/pme_memory/hygiene.py +0 -159
  107. package/packages/memory-engine/pme_memory/indexer.py +0 -200
  108. package/packages/memory-engine/pme_memory/needs.py +0 -55
  109. package/packages/memory-engine/pme_memory/provenance.py +0 -80
  110. package/packages/memory-engine/pme_memory/scoring.py +0 -168
  111. package/packages/memory-engine/pme_memory/search.py +0 -52
  112. package/packages/memory-engine/pme_memory/store.py +0 -86
  113. package/packages/memory-engine/pme_memory/synthesis.py +0 -114
  114. package/packages/memory-engine/pyproject.toml +0 -65
  115. package/packages/memory-engine/scripts/kg-extractor.py +0 -557
  116. package/packages/memory-engine/scripts/kg-preflexor-v2.py +0 -738
  117. package/packages/memory-engine/scripts/wipe-legacy-l3-entities.py +0 -128
  118. package/packages/memory-engine/tests/e2e_arena.sh +0 -259
  119. package/packages/memory-engine/tests/embed_stub/Dockerfile +0 -13
  120. package/packages/memory-engine/tests/embed_stub/server.py +0 -80
  121. package/packages/memory-engine/tests/test_aggregate.py +0 -333
  122. package/packages/memory-engine/tests/test_api_contract.sh +0 -57
  123. package/packages/memory-engine/tests/test_arena_safety.py +0 -232
  124. package/packages/memory-engine/tests/test_channel_stat_reader.py +0 -437
  125. package/packages/memory-engine/tests/test_channel_stat_rollups.py +0 -308
  126. package/packages/memory-engine/tests/test_compat_nv_embed_probe.py +0 -48
  127. package/packages/memory-engine/tests/test_embed_provider.py +0 -492
  128. package/packages/memory-engine/tests/test_l2_qmd_vec_search.py +0 -280
  129. package/packages/memory-engine/tests/test_l3_arena_isolation.py +0 -412
  130. package/packages/memory-engine/tests/test_l6_module_load.py +0 -84
  131. package/packages/memory-engine/tests/test_people_list_reader.py +0 -432
@@ -1,808 +0,0 @@
1
- #!/usr/bin/env python3
2
- """L5 Communications Layer — Deep semantic search over life data.
3
-
4
- Collections:
5
- - chats: Telegram, WhatsApp, iMessage, Slack transcripts
6
- - emails: Email archives (markdown summaries)
7
- - contacts: People profiles + contact records
8
- - memory: Daily notes, project docs, research files
9
-
10
- Usage:
11
- python3 l5-comms-layer.py index # Index all sources
12
- python3 l5-comms-layer.py index chats # Index just chats
13
- python3 l5-comms-layer.py search "query" # Search across all collections
14
- python3 l5-comms-layer.py search "query" --collection chats
15
- python3 l5-comms-layer.py health # Health check
16
- python3 l5-comms-layer.py stats # Collection stats
17
- python3 l5-comms-layer.py serve # Run as HTTP server (port 8034)
18
- """
19
-
20
- import argparse
21
- import logging
22
- import os
23
- import glob
24
- import hashlib
25
- import json
26
- import sys
27
- import time
28
- from datetime import datetime
29
- from pathlib import Path
30
-
31
- import httpx
32
- from pymilvus import MilvusClient, DataType, CollectionSchema, FieldSchema
33
-
34
- # Shared embed client lives at engine/services/_shared/.
35
- sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
36
- from _shared.embed_provider import EmbedClient # noqa: E402
37
-
38
- # --- Config ---
39
- DB_PATH = os.environ.get(
40
- "L5_DB_PATH",
41
- str(Path.home() / "memory-l5" / "comms.db"),
42
- )
43
- WORKSPACE = Path(os.environ.get("PME_WORKSPACE", ".pentatonic"))
44
- CLAWD_CHATS_DIR = Path.home() / "clawd" / "chats" # Legacy archive
45
- CHATS_DIR = WORKSPACE / "chats"
46
- EMAILS_DIR = WORKSPACE / "memory" / "chats" / "email"
47
- PEOPLE_DIR = WORKSPACE / "memory" / "people"
48
- CONTACTS_DIR = WORKSPACE / "memory" / "contacts"
49
- MEMORY_DIR = WORKSPACE / "memory"
50
-
51
- _embed: EmbedClient | None = None
52
-
53
-
54
- def _embed_client() -> EmbedClient:
55
- """Lazily build the shared EmbedClient for L5."""
56
- global _embed
57
- if _embed is None:
58
- _embed = EmbedClient.from_env(
59
- prefix="L5_",
60
- default_url="http://localhost:8041/v1/embeddings",
61
- )
62
- return _embed
63
-
64
-
65
- def _embed_post(texts):
66
- """Embed a batch of texts via the shared EmbedClient.
67
-
68
- Provider profile (auth scheme + URL path + body/response shape) is
69
- chosen by L5_EMBED_PROVIDER env var (openai | pentatonic-gateway |
70
- cohere | custom). Auto-detects on 401 unless L5_EMBED_AUTODETECT=false.
71
-
72
- Returns: list[list[float]] (one embedding per input text).
73
- """
74
- return _embed_client().embed_batch(texts)
75
-
76
- # Ollama fallback path. URL/model can be overridden so the L5 container can
77
- # reach an Ollama instance running on the docker host (host.docker.internal)
78
- # or on a co-located service. Mirrors the env-var pattern used by L2.
79
- OLLAMA_EMBED_URL = os.environ.get(
80
- "L5_OLLAMA_EMBED_URL", "http://localhost:11434/api/embed"
81
- )
82
- OLLAMA_EMBED_MODEL = os.environ.get("L5_OLLAMA_EMBED_MODEL", "nomic-embed-text")
83
- # Vector dim. Default matches NV-Embed-v2; override for smaller-dim models
84
- # (e.g. 768 for nomic-embed-text, 1024 for mxbai-embed-large). Milvus
85
- # collections are created at this dim; existing data won't survive a dim
86
- # change — wipe the L5 volume to switch.
87
- EMBED_DIM = int(os.environ.get("L5_EMBED_DIM", "4096"))
88
- # Dim of the Ollama-fallback model. If equal to EMBED_DIM, the fallback
89
- # returns vectors as-is; if smaller, they're zero-padded to EMBED_DIM.
90
- OLLAMA_DIM = int(os.environ.get("L5_OLLAMA_DIM", "768"))
91
- CHUNK_SIZE = 512 # chars per chunk
92
- CHUNK_OVERLAP = 64
93
- BATCH_SIZE = 100 # embeddings per batch
94
-
95
-
96
- def get_client():
97
- return MilvusClient(uri=DB_PATH)
98
-
99
-
100
- def embed_texts(texts: list[str]) -> list[list[float]]:
101
- """Get embeddings — NV-Embed-v2 batch primary, Ollama fallback."""
102
- # Try batch NV-Embed first
103
- batch_result = _embed_nv_batch(texts)
104
- if batch_result is not None:
105
- return batch_result
106
- # Fallback: one at a time
107
- results = []
108
- for text in texts:
109
- emb = _embed_nv_single(text)
110
- if emb is None:
111
- emb = _embed_ollama(text)
112
- results.append(emb if emb else [0.0] * EMBED_DIM)
113
- return results
114
-
115
-
116
- def _embed_nv_batch(texts: list[str]) -> list[list[float]] | None:
117
- """Batch embed via NV-Embed-v2 (4096-dim). Returns None on failure."""
118
- if not texts:
119
- return []
120
- try:
121
- truncated = [t[:4000] for t in texts]
122
- embeddings = _embed_post(truncated)
123
- if all(len(e) == EMBED_DIM for e in embeddings):
124
- return embeddings
125
- except Exception:
126
- logging.debug(f"Suppressed error in l5-comms-layer.py")
127
- return None
128
-
129
-
130
- def _embed_nv_single(text: str) -> list[float] | None:
131
- """Embed single text via NV-Embed-v2 (4096-dim)."""
132
- try:
133
- embs = _embed_post([text[:4000]])
134
- emb = embs[0]
135
- if len(emb) == EMBED_DIM:
136
- return emb
137
- except Exception:
138
- logging.debug(f"Suppressed error in l5-comms-layer.py")
139
- return None
140
-
141
-
142
- def _embed_ollama(text: str) -> list[float] | None:
143
- """Fallback: Ollama nomic-embed (768-dim), zero-padded to EMBED_DIM."""
144
- try:
145
- r = httpx.post(OLLAMA_EMBED_URL, json={"model": OLLAMA_EMBED_MODEL, "input": text}, timeout=30)
146
- r.raise_for_status()
147
- data = r.json()
148
- emb = data.get("embeddings", [data.get("embedding", [])])[0]
149
- if isinstance(emb, list) and len(emb) == OLLAMA_DIM:
150
- # Zero-pad to 4096 for Milvus compatibility
151
- return emb + [0.0] * (EMBED_DIM - OLLAMA_DIM)
152
- except Exception as e:
153
- print(f" Embed error: {e}")
154
- return None
155
-
156
-
157
- def chunk_text(text: str, chunk_size=CHUNK_SIZE, overlap=CHUNK_OVERLAP) -> list[str]:
158
- """Split text into overlapping chunks."""
159
- if len(text) <= chunk_size:
160
- return [text] if text.strip() else []
161
- chunks = []
162
- start = 0
163
- while start < len(text):
164
- end = start + chunk_size
165
- chunk = text[start:end].strip()
166
- if chunk:
167
- chunks.append(chunk)
168
- start = end - overlap
169
- return chunks
170
-
171
-
172
- def text_id(text: str, source: str) -> str:
173
- return hashlib.md5(f"{source}:{text[:200]}".encode()).hexdigest()
174
-
175
-
176
- def ensure_collection(client, name: str):
177
- """Create collection if not exists."""
178
- if client.has_collection(name):
179
- return
180
- schema = client.create_schema(auto_id=False, enable_dynamic_field=True)
181
- schema.add_field("id", DataType.VARCHAR, is_primary=True, max_length=64)
182
- schema.add_field("vector", DataType.FLOAT_VECTOR, dim=EMBED_DIM)
183
- schema.add_field("text", DataType.VARCHAR, max_length=8192)
184
- schema.add_field("source", DataType.VARCHAR, max_length=512)
185
- schema.add_field("channel", DataType.VARCHAR, max_length=64)
186
- schema.add_field("contact", DataType.VARCHAR, max_length=256)
187
- schema.add_field("timestamp", DataType.VARCHAR, max_length=32)
188
-
189
- index_params = client.prepare_index_params()
190
- index_params.add_index(field_name="vector", index_type="FLAT", metric_type="COSINE")
191
- client.create_collection(collection_name=name, schema=schema, index_params=index_params)
192
- print(f" Created collection: {name}")
193
-
194
-
195
- # --- Indexers ---
196
-
197
- def index_chats(client):
198
- """Index JSONL chat transcripts."""
199
- ensure_collection(client, "chats")
200
- total = 0
201
-
202
- # Walk all JSONL files under chats/
203
- jsonl_files = list(CHATS_DIR.rglob("*.jsonl"))
204
- # Also grab .txt chat exports
205
- txt_files = list(CHATS_DIR.rglob("*.txt"))
206
-
207
- print(f" Found {len(jsonl_files)} JSONL + {len(txt_files)} TXT chat files")
208
-
209
- for f in jsonl_files:
210
- try:
211
- lines = f.read_text(errors="replace").strip().split("\n")
212
- batch_data = []
213
-
214
- for line in lines:
215
- try:
216
- msg = json.loads(line)
217
- except json.JSONDecodeError:
218
- continue
219
-
220
- text = msg.get("text", "")
221
- if not text or len(text) < 10:
222
- continue
223
-
224
- channel = msg.get("channel", "unknown")
225
- contact = msg.get("contact", msg.get("sender", ""))
226
- ts = msg.get("timestamp", "")
227
- source = str(f.relative_to(WORKSPACE))
228
-
229
- for chunk in chunk_text(text):
230
- doc_id = text_id(chunk, source)
231
- batch_data.append({
232
- "id": doc_id,
233
- "text": chunk[:8000],
234
- "source": source[:500],
235
- "channel": channel[:60],
236
- "contact": str(contact)[:250],
237
- "timestamp": str(ts)[:30],
238
- })
239
-
240
- if len(batch_data) >= BATCH_SIZE:
241
- vectors = embed_texts([d["text"] for d in batch_data])
242
- for d, v in zip(batch_data, vectors):
243
- d["vector"] = v
244
- client.upsert(collection_name="chats", data=batch_data)
245
- total += len(batch_data)
246
- batch_data = []
247
-
248
- # Flush remaining
249
- if batch_data:
250
- vectors = embed_texts([d["text"] for d in batch_data])
251
- for d, v in zip(batch_data, vectors):
252
- d["vector"] = v
253
- client.upsert(collection_name="chats", data=batch_data)
254
- total += len(batch_data)
255
-
256
- except Exception as e:
257
- print(f" Error indexing {f}: {e}")
258
-
259
- # Index markdown chat summaries
260
- for channel_dir in ["telegram", "whatsapp", "imessage", "slack", "unknown"]:
261
- chat_md_dir = WORKSPACE / "memory" / "chats" / channel_dir
262
- if not chat_md_dir.exists():
263
- continue
264
- for f in chat_md_dir.glob("*.md"):
265
- try:
266
- text = f.read_text(errors="replace")
267
- if len(text) < 20:
268
- continue
269
- source = str(f.relative_to(WORKSPACE))
270
- batch_data = []
271
- for chunk in chunk_text(text):
272
- doc_id = text_id(chunk, source)
273
- batch_data.append({
274
- "id": doc_id,
275
- "text": chunk[:8000],
276
- "source": source[:500],
277
- "channel": channel_dir,
278
- "contact": f.stem[:250],
279
- "timestamp": "",
280
- })
281
- if batch_data:
282
- vectors = embed_texts([d["text"] for d in batch_data])
283
- for d, v in zip(batch_data, vectors):
284
- d["vector"] = v
285
- client.upsert(collection_name="chats", data=batch_data)
286
- total += len(batch_data)
287
- except Exception as e:
288
- print(f" Error indexing {f}: {e}")
289
-
290
- print(f" Indexed {total} chat chunks")
291
- return total
292
-
293
-
294
- def index_emails(client):
295
- """Index email archives."""
296
- ensure_collection(client, "emails")
297
- total = 0
298
-
299
- if not EMAILS_DIR.exists():
300
- print(" No email directory found")
301
- return 0
302
-
303
- for f in EMAILS_DIR.glob("*.md"):
304
- try:
305
- text = f.read_text(errors="replace")
306
- if len(text) < 20:
307
- continue
308
- source = str(f.relative_to(WORKSPACE))
309
- # Extract contact from filename
310
- contact = f.stem.replace("", "").replace("_", " ")[:250]
311
- batch_data = []
312
- for chunk in chunk_text(text):
313
- doc_id = text_id(chunk, source)
314
- batch_data.append({
315
- "id": doc_id,
316
- "text": chunk[:8000],
317
- "source": source[:500],
318
- "channel": "email",
319
- "contact": contact,
320
- "timestamp": "",
321
- })
322
- if batch_data:
323
- vectors = embed_texts([d["text"] for d in batch_data])
324
- for d, v in zip(batch_data, vectors):
325
- d["vector"] = v
326
- client.upsert(collection_name="emails", data=batch_data)
327
- total += len(batch_data)
328
- except Exception as e:
329
- print(f" Error indexing {f}: {e}")
330
-
331
- print(f" Indexed {total} email chunks")
332
- return total
333
-
334
-
335
- def index_contacts(client):
336
- """Index people profiles and contacts."""
337
- ensure_collection(client, "contacts")
338
- total = 0
339
-
340
- # People profiles
341
- if PEOPLE_DIR.exists():
342
- for f in PEOPLE_DIR.glob("*.md"):
343
- try:
344
- text = f.read_text(errors="replace")
345
- if len(text) < 20:
346
- continue
347
- source = str(f.relative_to(WORKSPACE))
348
- batch_data = []
349
- for chunk in chunk_text(text):
350
- doc_id = text_id(chunk, source)
351
- batch_data.append({
352
- "id": doc_id,
353
- "text": chunk[:8000],
354
- "source": source[:500],
355
- "channel": "profile",
356
- "contact": f.stem[:250],
357
- "timestamp": "",
358
- })
359
- if batch_data:
360
- vectors = embed_texts([d["text"] for d in batch_data])
361
- for d, v in zip(batch_data, vectors):
362
- d["vector"] = v
363
- client.upsert(collection_name="contacts", data=batch_data)
364
- total += len(batch_data)
365
- except Exception as e:
366
- print(f" Error: {e}")
367
-
368
- # Contact files
369
- if CONTACTS_DIR.exists():
370
- for f in CONTACTS_DIR.glob("*"):
371
- if not f.is_file():
372
- continue
373
- try:
374
- text = f.read_text(errors="replace")
375
- if len(text) < 20:
376
- continue
377
- source = str(f.relative_to(WORKSPACE))
378
- batch_data = []
379
- for chunk in chunk_text(text, chunk_size=1024):
380
- doc_id = text_id(chunk, source)
381
- batch_data.append({
382
- "id": doc_id,
383
- "text": chunk[:8000],
384
- "source": source[:500],
385
- "channel": "contacts",
386
- "contact": "",
387
- "timestamp": "",
388
- })
389
- if batch_data:
390
- vectors = embed_texts([d["text"] for d in batch_data])
391
- for d, v in zip(batch_data, vectors):
392
- d["vector"] = v
393
- client.upsert(collection_name="contacts", data=batch_data)
394
- total += len(batch_data)
395
- except Exception as e:
396
- print(f" Error: {e}")
397
-
398
- print(f" Indexed {total} contact chunks")
399
- return total
400
-
401
-
402
- def index_memory(client):
403
- """Index memory markdown files (daily notes, projects, research, rules)."""
404
- ensure_collection(client, "memory")
405
- total = 0
406
-
407
- # Skip chats/ (handled separately) and evolution run logs (too many, low value)
408
- skip_patterns = ["chats/", "evolution/loop-run-", "evolution/v3/runs/"]
409
-
410
- for f in MEMORY_DIR.rglob("*.md"):
411
- source = str(f.relative_to(WORKSPACE))
412
- if any(p in source for p in skip_patterns):
413
- continue
414
- try:
415
- text = f.read_text(errors="replace")
416
- if len(text) < 30:
417
- continue
418
- batch_data = []
419
- for chunk in chunk_text(text):
420
- doc_id = text_id(chunk, source)
421
- batch_data.append({
422
- "id": doc_id,
423
- "text": chunk[:8000],
424
- "source": source[:500],
425
- "channel": "memory",
426
- "contact": "",
427
- "timestamp": "",
428
- })
429
- if batch_data:
430
- vectors = embed_texts([d["text"] for d in batch_data])
431
- for d, v in zip(batch_data, vectors):
432
- d["vector"] = v
433
- client.upsert(collection_name="memory", data=batch_data)
434
- total += len(batch_data)
435
- except Exception as e:
436
- print(f" Error: {e}")
437
-
438
- print(f" Indexed {total} memory chunks")
439
- return total
440
-
441
-
442
- # --- Search ---
443
-
444
- def search(query: str, collection: str = None, limit: int = 10,
445
- arena: str = None, arenas=None):
446
- """Search across collections.
447
-
448
- arena / arenas (optional): when set, filter rows whose `arena`
449
- dynamic field matches. Multi-arena uses Milvus `in [...]` so a
450
- single-pass user-scoped search (tenant + own user) returns rows
451
- from both buckets. Records without an arena tag are dropped under
452
- multi-tenant safety.
453
- """
454
- client = get_client()
455
- vectors = embed_texts([query])
456
- if not vectors or all(v == 0.0 for v in vectors[0]):
457
- print("Failed to embed query")
458
- return []
459
-
460
- collections = [collection] if collection else ["chats", "emails", "contacts", "memory"]
461
- all_results = []
462
-
463
- # Normalize arenas list and build the Milvus filter expression.
464
- if arenas is None:
465
- arena_list = [arena] if arena else []
466
- else:
467
- arena_list = [a for a in arenas if a]
468
- filter_expr = ""
469
- if len(arena_list) == 1:
470
- safe = str(arena_list[0]).replace('"', '\\"')
471
- filter_expr = f'arena == "{safe}"'
472
- elif len(arena_list) > 1:
473
- quoted = ", ".join(
474
- '"{}"'.format(str(a).replace('"', '\\"')) for a in arena_list
475
- )
476
- filter_expr = f'arena in [{quoted}]'
477
-
478
- for coll in collections:
479
- if not client.has_collection(coll):
480
- continue
481
- try:
482
- results = client.search(
483
- collection_name=coll,
484
- data=[vectors[0]],
485
- limit=limit,
486
- filter=filter_expr,
487
- output_fields=["text", "source", "channel", "contact", "timestamp", "arena"],
488
- )
489
- for hits in results:
490
- for hit in hits:
491
- entity = hit.get("entity", {})
492
- all_results.append({
493
- "id": hit.get("id", ""),
494
- "collection": coll,
495
- "score": round(hit.get("distance", 0), 4),
496
- "text": entity.get("text", ""),
497
- "source": entity.get("source", ""),
498
- "channel": entity.get("channel", ""),
499
- "contact": entity.get("contact", ""),
500
- "timestamp": entity.get("timestamp", ""),
501
- "arena": entity.get("arena", ""),
502
- })
503
- except Exception as e:
504
- print(f" Search error in {coll}: {e}")
505
-
506
- all_results.sort(key=lambda x: x["score"], reverse=True)
507
- return all_results[:limit]
508
-
509
-
510
- # --- Health / Stats ---
511
-
512
- def health():
513
- """Check L5 health.
514
-
515
- Reports "ok" iff the Milvus client can list collections — that's
516
- L5's actual data plane. Embeddings are intentionally NOT probed
517
- here: that's a separate concern reported by the compat shim's
518
- nv_embed entry. Probing an external embedding endpoint on every
519
- /health adds latency and false negatives for layers that only
520
- embed on demand.
521
- """
522
- try:
523
- client = get_client()
524
- collections = ["chats", "emails", "contacts", "memory"]
525
- out = {"status": "ok", "db_path": DB_PATH, "collections": {}}
526
- for coll in collections:
527
- if client.has_collection(coll):
528
- stats = client.get_collection_stats(coll)
529
- count = stats.get("row_count", 0)
530
- out["collections"][coll] = {"exists": True, "count": count}
531
- else:
532
- out["collections"][coll] = {"exists": False, "count": 0}
533
- out["total_chunks"] = sum(c["count"] for c in out["collections"].values())
534
- return out
535
- except Exception as e:
536
- return {"status": "error", "error": str(e)}
537
-
538
-
539
- def stats():
540
- """Print collection stats."""
541
- h = health()
542
- print(f"\nL5 Communications Layer — {h.get('status', 'unknown')}")
543
- print(f"DB: {h.get('db_path', '?')}")
544
- print(f"Embeddings: {'OK' if h.get('embeddings') else 'UNAVAILABLE'}")
545
- print(f"\nCollections:")
546
- for name, info in h.get("collections", {}).items():
547
- if info["exists"]:
548
- print(f" {name}: {info['count']:,} chunks")
549
- else:
550
- print(f" {name}: not created")
551
- print(f"\nTotal: {h.get('total_chunks', 0):,} chunks")
552
-
553
-
554
- # --- HTTP Server ---
555
-
556
- def serve(port=8034):
557
- """Run as HTTP API server."""
558
- from fastapi import FastAPI, Query
559
- import uvicorn
560
-
561
- # Bootstrap all 4 collections on startup. Previously only `chats`
562
- # was being created (the indexer entrypoints below each call their
563
- # own ensure_collection lazily, so collections without an indexer
564
- # — i.e. those fed solely via /index-internal or the compat shim's
565
- # _index_l5 fan-out — never came into existence and writes to them
566
- # 500'd). Idempotent: ensure_collection short-circuits if exists.
567
- try:
568
- bootstrap_client = get_client()
569
- for _name in ("chats", "emails", "contacts", "memory"):
570
- ensure_collection(bootstrap_client, _name)
571
- logging.info("L5 collections bootstrapped: chats, emails, contacts, memory")
572
- except Exception as exc:
573
- logging.warning(f"L5 collection bootstrap failed (continuing): {exc}")
574
-
575
- api = FastAPI(title="L5 Communications Layer")
576
-
577
- @api.get("/health")
578
- def api_health():
579
- return health()
580
-
581
- @api.get("/search")
582
- def api_search(q: str = Query(...), collection: str = None, limit: int = 10,
583
- arena: str = None, arenas: list = Query(default=[])):
584
- # `arenas` (repeated query param) wins when both are present.
585
- results = search(
586
- q, collection=collection, limit=limit,
587
- arena=arena, arenas=arenas or None,
588
- )
589
- return {"query": q, "results": results, "count": len(results)}
590
-
591
- @api.get("/stats")
592
- def api_stats():
593
- return health()
594
-
595
- @api.post("/index-batch")
596
- def api_index_batch(req: dict):
597
- """Index a batch of pre-formed records using a single batched
598
- NV-Embed call + a single milvus insert. Roughly 30-50x faster
599
- than calling /index for each item or running the legacy
600
- per-chunk indexers, which is critical for tests, smoke runs and
601
- bench harnesses where a few dozen docs need to land quickly.
602
-
603
- Request body::
604
-
605
- {
606
- "collection": "chats", # one of: chats|emails|contacts|memory
607
- "records": [
608
- {
609
- "id": "opt-stable-id", # optional, auto-generated if absent
610
- "text": "…", # required
611
- "source": "…", # optional
612
- "channel": "…", # optional
613
- "contact": "…" # optional
614
- }, …
615
- ]
616
- }
617
-
618
- Returns::
619
-
620
- {"status": "ok", "collection": "chats", "inserted": N,
621
- "embed_ms": float, "insert_ms": float}
622
- """
623
- import time as _time, hashlib as _hashlib
624
- collection = req.get("collection", "chats")
625
- records = req.get("records") or []
626
- if not records:
627
- return {"status": "ok", "inserted": 0, "collection": collection}
628
-
629
- client = get_client()
630
- ensure_collection(client, collection)
631
-
632
- # Shared-embed shortcut: caller (compat shim) computed vectors
633
- # once and forwards them so we skip the embed RPC. Length must
634
- # match records — fall back to per-layer embed if it doesn't.
635
- texts = [(r.get("text") or "")[:8192] for r in records]
636
- shared_embs = req.get("embeddings")
637
- t0 = _time.time()
638
- if isinstance(shared_embs, list) and len(shared_embs) == len(records):
639
- embs = shared_embs
640
- else:
641
- try:
642
- embs = _embed_post(texts)
643
- except Exception as exc:
644
- return {"status": "error", "error": f"embed failed: {exc}"}
645
- embed_ms = (_time.time() - t0) * 1000.0
646
-
647
- # Single batched insert. Mirror every field the chats collection
648
- # schema requires (id/vector/text/source/channel/contact/timestamp).
649
- from datetime import datetime as _dt, timezone as _tz
650
- _now = _dt.now(_tz.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
651
- rows = []
652
- for r, emb, txt in zip(records, embs, texts):
653
- if emb is None:
654
- continue
655
- rid = r.get("id") or _hashlib.sha1(txt.encode("utf-8")).hexdigest()[:32]
656
- rows.append({
657
- "id": rid[:63],
658
- "vector": emb,
659
- "text": txt,
660
- "source": (r.get("source") or "")[:512],
661
- "channel": (r.get("channel") or "")[:64],
662
- "contact": (r.get("contact") or "")[:256],
663
- "timestamp": (r.get("timestamp") or _now)[:32],
664
- # arena lands in the dynamic-field section of the
665
- # collection (enable_dynamic_field=True). Filterable
666
- # via `arena == "..."` in /search.
667
- "arena": (r.get("arena") or "general")[:64],
668
- })
669
- t1 = _time.time()
670
- if rows:
671
- client.insert(collection_name=collection, data=rows)
672
- insert_ms = (_time.time() - t1) * 1000.0
673
- return {
674
- "status": "ok",
675
- "collection": collection,
676
- "inserted": len(rows),
677
- "embed_ms": round(embed_ms, 1),
678
- "insert_ms": round(insert_ms, 1),
679
- }
680
-
681
- @api.get("/health/deep")
682
- def api_health_deep():
683
- """Real functional probe: assert all 4 collections exist + run
684
- embed+insert+search of a sentinel chunk in the `chats` collection.
685
- Catches missing-collection regressions (Issue 3) and embed/insert/
686
- search path bugs the shallow /health misses."""
687
- import time as _time, hashlib as _hashlib
688
- out = {"status": "ok", "ok": True}
689
- client = get_client()
690
-
691
- # 1. Collection presence
692
- expected = ("chats", "emails", "contacts", "memory")
693
- present = {n: client.has_collection(n) for n in expected}
694
- out["collections"] = present
695
- missing = [n for n, ok in present.items() if not ok]
696
- if missing:
697
- out["status"] = f"missing_collections:{','.join(missing)}"
698
- out["ok"] = False
699
- return out
700
-
701
- # 2. Synthetic embed → insert → search in `chats`
702
- sentinel_id = "__healthcheck__sentinel"
703
- sentinel_text = "healthcheck sentinel — L5 embed-write-search round-trip verifier"
704
- try:
705
- t0 = _time.time()
706
- embs = _embed_post([sentinel_text])
707
- out["embed_ms"] = round((_time.time() - t0) * 1000.0, 1)
708
- if not embs or embs[0] is None:
709
- out["status"] = "embed_failed"
710
- out["ok"] = False
711
- return out
712
-
713
- t1 = _time.time()
714
- from datetime import datetime as _dt, timezone as _tz
715
- client.upsert(collection_name="chats", data=[{
716
- "id": sentinel_id,
717
- "vector": embs[0],
718
- "text": sentinel_text,
719
- "source": "healthcheck",
720
- "channel": "__healthcheck__",
721
- "contact": "",
722
- "timestamp": _dt.now(_tz.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
723
- "arena": "__healthcheck__",
724
- }])
725
- out["write_ms"] = round((_time.time() - t1) * 1000.0, 1)
726
-
727
- t2 = _time.time()
728
- hits = client.search(
729
- collection_name="chats",
730
- data=[embs[0]],
731
- limit=1,
732
- filter='arena == "__healthcheck__"',
733
- output_fields=["id"],
734
- )
735
- out["search_ms"] = round((_time.time() - t2) * 1000.0, 1)
736
- found = bool(hits and hits[0] and hits[0][0].get("entity", {}).get("id") == sentinel_id)
737
- out["hit"] = found
738
- if not found:
739
- out["status"] = "sentinel_missing"
740
- out["ok"] = False
741
- except Exception as exc:
742
- out["status"] = f"probe_error: {type(exc).__name__}: {exc}"
743
- out["ok"] = False
744
- return out
745
-
746
- print(f"\n L5 Communications Layer — http://127.0.0.1:{port}")
747
- uvicorn.run(api, host=os.environ.get("HOST","127.0.0.1"), port=port, log_level="warning")
748
-
749
-
750
- # --- CLI ---
751
-
752
- def main():
753
- parser = argparse.ArgumentParser(description="L5 Communications Layer")
754
- parser.add_argument("command", choices=["index", "search", "health", "stats", "serve"])
755
- parser.add_argument("args", nargs="*")
756
- parser.add_argument("--collection", "-c", default=None)
757
- parser.add_argument("--limit", "-l", type=int, default=10)
758
- parser.add_argument("--port", "-p", type=int, default=8034)
759
- args = parser.parse_args()
760
-
761
- if args.command == "index":
762
- client = get_client()
763
- targets = args.args if args.args else ["chats", "emails", "contacts", "memory"]
764
- t0 = time.time()
765
- total = 0
766
- for target in targets:
767
- print(f"\nIndexing {target}...")
768
- if target == "chats":
769
- total += index_chats(client)
770
- elif target == "emails":
771
- total += index_emails(client)
772
- elif target == "contacts":
773
- total += index_contacts(client)
774
- elif target == "memory":
775
- total += index_memory(client)
776
- else:
777
- print(f" Unknown target: {target}")
778
- elapsed = time.time() - t0
779
- print(f"\nDone: {total:,} chunks indexed in {elapsed:.1f}s")
780
-
781
- elif args.command == "search":
782
- query = " ".join(args.args) if args.args else ""
783
- if not query:
784
- print("Usage: l5-comms-layer.py search 'your query'")
785
- return
786
- results = search(query, collection=args.collection, limit=args.limit)
787
- for i, r in enumerate(results, 1):
788
- print(f"\n--- [{i}] {r['collection']} (score: {r['score']}) ---")
789
- print(f"Source: {r['source']}")
790
- if r["contact"]:
791
- print(f"Contact: {r['contact']}")
792
- if r["timestamp"]:
793
- print(f"Time: {r['timestamp']}")
794
- print(r["text"][:300])
795
-
796
- elif args.command == "health":
797
- h = health()
798
- print(json.dumps(h, indent=2))
799
-
800
- elif args.command == "stats":
801
- stats()
802
-
803
- elif args.command == "serve":
804
- serve(port=args.port)
805
-
806
-
807
- if __name__ == "__main__":
808
- main()