@pentatonic-ai/ai-agent-sdk 0.6.0 → 0.7.1

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 (92) hide show
  1. package/README.md +178 -69
  2. package/bin/__tests__/callback-server.test.js +4 -1
  3. package/bin/cli.js +41 -164
  4. package/bin/commands/config.js +251 -0
  5. package/bin/commands/login.js +10 -3
  6. package/package.json +2 -1
  7. package/packages/doctor/__tests__/detect.test.js +2 -6
  8. package/packages/doctor/src/checks/local-memory.js +164 -196
  9. package/packages/doctor/src/detect.js +11 -3
  10. package/packages/memory/src/corpus/adapters.js +104 -0
  11. package/packages/memory/src/corpus/cli.js +72 -7
  12. package/packages/memory/src/corpus/index.js +1 -1
  13. package/packages/memory-engine/.env.example +13 -0
  14. package/packages/memory-engine/README.md +131 -0
  15. package/packages/memory-engine/bench/README.md +99 -0
  16. package/packages/memory-engine/bench/scorecards-engine/agent-coding__pentatonic-baseline__20260427-142523.json +1115 -0
  17. package/packages/memory-engine/bench/scorecards-engine/chat-recall__pentatonic-baseline__20260427-142648.json +819 -0
  18. package/packages/memory-engine/bench/scorecards-engine/circular-economy__pentatonic-baseline__20260427-142757.json +1278 -0
  19. package/packages/memory-engine/bench/scorecards-engine/customer-support__pentatonic-baseline__20260427-142900.json +1018 -0
  20. package/packages/memory-engine/bench/scorecards-engine/marketplace-ops__pentatonic-baseline__20260427-142957.json +1038 -0
  21. package/packages/memory-engine/bench/scorecards-engine/product-catalogue__pentatonic-baseline__20260427-143122.json +961 -0
  22. package/packages/memory-engine/bench/scorecards-engine-via-docker/agent-coding__pentatonic-memory__20260427-161812.json +1115 -0
  23. package/packages/memory-engine/bench/scorecards-engine-via-docker/chat-recall__pentatonic-memory__20260427-161701.json +819 -0
  24. package/packages/memory-engine/bench/scorecards-engine-via-docker/circular-economy__pentatonic-memory__20260427-161713.json +1278 -0
  25. package/packages/memory-engine/bench/scorecards-engine-via-docker/customer-support__pentatonic-memory__20260427-161723.json +1018 -0
  26. package/packages/memory-engine/bench/scorecards-engine-via-docker/marketplace-ops__pentatonic-memory__20260427-161732.json +1038 -0
  27. package/packages/memory-engine/bench/scorecards-engine-via-docker/product-catalogue__pentatonic-memory__20260427-161741.json +937 -0
  28. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/agent-coding__pentatonic-memory__20260427-184718.json +1115 -0
  29. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/chat-recall__pentatonic-memory__20260427-184614.json +819 -0
  30. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/circular-economy__pentatonic-memory__20260427-184809.json +1278 -0
  31. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/customer-support__pentatonic-memory__20260427-184854.json +1018 -0
  32. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/marketplace-ops__pentatonic-memory__20260427-184929.json +1038 -0
  33. package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/product-catalogue__pentatonic-memory__20260427-185015.json +961 -0
  34. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/agent-coding__pentatonic-memory__20260427-175252.json +1115 -0
  35. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/chat-recall__pentatonic-memory__20260427-175312.json +819 -0
  36. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/circular-economy__pentatonic-memory__20260427-175335.json +1278 -0
  37. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/customer-support__pentatonic-memory__20260427-175355.json +1018 -0
  38. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/marketplace-ops__pentatonic-memory__20260427-175413.json +1038 -0
  39. package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/product-catalogue__pentatonic-memory__20260427-175430.json +883 -0
  40. package/packages/memory-engine/bench/scorecards-engine-via-shim/agent-coding__pentatonic-memory__20260427-155409.json +1115 -0
  41. package/packages/memory-engine/bench/scorecards-engine-via-shim/chat-recall__pentatonic-memory__20260427-155421.json +819 -0
  42. package/packages/memory-engine/bench/scorecards-engine-via-shim/circular-economy__pentatonic-memory__20260427-155433.json +1278 -0
  43. package/packages/memory-engine/bench/scorecards-engine-via-shim/customer-support__pentatonic-memory__20260427-155443.json +1018 -0
  44. package/packages/memory-engine/bench/scorecards-engine-via-shim/marketplace-ops__pentatonic-memory__20260427-155453.json +1038 -0
  45. package/packages/memory-engine/bench/scorecards-engine-via-shim/product-catalogue__pentatonic-memory__20260427-155503.json +937 -0
  46. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory-latest__20260427-145103.json +1115 -0
  47. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory__20260427-144909.json +1115 -0
  48. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory-latest__20260427-145153.json +819 -0
  49. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory__20260427-145120.json +542 -0
  50. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory-latest__20260427-145313.json +1278 -0
  51. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory__20260427-145207.json +894 -0
  52. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory-latest__20260427-145412.json +1018 -0
  53. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory__20260427-145327.json +680 -0
  54. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory-latest__20260427-145517.json +1038 -0
  55. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory__20260427-145422.json +693 -0
  56. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory-latest__20260427-145616.json +961 -0
  57. package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory__20260427-145528.json +727 -0
  58. package/packages/memory-engine/compat/Dockerfile +11 -0
  59. package/packages/memory-engine/compat/server.py +680 -0
  60. package/packages/memory-engine/docker-compose.yml +243 -0
  61. package/packages/memory-engine/engine/README.md +52 -0
  62. package/packages/memory-engine/engine/l2-hybridrag-proxy.py +1543 -0
  63. package/packages/memory-engine/engine/l5-comms-layer.py +663 -0
  64. package/packages/memory-engine/engine/l6-document-store.py +1018 -0
  65. package/packages/memory-engine/engine/services/l2/Dockerfile +41 -0
  66. package/packages/memory-engine/engine/services/l2/init_databases.py +81 -0
  67. package/packages/memory-engine/engine/services/l2/l2-hybridrag-proxy.py +1543 -0
  68. package/packages/memory-engine/engine/services/l4/Dockerfile +15 -0
  69. package/packages/memory-engine/engine/services/l4/server.py +265 -0
  70. package/packages/memory-engine/engine/services/l5/Dockerfile +9 -0
  71. package/packages/memory-engine/engine/services/l5/l5-comms-layer.py +696 -0
  72. package/packages/memory-engine/engine/services/l6/Dockerfile +11 -0
  73. package/packages/memory-engine/engine/services/l6/l6-document-store.py +1035 -0
  74. package/packages/memory-engine/engine/services/nv-embed/Dockerfile +28 -0
  75. package/packages/memory-engine/engine/services/nv-embed/server.py +152 -0
  76. package/packages/memory-engine/pme_memory/__init__.py +0 -0
  77. package/packages/memory-engine/pme_memory/__main__.py +129 -0
  78. package/packages/memory-engine/pme_memory/artifacts.py +95 -0
  79. package/packages/memory-engine/pme_memory/embed.py +74 -0
  80. package/packages/memory-engine/pme_memory/health.py +36 -0
  81. package/packages/memory-engine/pme_memory/hygiene.py +159 -0
  82. package/packages/memory-engine/pme_memory/indexer.py +200 -0
  83. package/packages/memory-engine/pme_memory/needs.py +55 -0
  84. package/packages/memory-engine/pme_memory/provenance.py +80 -0
  85. package/packages/memory-engine/pme_memory/scoring.py +168 -0
  86. package/packages/memory-engine/pme_memory/search.py +52 -0
  87. package/packages/memory-engine/pme_memory/store.py +86 -0
  88. package/packages/memory-engine/pme_memory/synthesis.py +114 -0
  89. package/packages/memory-engine/pyproject.toml +65 -0
  90. package/packages/memory-engine/scripts/kg-extractor.py +557 -0
  91. package/packages/memory-engine/scripts/kg-preflexor-v2.py +738 -0
  92. package/packages/memory-engine/tests/test_api_contract.sh +57 -0
@@ -0,0 +1,696 @@
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 time
27
+ from datetime import datetime
28
+ from pathlib import Path
29
+
30
+ import httpx
31
+ from pymilvus import MilvusClient, DataType, CollectionSchema, FieldSchema
32
+
33
+ # --- Config ---
34
+ DB_PATH = os.environ.get(
35
+ "L5_DB_PATH",
36
+ str(Path.home() / "memory-l5" / "comms.db"),
37
+ )
38
+ WORKSPACE = Path(os.environ.get("PME_WORKSPACE", ".pentatonic"))
39
+ CLAWD_CHATS_DIR = Path.home() / "clawd" / "chats" # Legacy archive
40
+ CHATS_DIR = WORKSPACE / "chats"
41
+ EMAILS_DIR = WORKSPACE / "memory" / "chats" / "email"
42
+ PEOPLE_DIR = WORKSPACE / "memory" / "people"
43
+ CONTACTS_DIR = WORKSPACE / "memory" / "contacts"
44
+ MEMORY_DIR = WORKSPACE / "memory"
45
+
46
+ NV_EMBED_URL = os.environ.get("L5_NV_EMBED_URL", "http://localhost:8041/v1/embeddings")
47
+ # Embedding model name sent in /v1/embeddings request body. Defaults to
48
+ # the production NV-Embed-v2 name; override when pointing at a different
49
+ # OpenAI-compat endpoint (e.g. Ollama with nomic-embed-text).
50
+ EMBED_MODEL_NAME = os.environ.get("L5_EMBED_MODEL", "nv-embed-v2")
51
+ # Optional Authorization: Bearer <key> for the primary embedding endpoint.
52
+ EMBED_API_KEY = os.environ.get("L5_EMBED_API_KEY", "")
53
+
54
+ def _embed_post(texts):
55
+ """POST to the configured embedding endpoint. Tries OpenAI-compat
56
+ shape first; falls back to Pentatonic-AI lambda-gateway native shape
57
+ on any failure. When the gateway adds an /v1/embeddings alias the
58
+ primary path will succeed and the fallback never fires.
59
+
60
+ Returns: list[list[float]] (one embedding per input text).
61
+ """
62
+ payload = {"input": texts, "model": EMBED_MODEL_NAME}
63
+ try:
64
+ r = httpx.post(
65
+ NV_EMBED_URL,
66
+ headers={"Authorization": f"Bearer {EMBED_API_KEY}"} if EMBED_API_KEY else {},
67
+ json=payload,
68
+ timeout=120,
69
+ )
70
+ r.raise_for_status()
71
+ return [d["embedding"] for d in r.json()["data"]]
72
+ except Exception:
73
+ pass
74
+ fallback_url = NV_EMBED_URL.replace("/v1/embeddings", "/v1/embed").replace("/embeddings", "/embed")
75
+ r = httpx.post(
76
+ fallback_url,
77
+ headers={"X-API-Key": EMBED_API_KEY} if EMBED_API_KEY else {},
78
+ json=payload,
79
+ timeout=120,
80
+ )
81
+ r.raise_for_status()
82
+ return r.json()["embeddings"]
83
+
84
+ # Ollama fallback path. URL/model can be overridden so the L5 container can
85
+ # reach an Ollama instance running on the docker host (host.docker.internal)
86
+ # or on a co-located service. Mirrors the env-var pattern used by L2.
87
+ OLLAMA_EMBED_URL = os.environ.get(
88
+ "L5_OLLAMA_EMBED_URL", "http://localhost:11434/api/embed"
89
+ )
90
+ OLLAMA_EMBED_MODEL = os.environ.get("L5_OLLAMA_EMBED_MODEL", "nomic-embed-text")
91
+ # Vector dim. Default matches NV-Embed-v2; override for smaller-dim models
92
+ # (e.g. 768 for nomic-embed-text, 1024 for mxbai-embed-large). Milvus
93
+ # collections are created at this dim; existing data won't survive a dim
94
+ # change — wipe the L5 volume to switch.
95
+ EMBED_DIM = int(os.environ.get("L5_EMBED_DIM", "4096"))
96
+ # Dim of the Ollama-fallback model. If equal to EMBED_DIM, the fallback
97
+ # returns vectors as-is; if smaller, they're zero-padded to EMBED_DIM.
98
+ OLLAMA_DIM = int(os.environ.get("L5_OLLAMA_DIM", "768"))
99
+ CHUNK_SIZE = 512 # chars per chunk
100
+ CHUNK_OVERLAP = 64
101
+ BATCH_SIZE = 100 # embeddings per batch
102
+
103
+
104
+ def get_client():
105
+ return MilvusClient(uri=DB_PATH)
106
+
107
+
108
+ def embed_texts(texts: list[str]) -> list[list[float]]:
109
+ """Get embeddings — NV-Embed-v2 batch primary, Ollama fallback."""
110
+ # Try batch NV-Embed first
111
+ batch_result = _embed_nv_batch(texts)
112
+ if batch_result is not None:
113
+ return batch_result
114
+ # Fallback: one at a time
115
+ results = []
116
+ for text in texts:
117
+ emb = _embed_nv_single(text)
118
+ if emb is None:
119
+ emb = _embed_ollama(text)
120
+ results.append(emb if emb else [0.0] * EMBED_DIM)
121
+ return results
122
+
123
+
124
+ def _embed_nv_batch(texts: list[str]) -> list[list[float]] | None:
125
+ """Batch embed via NV-Embed-v2 (4096-dim). Returns None on failure."""
126
+ if not texts:
127
+ return []
128
+ try:
129
+ truncated = [t[:4000] for t in texts]
130
+ embeddings = _embed_post(truncated)
131
+ if all(len(e) == EMBED_DIM for e in embeddings):
132
+ return embeddings
133
+ except Exception:
134
+ logging.debug(f"Suppressed error in l5-comms-layer.py")
135
+ return None
136
+
137
+
138
+ def _embed_nv_single(text: str) -> list[float] | None:
139
+ """Embed single text via NV-Embed-v2 (4096-dim)."""
140
+ try:
141
+ embs = _embed_post([text[:4000]])
142
+ emb = embs[0]
143
+ if len(emb) == EMBED_DIM:
144
+ return emb
145
+ except Exception:
146
+ logging.debug(f"Suppressed error in l5-comms-layer.py")
147
+ return None
148
+
149
+
150
+ def _embed_ollama(text: str) -> list[float] | None:
151
+ """Fallback: Ollama nomic-embed (768-dim), zero-padded to EMBED_DIM."""
152
+ try:
153
+ r = httpx.post(OLLAMA_EMBED_URL, json={"model": OLLAMA_EMBED_MODEL, "input": text}, timeout=30)
154
+ r.raise_for_status()
155
+ data = r.json()
156
+ emb = data.get("embeddings", [data.get("embedding", [])])[0]
157
+ if isinstance(emb, list) and len(emb) == OLLAMA_DIM:
158
+ # Zero-pad to 4096 for Milvus compatibility
159
+ return emb + [0.0] * (EMBED_DIM - OLLAMA_DIM)
160
+ except Exception as e:
161
+ print(f" Embed error: {e}")
162
+ return None
163
+
164
+
165
+ def chunk_text(text: str, chunk_size=CHUNK_SIZE, overlap=CHUNK_OVERLAP) -> list[str]:
166
+ """Split text into overlapping chunks."""
167
+ if len(text) <= chunk_size:
168
+ return [text] if text.strip() else []
169
+ chunks = []
170
+ start = 0
171
+ while start < len(text):
172
+ end = start + chunk_size
173
+ chunk = text[start:end].strip()
174
+ if chunk:
175
+ chunks.append(chunk)
176
+ start = end - overlap
177
+ return chunks
178
+
179
+
180
+ def text_id(text: str, source: str) -> str:
181
+ return hashlib.md5(f"{source}:{text[:200]}".encode()).hexdigest()
182
+
183
+
184
+ def ensure_collection(client, name: str):
185
+ """Create collection if not exists."""
186
+ if client.has_collection(name):
187
+ return
188
+ schema = client.create_schema(auto_id=False, enable_dynamic_field=True)
189
+ schema.add_field("id", DataType.VARCHAR, is_primary=True, max_length=64)
190
+ schema.add_field("vector", DataType.FLOAT_VECTOR, dim=EMBED_DIM)
191
+ schema.add_field("text", DataType.VARCHAR, max_length=8192)
192
+ schema.add_field("source", DataType.VARCHAR, max_length=512)
193
+ schema.add_field("channel", DataType.VARCHAR, max_length=64)
194
+ schema.add_field("contact", DataType.VARCHAR, max_length=256)
195
+ schema.add_field("timestamp", DataType.VARCHAR, max_length=32)
196
+
197
+ index_params = client.prepare_index_params()
198
+ index_params.add_index(field_name="vector", index_type="FLAT", metric_type="COSINE")
199
+ client.create_collection(collection_name=name, schema=schema, index_params=index_params)
200
+ print(f" Created collection: {name}")
201
+
202
+
203
+ # --- Indexers ---
204
+
205
+ def index_chats(client):
206
+ """Index JSONL chat transcripts."""
207
+ ensure_collection(client, "chats")
208
+ total = 0
209
+
210
+ # Walk all JSONL files under chats/
211
+ jsonl_files = list(CHATS_DIR.rglob("*.jsonl"))
212
+ # Also grab .txt chat exports
213
+ txt_files = list(CHATS_DIR.rglob("*.txt"))
214
+
215
+ print(f" Found {len(jsonl_files)} JSONL + {len(txt_files)} TXT chat files")
216
+
217
+ for f in jsonl_files:
218
+ try:
219
+ lines = f.read_text(errors="replace").strip().split("\n")
220
+ batch_data = []
221
+
222
+ for line in lines:
223
+ try:
224
+ msg = json.loads(line)
225
+ except json.JSONDecodeError:
226
+ continue
227
+
228
+ text = msg.get("text", "")
229
+ if not text or len(text) < 10:
230
+ continue
231
+
232
+ channel = msg.get("channel", "unknown")
233
+ contact = msg.get("contact", msg.get("sender", ""))
234
+ ts = msg.get("timestamp", "")
235
+ source = str(f.relative_to(WORKSPACE))
236
+
237
+ for chunk in chunk_text(text):
238
+ doc_id = text_id(chunk, source)
239
+ batch_data.append({
240
+ "id": doc_id,
241
+ "text": chunk[:8000],
242
+ "source": source[:500],
243
+ "channel": channel[:60],
244
+ "contact": str(contact)[:250],
245
+ "timestamp": str(ts)[:30],
246
+ })
247
+
248
+ if len(batch_data) >= BATCH_SIZE:
249
+ vectors = embed_texts([d["text"] for d in batch_data])
250
+ for d, v in zip(batch_data, vectors):
251
+ d["vector"] = v
252
+ client.upsert(collection_name="chats", data=batch_data)
253
+ total += len(batch_data)
254
+ batch_data = []
255
+
256
+ # Flush remaining
257
+ if batch_data:
258
+ vectors = embed_texts([d["text"] for d in batch_data])
259
+ for d, v in zip(batch_data, vectors):
260
+ d["vector"] = v
261
+ client.upsert(collection_name="chats", data=batch_data)
262
+ total += len(batch_data)
263
+
264
+ except Exception as e:
265
+ print(f" Error indexing {f}: {e}")
266
+
267
+ # Index markdown chat summaries
268
+ for channel_dir in ["telegram", "whatsapp", "imessage", "slack", "unknown"]:
269
+ chat_md_dir = WORKSPACE / "memory" / "chats" / channel_dir
270
+ if not chat_md_dir.exists():
271
+ continue
272
+ for f in chat_md_dir.glob("*.md"):
273
+ try:
274
+ text = f.read_text(errors="replace")
275
+ if len(text) < 20:
276
+ continue
277
+ source = str(f.relative_to(WORKSPACE))
278
+ batch_data = []
279
+ for chunk in chunk_text(text):
280
+ doc_id = text_id(chunk, source)
281
+ batch_data.append({
282
+ "id": doc_id,
283
+ "text": chunk[:8000],
284
+ "source": source[:500],
285
+ "channel": channel_dir,
286
+ "contact": f.stem[:250],
287
+ "timestamp": "",
288
+ })
289
+ if batch_data:
290
+ vectors = embed_texts([d["text"] for d in batch_data])
291
+ for d, v in zip(batch_data, vectors):
292
+ d["vector"] = v
293
+ client.upsert(collection_name="chats", data=batch_data)
294
+ total += len(batch_data)
295
+ except Exception as e:
296
+ print(f" Error indexing {f}: {e}")
297
+
298
+ print(f" Indexed {total} chat chunks")
299
+ return total
300
+
301
+
302
+ def index_emails(client):
303
+ """Index email archives."""
304
+ ensure_collection(client, "emails")
305
+ total = 0
306
+
307
+ if not EMAILS_DIR.exists():
308
+ print(" No email directory found")
309
+ return 0
310
+
311
+ for f in EMAILS_DIR.glob("*.md"):
312
+ try:
313
+ text = f.read_text(errors="replace")
314
+ if len(text) < 20:
315
+ continue
316
+ source = str(f.relative_to(WORKSPACE))
317
+ # Extract contact from filename
318
+ contact = f.stem.replace("", "").replace("_", " ")[:250]
319
+ batch_data = []
320
+ for chunk in chunk_text(text):
321
+ doc_id = text_id(chunk, source)
322
+ batch_data.append({
323
+ "id": doc_id,
324
+ "text": chunk[:8000],
325
+ "source": source[:500],
326
+ "channel": "email",
327
+ "contact": contact,
328
+ "timestamp": "",
329
+ })
330
+ if batch_data:
331
+ vectors = embed_texts([d["text"] for d in batch_data])
332
+ for d, v in zip(batch_data, vectors):
333
+ d["vector"] = v
334
+ client.upsert(collection_name="emails", data=batch_data)
335
+ total += len(batch_data)
336
+ except Exception as e:
337
+ print(f" Error indexing {f}: {e}")
338
+
339
+ print(f" Indexed {total} email chunks")
340
+ return total
341
+
342
+
343
+ def index_contacts(client):
344
+ """Index people profiles and contacts."""
345
+ ensure_collection(client, "contacts")
346
+ total = 0
347
+
348
+ # People profiles
349
+ if PEOPLE_DIR.exists():
350
+ for f in PEOPLE_DIR.glob("*.md"):
351
+ try:
352
+ text = f.read_text(errors="replace")
353
+ if len(text) < 20:
354
+ continue
355
+ source = str(f.relative_to(WORKSPACE))
356
+ batch_data = []
357
+ for chunk in chunk_text(text):
358
+ doc_id = text_id(chunk, source)
359
+ batch_data.append({
360
+ "id": doc_id,
361
+ "text": chunk[:8000],
362
+ "source": source[:500],
363
+ "channel": "profile",
364
+ "contact": f.stem[:250],
365
+ "timestamp": "",
366
+ })
367
+ if batch_data:
368
+ vectors = embed_texts([d["text"] for d in batch_data])
369
+ for d, v in zip(batch_data, vectors):
370
+ d["vector"] = v
371
+ client.upsert(collection_name="contacts", data=batch_data)
372
+ total += len(batch_data)
373
+ except Exception as e:
374
+ print(f" Error: {e}")
375
+
376
+ # Contact files
377
+ if CONTACTS_DIR.exists():
378
+ for f in CONTACTS_DIR.glob("*"):
379
+ if not f.is_file():
380
+ continue
381
+ try:
382
+ text = f.read_text(errors="replace")
383
+ if len(text) < 20:
384
+ continue
385
+ source = str(f.relative_to(WORKSPACE))
386
+ batch_data = []
387
+ for chunk in chunk_text(text, chunk_size=1024):
388
+ doc_id = text_id(chunk, source)
389
+ batch_data.append({
390
+ "id": doc_id,
391
+ "text": chunk[:8000],
392
+ "source": source[:500],
393
+ "channel": "contacts",
394
+ "contact": "",
395
+ "timestamp": "",
396
+ })
397
+ if batch_data:
398
+ vectors = embed_texts([d["text"] for d in batch_data])
399
+ for d, v in zip(batch_data, vectors):
400
+ d["vector"] = v
401
+ client.upsert(collection_name="contacts", data=batch_data)
402
+ total += len(batch_data)
403
+ except Exception as e:
404
+ print(f" Error: {e}")
405
+
406
+ print(f" Indexed {total} contact chunks")
407
+ return total
408
+
409
+
410
+ def index_memory(client):
411
+ """Index memory markdown files (daily notes, projects, research, rules)."""
412
+ ensure_collection(client, "memory")
413
+ total = 0
414
+
415
+ # Skip chats/ (handled separately) and evolution run logs (too many, low value)
416
+ skip_patterns = ["chats/", "evolution/loop-run-", "evolution/v3/runs/"]
417
+
418
+ for f in MEMORY_DIR.rglob("*.md"):
419
+ source = str(f.relative_to(WORKSPACE))
420
+ if any(p in source for p in skip_patterns):
421
+ continue
422
+ try:
423
+ text = f.read_text(errors="replace")
424
+ if len(text) < 30:
425
+ continue
426
+ batch_data = []
427
+ for chunk in chunk_text(text):
428
+ doc_id = text_id(chunk, source)
429
+ batch_data.append({
430
+ "id": doc_id,
431
+ "text": chunk[:8000],
432
+ "source": source[:500],
433
+ "channel": "memory",
434
+ "contact": "",
435
+ "timestamp": "",
436
+ })
437
+ if batch_data:
438
+ vectors = embed_texts([d["text"] for d in batch_data])
439
+ for d, v in zip(batch_data, vectors):
440
+ d["vector"] = v
441
+ client.upsert(collection_name="memory", data=batch_data)
442
+ total += len(batch_data)
443
+ except Exception as e:
444
+ print(f" Error: {e}")
445
+
446
+ print(f" Indexed {total} memory chunks")
447
+ return total
448
+
449
+
450
+ # --- Search ---
451
+
452
+ def search(query: str, collection: str = None, limit: int = 10):
453
+ """Search across collections."""
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
+ for coll in collections:
464
+ if not client.has_collection(coll):
465
+ continue
466
+ try:
467
+ results = client.search(
468
+ collection_name=coll,
469
+ data=[vectors[0]],
470
+ limit=limit,
471
+ output_fields=["text", "source", "channel", "contact", "timestamp"],
472
+ )
473
+ for hits in results:
474
+ for hit in hits:
475
+ entity = hit.get("entity", {})
476
+ all_results.append({
477
+ "collection": coll,
478
+ "score": round(hit.get("distance", 0), 4),
479
+ "text": entity.get("text", ""),
480
+ "source": entity.get("source", ""),
481
+ "channel": entity.get("channel", ""),
482
+ "contact": entity.get("contact", ""),
483
+ "timestamp": entity.get("timestamp", ""),
484
+ })
485
+ except Exception as e:
486
+ print(f" Search error in {coll}: {e}")
487
+
488
+ all_results.sort(key=lambda x: x["score"], reverse=True)
489
+ return all_results[:limit]
490
+
491
+
492
+ # --- Health / Stats ---
493
+
494
+ def health():
495
+ """Check L5 health."""
496
+ try:
497
+ client = get_client()
498
+ collections = ["chats", "emails", "contacts", "memory"]
499
+ status = {"status": "ok", "db_path": DB_PATH, "collections": {}}
500
+ for coll in collections:
501
+ if client.has_collection(coll):
502
+ stats = client.get_collection_stats(coll)
503
+ count = stats.get("row_count", 0)
504
+ status["collections"][coll] = {"exists": True, "count": count}
505
+ else:
506
+ status["collections"][coll] = {"exists": False, "count": 0}
507
+ total = sum(c["count"] for c in status["collections"].values())
508
+ status["total_chunks"] = total
509
+ # Check embeddings
510
+ try:
511
+ r = httpx.get("http://localhost:11434/api/tags", timeout=3)
512
+ models = [m["name"] for m in r.json().get("models", [])]
513
+ status["embeddings"] = EMBED_MODEL in str(models)
514
+ except Exception:
515
+ status["embeddings"] = False
516
+ return status
517
+ except Exception as e:
518
+ return {"status": "error", "error": str(e)}
519
+
520
+
521
+ def stats():
522
+ """Print collection stats."""
523
+ h = health()
524
+ print(f"\nL5 Communications Layer — {h.get('status', 'unknown')}")
525
+ print(f"DB: {h.get('db_path', '?')}")
526
+ print(f"Embeddings: {'OK' if h.get('embeddings') else 'UNAVAILABLE'}")
527
+ print(f"\nCollections:")
528
+ for name, info in h.get("collections", {}).items():
529
+ if info["exists"]:
530
+ print(f" {name}: {info['count']:,} chunks")
531
+ else:
532
+ print(f" {name}: not created")
533
+ print(f"\nTotal: {h.get('total_chunks', 0):,} chunks")
534
+
535
+
536
+ # --- HTTP Server ---
537
+
538
+ def serve(port=8034):
539
+ """Run as HTTP API server."""
540
+ from fastapi import FastAPI, Query
541
+ import uvicorn
542
+
543
+ api = FastAPI(title="L5 Communications Layer")
544
+
545
+ @api.get("/health")
546
+ def api_health():
547
+ return health()
548
+
549
+ @api.get("/search")
550
+ def api_search(q: str = Query(...), collection: str = None, limit: int = 10):
551
+ results = search(q, collection=collection, limit=limit)
552
+ return {"query": q, "results": results, "count": len(results)}
553
+
554
+ @api.get("/stats")
555
+ def api_stats():
556
+ return health()
557
+
558
+ @api.post("/index-batch")
559
+ def api_index_batch(req: dict):
560
+ """Index a batch of pre-formed records using a single batched
561
+ NV-Embed call + a single milvus insert. Roughly 30-50x faster
562
+ than calling /index for each item or running the legacy
563
+ per-chunk indexers, which is critical for tests, smoke runs and
564
+ bench harnesses where a few dozen docs need to land quickly.
565
+
566
+ Request body::
567
+
568
+ {
569
+ "collection": "chats", # one of: chats|emails|contacts|memory
570
+ "records": [
571
+ {
572
+ "id": "opt-stable-id", # optional, auto-generated if absent
573
+ "text": "…", # required
574
+ "source": "…", # optional
575
+ "channel": "…", # optional
576
+ "contact": "…" # optional
577
+ }, …
578
+ ]
579
+ }
580
+
581
+ Returns::
582
+
583
+ {"status": "ok", "collection": "chats", "inserted": N,
584
+ "embed_ms": float, "insert_ms": float}
585
+ """
586
+ import time as _time, hashlib as _hashlib
587
+ collection = req.get("collection", "chats")
588
+ records = req.get("records") or []
589
+ if not records:
590
+ return {"status": "ok", "inserted": 0, "collection": collection}
591
+
592
+ client = get_client()
593
+ ensure_collection(client, collection)
594
+
595
+ # Single batched embed call.
596
+ texts = [(r.get("text") or "")[:8192] for r in records]
597
+ t0 = _time.time()
598
+ try:
599
+ embs = _embed_post(texts)
600
+ except Exception as exc:
601
+ return {"status": "error", "error": f"embed failed: {exc}"}
602
+ embed_ms = (_time.time() - t0) * 1000.0
603
+
604
+ # Single batched insert. Mirror every field the chats collection
605
+ # schema requires (id/vector/text/source/channel/contact/timestamp).
606
+ from datetime import datetime as _dt, timezone as _tz
607
+ _now = _dt.now(_tz.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
608
+ rows = []
609
+ for r, emb, txt in zip(records, embs, texts):
610
+ if emb is None:
611
+ continue
612
+ rid = r.get("id") or _hashlib.sha1(txt.encode("utf-8")).hexdigest()[:32]
613
+ rows.append({
614
+ "id": rid[:63],
615
+ "vector": emb,
616
+ "text": txt,
617
+ "source": (r.get("source") or "")[:512],
618
+ "channel": (r.get("channel") or "")[:64],
619
+ "contact": (r.get("contact") or "")[:256],
620
+ "timestamp": (r.get("timestamp") or _now)[:32],
621
+ })
622
+ t1 = _time.time()
623
+ if rows:
624
+ client.insert(collection_name=collection, data=rows)
625
+ insert_ms = (_time.time() - t1) * 1000.0
626
+ return {
627
+ "status": "ok",
628
+ "collection": collection,
629
+ "inserted": len(rows),
630
+ "embed_ms": round(embed_ms, 1),
631
+ "insert_ms": round(insert_ms, 1),
632
+ }
633
+
634
+ print(f"\n L5 Communications Layer — http://127.0.0.1:{port}")
635
+ uvicorn.run(api, host=os.environ.get("HOST","127.0.0.1"), port=port, log_level="warning")
636
+
637
+
638
+ # --- CLI ---
639
+
640
+ def main():
641
+ parser = argparse.ArgumentParser(description="L5 Communications Layer")
642
+ parser.add_argument("command", choices=["index", "search", "health", "stats", "serve"])
643
+ parser.add_argument("args", nargs="*")
644
+ parser.add_argument("--collection", "-c", default=None)
645
+ parser.add_argument("--limit", "-l", type=int, default=10)
646
+ parser.add_argument("--port", "-p", type=int, default=8034)
647
+ args = parser.parse_args()
648
+
649
+ if args.command == "index":
650
+ client = get_client()
651
+ targets = args.args if args.args else ["chats", "emails", "contacts", "memory"]
652
+ t0 = time.time()
653
+ total = 0
654
+ for target in targets:
655
+ print(f"\nIndexing {target}...")
656
+ if target == "chats":
657
+ total += index_chats(client)
658
+ elif target == "emails":
659
+ total += index_emails(client)
660
+ elif target == "contacts":
661
+ total += index_contacts(client)
662
+ elif target == "memory":
663
+ total += index_memory(client)
664
+ else:
665
+ print(f" Unknown target: {target}")
666
+ elapsed = time.time() - t0
667
+ print(f"\nDone: {total:,} chunks indexed in {elapsed:.1f}s")
668
+
669
+ elif args.command == "search":
670
+ query = " ".join(args.args) if args.args else ""
671
+ if not query:
672
+ print("Usage: l5-comms-layer.py search 'your query'")
673
+ return
674
+ results = search(query, collection=args.collection, limit=args.limit)
675
+ for i, r in enumerate(results, 1):
676
+ print(f"\n--- [{i}] {r['collection']} (score: {r['score']}) ---")
677
+ print(f"Source: {r['source']}")
678
+ if r["contact"]:
679
+ print(f"Contact: {r['contact']}")
680
+ if r["timestamp"]:
681
+ print(f"Time: {r['timestamp']}")
682
+ print(r["text"][:300])
683
+
684
+ elif args.command == "health":
685
+ h = health()
686
+ print(json.dumps(h, indent=2))
687
+
688
+ elif args.command == "stats":
689
+ stats()
690
+
691
+ elif args.command == "serve":
692
+ serve(port=args.port)
693
+
694
+
695
+ if __name__ == "__main__":
696
+ main()