@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.
- package/README.md +170 -69
- package/bin/__tests__/callback-server.test.js +4 -1
- package/bin/cli.js +41 -164
- package/bin/commands/config.js +251 -0
- package/package.json +2 -1
- package/packages/doctor/__tests__/detect.test.js +2 -6
- package/packages/doctor/src/checks/local-memory.js +164 -196
- package/packages/doctor/src/detect.js +11 -3
- package/packages/memory/src/corpus/adapters.js +104 -0
- package/packages/memory/src/corpus/cli.js +72 -7
- package/packages/memory/src/corpus/index.js +1 -1
- package/packages/memory-engine/.env.example +13 -0
- package/packages/memory-engine/README.md +131 -0
- package/packages/memory-engine/bench/README.md +99 -0
- package/packages/memory-engine/bench/scorecards-engine/agent-coding__pentatonic-baseline__20260427-142523.json +1115 -0
- package/packages/memory-engine/bench/scorecards-engine/chat-recall__pentatonic-baseline__20260427-142648.json +819 -0
- package/packages/memory-engine/bench/scorecards-engine/circular-economy__pentatonic-baseline__20260427-142757.json +1278 -0
- package/packages/memory-engine/bench/scorecards-engine/customer-support__pentatonic-baseline__20260427-142900.json +1018 -0
- package/packages/memory-engine/bench/scorecards-engine/marketplace-ops__pentatonic-baseline__20260427-142957.json +1038 -0
- package/packages/memory-engine/bench/scorecards-engine/product-catalogue__pentatonic-baseline__20260427-143122.json +961 -0
- package/packages/memory-engine/bench/scorecards-engine-via-docker/agent-coding__pentatonic-memory__20260427-161812.json +1115 -0
- package/packages/memory-engine/bench/scorecards-engine-via-docker/chat-recall__pentatonic-memory__20260427-161701.json +819 -0
- package/packages/memory-engine/bench/scorecards-engine-via-docker/circular-economy__pentatonic-memory__20260427-161713.json +1278 -0
- package/packages/memory-engine/bench/scorecards-engine-via-docker/customer-support__pentatonic-memory__20260427-161723.json +1018 -0
- package/packages/memory-engine/bench/scorecards-engine-via-docker/marketplace-ops__pentatonic-memory__20260427-161732.json +1038 -0
- package/packages/memory-engine/bench/scorecards-engine-via-docker/product-catalogue__pentatonic-memory__20260427-161741.json +937 -0
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/agent-coding__pentatonic-memory__20260427-184718.json +1115 -0
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/chat-recall__pentatonic-memory__20260427-184614.json +819 -0
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/circular-economy__pentatonic-memory__20260427-184809.json +1278 -0
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/customer-support__pentatonic-memory__20260427-184854.json +1018 -0
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/marketplace-ops__pentatonic-memory__20260427-184929.json +1038 -0
- package/packages/memory-engine/bench/scorecards-engine-via-l2-7-layer-populated/product-catalogue__pentatonic-memory__20260427-185015.json +961 -0
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/agent-coding__pentatonic-memory__20260427-175252.json +1115 -0
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/chat-recall__pentatonic-memory__20260427-175312.json +819 -0
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/circular-economy__pentatonic-memory__20260427-175335.json +1278 -0
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/customer-support__pentatonic-memory__20260427-175355.json +1018 -0
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/marketplace-ops__pentatonic-memory__20260427-175413.json +1038 -0
- package/packages/memory-engine/bench/scorecards-engine-via-l2-empty-layers/product-catalogue__pentatonic-memory__20260427-175430.json +883 -0
- package/packages/memory-engine/bench/scorecards-engine-via-shim/agent-coding__pentatonic-memory__20260427-155409.json +1115 -0
- package/packages/memory-engine/bench/scorecards-engine-via-shim/chat-recall__pentatonic-memory__20260427-155421.json +819 -0
- package/packages/memory-engine/bench/scorecards-engine-via-shim/circular-economy__pentatonic-memory__20260427-155433.json +1278 -0
- package/packages/memory-engine/bench/scorecards-engine-via-shim/customer-support__pentatonic-memory__20260427-155443.json +1018 -0
- package/packages/memory-engine/bench/scorecards-engine-via-shim/marketplace-ops__pentatonic-memory__20260427-155453.json +1038 -0
- package/packages/memory-engine/bench/scorecards-engine-via-shim/product-catalogue__pentatonic-memory__20260427-155503.json +937 -0
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory-latest__20260427-145103.json +1115 -0
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/agent-coding__pentatonic-memory__20260427-144909.json +1115 -0
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory-latest__20260427-145153.json +819 -0
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/chat-recall__pentatonic-memory__20260427-145120.json +542 -0
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory-latest__20260427-145313.json +1278 -0
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/circular-economy__pentatonic-memory__20260427-145207.json +894 -0
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory-latest__20260427-145412.json +1018 -0
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/customer-support__pentatonic-memory__20260427-145327.json +680 -0
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory-latest__20260427-145517.json +1038 -0
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/marketplace-ops__pentatonic-memory__20260427-145422.json +693 -0
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory-latest__20260427-145616.json +961 -0
- package/packages/memory-engine/bench/scorecards-pentatonic-baseline/product-catalogue__pentatonic-memory__20260427-145528.json +727 -0
- package/packages/memory-engine/compat/Dockerfile +11 -0
- package/packages/memory-engine/compat/server.py +680 -0
- package/packages/memory-engine/docker-compose.yml +243 -0
- package/packages/memory-engine/docs/MIGRATION.md +178 -0
- package/packages/memory-engine/docs/RUNBOOK-AWS.md +375 -0
- package/packages/memory-engine/docs/why-v05-underperforms.md +138 -0
- package/packages/memory-engine/engine/README.md +52 -0
- package/packages/memory-engine/engine/l2-hybridrag-proxy.py +1543 -0
- package/packages/memory-engine/engine/l5-comms-layer.py +663 -0
- package/packages/memory-engine/engine/l6-document-store.py +1018 -0
- package/packages/memory-engine/engine/services/l2/Dockerfile +41 -0
- package/packages/memory-engine/engine/services/l2/init_databases.py +81 -0
- package/packages/memory-engine/engine/services/l2/l2-hybridrag-proxy.py +1543 -0
- package/packages/memory-engine/engine/services/l4/Dockerfile +15 -0
- package/packages/memory-engine/engine/services/l4/server.py +235 -0
- package/packages/memory-engine/engine/services/l5/Dockerfile +9 -0
- package/packages/memory-engine/engine/services/l5/l5-comms-layer.py +678 -0
- package/packages/memory-engine/engine/services/l6/Dockerfile +11 -0
- package/packages/memory-engine/engine/services/l6/l6-document-store.py +1016 -0
- package/packages/memory-engine/engine/services/nv-embed/Dockerfile +28 -0
- package/packages/memory-engine/engine/services/nv-embed/server.py +152 -0
- package/packages/memory-engine/pme_memory/__init__.py +0 -0
- package/packages/memory-engine/pme_memory/__main__.py +129 -0
- package/packages/memory-engine/pme_memory/artifacts.py +95 -0
- package/packages/memory-engine/pme_memory/embed.py +74 -0
- package/packages/memory-engine/pme_memory/health.py +36 -0
- package/packages/memory-engine/pme_memory/hygiene.py +159 -0
- package/packages/memory-engine/pme_memory/indexer.py +200 -0
- package/packages/memory-engine/pme_memory/needs.py +55 -0
- package/packages/memory-engine/pme_memory/provenance.py +80 -0
- package/packages/memory-engine/pme_memory/scoring.py +168 -0
- package/packages/memory-engine/pme_memory/search.py +52 -0
- package/packages/memory-engine/pme_memory/store.py +86 -0
- package/packages/memory-engine/pme_memory/synthesis.py +114 -0
- package/packages/memory-engine/pyproject.toml +65 -0
- package/packages/memory-engine/scripts/kg-extractor.py +557 -0
- package/packages/memory-engine/scripts/kg-preflexor-v2.py +738 -0
- 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"]
|