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