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