synapse-recall 0.1.0__py3-none-any.whl

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 (47) hide show
  1. synapse/__init__.py +1 -0
  2. synapse/api/__init__.py +1 -0
  3. synapse/api/main.py +377 -0
  4. synapse/config.py +61 -0
  5. synapse/consolidation/__init__.py +1 -0
  6. synapse/consolidation/extractor.py +111 -0
  7. synapse/consolidation/llm_extractor.py +227 -0
  8. synapse/consolidation/models.py +62 -0
  9. synapse/consolidation/service.py +374 -0
  10. synapse/db/__init__.py +1 -0
  11. synapse/db/base.py +5 -0
  12. synapse/db/models.py +412 -0
  13. synapse/db/session.py +13 -0
  14. synapse/domain/__init__.py +1 -0
  15. synapse/domain/models.py +70 -0
  16. synapse/enrichment/__init__.py +1 -0
  17. synapse/enrichment/crustdata.py +105 -0
  18. synapse/enrichment/models.py +11 -0
  19. synapse/episodic/__init__.py +1 -0
  20. synapse/episodic/service.py +87 -0
  21. synapse/inference/__init__.py +1 -0
  22. synapse/inference/assertions.py +190 -0
  23. synapse/inference/chat.py +210 -0
  24. synapse/mcp/__init__.py +1 -0
  25. synapse/mcp/server.py +86 -0
  26. synapse/memory/__init__.py +1 -0
  27. synapse/memory/mem0_store.py +114 -0
  28. synapse/procedural/__init__.py +1 -0
  29. synapse/procedural/models.py +64 -0
  30. synapse/procedural/service.py +300 -0
  31. synapse/procedural/templates.py +87 -0
  32. synapse/recall/__init__.py +1 -0
  33. synapse/recall/answerability.py +98 -0
  34. synapse/recall/models.py +39 -0
  35. synapse/recall/service.py +268 -0
  36. synapse/semantic/__init__.py +1 -0
  37. synapse/semantic/repository.py +194 -0
  38. synapse/sources/__init__.py +1 -0
  39. synapse/sources/mock.py +631 -0
  40. synapse/sources/slack_export.py +357 -0
  41. synapse/sources/slack_import.py +105 -0
  42. synapse/worker.py +21 -0
  43. synapse_recall-0.1.0.dist-info/METADATA +539 -0
  44. synapse_recall-0.1.0.dist-info/RECORD +47 -0
  45. synapse_recall-0.1.0.dist-info/WHEEL +4 -0
  46. synapse_recall-0.1.0.dist-info/entry_points.txt +2 -0
  47. synapse_recall-0.1.0.dist-info/licenses/LICENSE +21 -0
synapse/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Synapse organizational memory POC."""
@@ -0,0 +1 @@
1
+ """HTTP API package."""
synapse/api/main.py ADDED
@@ -0,0 +1,377 @@
1
+ import asyncio
2
+ from collections.abc import AsyncIterator
3
+ from contextlib import asynccontextmanager
4
+ from typing import Annotated, Any
5
+
6
+ import httpx
7
+ import uvicorn
8
+ from fastapi import Depends, FastAPI, Query
9
+ from pydantic import BaseModel
10
+ from sqlalchemy import select, text
11
+ from sqlalchemy.ext.asyncio import AsyncSession
12
+
13
+ from synapse.config import get_settings
14
+ from synapse.consolidation.service import ConsolidationService
15
+ from synapse.db.models import GROUP_ID_LENGTH, ConsolidationRun, Episode
16
+ from synapse.db.session import engine, get_session
17
+ from synapse.episodic.service import SystemOneService
18
+ from synapse.inference.chat import LLMInterpreter
19
+ from synapse.memory.mem0_store import Mem0EpisodeStore
20
+ from synapse.procedural.models import (
21
+ DetectionResult,
22
+ PatternView,
23
+ SkillSpecView,
24
+ SkillStatusUpdate,
25
+ )
26
+ from synapse.procedural.service import ProceduralMemoryService, list_patterns, list_skills
27
+ from synapse.recall.models import RecallResult
28
+ from synapse.recall.service import RecallService
29
+ from synapse.semantic.repository import load_entities, load_relationships
30
+ from synapse.sources.mock import mock_events
31
+
32
+ GroupId = Annotated[
33
+ str,
34
+ Query(
35
+ min_length=1,
36
+ max_length=GROUP_ID_LENGTH,
37
+ description="Account the request reads from and writes to.",
38
+ ),
39
+ ]
40
+ """Required on every route that touches account data.
41
+
42
+ It has no default on purpose. A default would make the common case work without
43
+ naming an account, and the first time someone forgot to pass one their data
44
+ would quietly land in whichever account the default named.
45
+ """
46
+
47
+
48
+ class HealthResponse(BaseModel):
49
+ status: str
50
+ database: str
51
+ inference: str
52
+ models: dict[str, str]
53
+
54
+
55
+ class EpisodeResponse(BaseModel):
56
+ id: str
57
+ kind: str
58
+ source_type: str
59
+ content: str
60
+ summary: str | None
61
+ participants: list[str]
62
+ occurred_at: str
63
+ mem0_id: str | None
64
+
65
+
66
+ class ReplayResponse(BaseModel):
67
+ processed: int
68
+ skipped: int
69
+ indexed: int
70
+
71
+
72
+ class RecallRequest(BaseModel):
73
+ query: str
74
+
75
+
76
+ class ConsolidationRunResponse(BaseModel):
77
+ id: str
78
+ status: str
79
+ stage: str
80
+ provider: str
81
+ episode_count: int
82
+ entity_count: int
83
+ relationship_count: int
84
+ graph_diff: dict[str, Any]
85
+ error: str | None
86
+ created_at: str
87
+ started_at: str | None
88
+ completed_at: str | None
89
+
90
+
91
+ class GraphNodeResponse(BaseModel):
92
+ id: str
93
+ kind: str
94
+ name: str
95
+ aliases: list[str]
96
+ description: str | None
97
+ attributes: dict[str, Any]
98
+ confidence: float
99
+ external_attributes: list["ExternalAttributeResponse"]
100
+
101
+
102
+ class ExternalAttributeResponse(BaseModel):
103
+ provider: str
104
+ field_group: str
105
+ payload: dict[str, Any]
106
+ source_url: str | None
107
+ confidence: float
108
+ observed_at: str
109
+
110
+
111
+ class GraphEvidenceResponse(BaseModel):
112
+ episode_id: str
113
+ source_type: str
114
+ occurred_at: str
115
+ excerpt: str
116
+ confidence: float
117
+
118
+
119
+ class GraphEdgeResponse(BaseModel):
120
+ id: str
121
+ source: str
122
+ target: str
123
+ predicate: str
124
+ attributes: dict[str, Any]
125
+ confidence: float
126
+ valid_at: str
127
+ invalid_at: str | None
128
+ evidence: list[GraphEvidenceResponse]
129
+
130
+
131
+ class GraphResponse(BaseModel):
132
+ nodes: list[GraphNodeResponse]
133
+ edges: list[GraphEdgeResponse]
134
+
135
+
136
+ @asynccontextmanager
137
+ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
138
+ yield
139
+ await engine.dispose()
140
+
141
+
142
+ app = FastAPI(title="Synapse API", version="0.1.0", lifespan=lifespan)
143
+
144
+
145
+ async def inference_status() -> str:
146
+ settings = get_settings()
147
+ try:
148
+ async with httpx.AsyncClient(timeout=1.5) as client:
149
+ responses = await asyncio.gather(
150
+ client.get(f"{settings.chat_base_url.rstrip('/')}/models"),
151
+ client.get(f"{settings.embedding_base_url.rstrip('/')}/models"),
152
+ )
153
+ for response in responses:
154
+ response.raise_for_status()
155
+ except (httpx.HTTPError, OSError):
156
+ return "unavailable"
157
+ return "available"
158
+
159
+
160
+ @app.get("/api/health", response_model=HealthResponse)
161
+ async def health(session: Annotated[AsyncSession, Depends(get_session)]) -> HealthResponse:
162
+ await session.execute(text("SELECT 1"))
163
+ settings = get_settings()
164
+ inference = await inference_status()
165
+ return HealthResponse(
166
+ status="ok" if inference == "available" else "degraded",
167
+ database="available",
168
+ inference=inference,
169
+ models={
170
+ "chat": settings.chat_model,
171
+ "embedding": settings.embedding_model,
172
+ },
173
+ )
174
+
175
+
176
+ @app.get("/api/episodes", response_model=list[EpisodeResponse])
177
+ async def list_episodes(
178
+ group_id: GroupId,
179
+ session: Annotated[AsyncSession, Depends(get_session)],
180
+ ) -> list[EpisodeResponse]:
181
+ result = await session.scalars(
182
+ select(Episode)
183
+ .where(Episode.group_id == group_id)
184
+ .order_by(Episode.occurred_at.desc())
185
+ .limit(100)
186
+ )
187
+ return [
188
+ EpisodeResponse(
189
+ id=str(episode.id),
190
+ kind=episode.kind.value,
191
+ source_type=episode.source_type.value,
192
+ content=episode.content,
193
+ summary=episode.summary,
194
+ participants=episode.participants,
195
+ occurred_at=episode.occurred_at.isoformat(),
196
+ mem0_id=episode.mem0_id,
197
+ )
198
+ for episode in result
199
+ ]
200
+
201
+
202
+ @app.get("/api/config")
203
+ async def public_config() -> dict[str, Any]:
204
+ settings = get_settings()
205
+ return {
206
+ "memory_hit_threshold": settings.memory_hit_threshold,
207
+ "embedding_dimensions": settings.embedding_dimensions,
208
+ "chat_model": settings.chat_model,
209
+ "embedding_model": settings.embedding_model,
210
+ }
211
+
212
+
213
+ @app.post("/api/demo/replay", response_model=ReplayResponse)
214
+ async def replay_demo(
215
+ group_id: GroupId,
216
+ session: Annotated[AsyncSession, Depends(get_session)],
217
+ ) -> ReplayResponse:
218
+ service = SystemOneService(LLMInterpreter(), Mem0EpisodeStore())
219
+ processed = 0
220
+ skipped = 0
221
+ indexed = 0
222
+ for event in mock_events():
223
+ episode, created = await service.process(event, group_id, session)
224
+ if created:
225
+ processed += 1
226
+ else:
227
+ skipped += 1
228
+ indexed += int(episode.mem0_id is not None)
229
+ return ReplayResponse(processed=processed, skipped=skipped, indexed=indexed)
230
+
231
+
232
+ @app.post("/api/recall", response_model=RecallResult)
233
+ async def recall(
234
+ request: RecallRequest,
235
+ group_id: GroupId,
236
+ session: Annotated[AsyncSession, Depends(get_session)],
237
+ ) -> RecallResult:
238
+ return await RecallService().recall(request.query, group_id, session)
239
+
240
+
241
+ @app.post("/api/procedural/detect", response_model=DetectionResult)
242
+ async def detect_procedural_patterns(
243
+ group_id: GroupId,
244
+ session: Annotated[AsyncSession, Depends(get_session)],
245
+ ) -> DetectionResult:
246
+ return await ProceduralMemoryService().detect(group_id, session)
247
+
248
+
249
+ @app.get("/api/procedural/patterns", response_model=list[PatternView])
250
+ async def get_procedural_patterns(
251
+ group_id: GroupId,
252
+ session: Annotated[AsyncSession, Depends(get_session)],
253
+ ) -> list[PatternView]:
254
+ return await list_patterns(group_id, session)
255
+
256
+
257
+ @app.get("/api/procedural/skills", response_model=list[SkillSpecView])
258
+ async def get_procedural_skills(
259
+ group_id: GroupId,
260
+ session: Annotated[AsyncSession, Depends(get_session)],
261
+ ) -> list[SkillSpecView]:
262
+ return await list_skills(group_id, session)
263
+
264
+
265
+ @app.post("/api/procedural/skills/{skill_id}/status", response_model=SkillSpecView)
266
+ async def update_procedural_skill_status(
267
+ skill_id: str,
268
+ update: SkillStatusUpdate,
269
+ group_id: GroupId,
270
+ session: Annotated[AsyncSession, Depends(get_session)],
271
+ ) -> SkillSpecView:
272
+ return await ProceduralMemoryService().set_status(
273
+ skill_id, update.status, group_id, session
274
+ )
275
+
276
+
277
+ def consolidation_response(run: ConsolidationRun) -> ConsolidationRunResponse:
278
+ return ConsolidationRunResponse(
279
+ id=str(run.id),
280
+ status=run.status,
281
+ stage=run.stage,
282
+ provider=run.provider,
283
+ episode_count=run.episode_count,
284
+ entity_count=run.entity_count,
285
+ relationship_count=run.relationship_count,
286
+ graph_diff=run.graph_diff,
287
+ error=run.error,
288
+ created_at=run.created_at.isoformat(),
289
+ started_at=run.started_at.isoformat() if run.started_at else None,
290
+ completed_at=run.completed_at.isoformat() if run.completed_at else None,
291
+ )
292
+
293
+
294
+ @app.post("/api/system2/consolidate", response_model=ConsolidationRunResponse)
295
+ async def consolidate(
296
+ group_id: GroupId,
297
+ session: Annotated[AsyncSession, Depends(get_session)],
298
+ ) -> ConsolidationRunResponse:
299
+ run = await ConsolidationService().run(group_id, session)
300
+ return consolidation_response(run)
301
+
302
+
303
+ @app.get("/api/system2/runs", response_model=list[ConsolidationRunResponse])
304
+ async def list_consolidation_runs(
305
+ group_id: GroupId,
306
+ session: Annotated[AsyncSession, Depends(get_session)],
307
+ ) -> list[ConsolidationRunResponse]:
308
+ runs = await session.scalars(
309
+ select(ConsolidationRun)
310
+ .where(ConsolidationRun.group_id == group_id)
311
+ .order_by(ConsolidationRun.created_at.desc())
312
+ .limit(25)
313
+ )
314
+ return [consolidation_response(run) for run in runs]
315
+
316
+
317
+ @app.get("/api/system2/graph", response_model=GraphResponse)
318
+ async def get_graph(
319
+ group_id: GroupId,
320
+ session: Annotated[AsyncSession, Depends(get_session)],
321
+ ) -> GraphResponse:
322
+ entities = await load_entities(group_id, session)
323
+ relationships = await load_relationships(group_id, session)
324
+ return GraphResponse(
325
+ nodes=[
326
+ GraphNodeResponse(
327
+ id=str(entity.id),
328
+ kind=entity.kind,
329
+ name=entity.canonical_name,
330
+ aliases=[alias.alias for alias in entity.aliases],
331
+ description=entity.description,
332
+ attributes=entity.attributes,
333
+ confidence=entity.confidence,
334
+ external_attributes=[
335
+ ExternalAttributeResponse(
336
+ provider=external.provider,
337
+ field_group=external.field_group,
338
+ payload=external.payload,
339
+ source_url=external.source_url,
340
+ confidence=external.confidence,
341
+ observed_at=external.observed_at.isoformat(),
342
+ )
343
+ for external in entity.external_attributes
344
+ ],
345
+ )
346
+ for entity in entities
347
+ ],
348
+ edges=[
349
+ GraphEdgeResponse(
350
+ id=str(relationship.id),
351
+ source=str(relationship.subject_id),
352
+ target=str(relationship.object_id),
353
+ predicate=relationship.predicate,
354
+ attributes=relationship.attributes,
355
+ confidence=relationship.confidence,
356
+ valid_at=relationship.valid_at.isoformat(),
357
+ invalid_at=(
358
+ relationship.invalid_at.isoformat() if relationship.invalid_at else None
359
+ ),
360
+ evidence=[
361
+ GraphEvidenceResponse(
362
+ episode_id=str(evidence.episode_id),
363
+ source_type=evidence.episode.source_type.value,
364
+ occurred_at=evidence.episode.occurred_at.isoformat(),
365
+ excerpt=evidence.excerpt,
366
+ confidence=evidence.confidence,
367
+ )
368
+ for evidence in relationship.evidence
369
+ ],
370
+ )
371
+ for relationship in relationships
372
+ ],
373
+ )
374
+
375
+
376
+ def run() -> None:
377
+ uvicorn.run("synapse.api.main:app", host="0.0.0.0", port=8000)
synapse/config.py ADDED
@@ -0,0 +1,61 @@
1
+ """Configuration, including which model server answers each kind of call.
2
+
3
+ Inference is addressed as two OpenAI-compatible endpoints — one for chat, one
4
+ for embeddings — rather than as a named provider. The defaults point at a local
5
+ Cactus serving Gemma, which is how the project is meant to be run, but nothing
6
+ below the settings knows that: pointing the URLs at a hosted gateway and
7
+ supplying its key is the whole of what switching providers involves.
8
+
9
+ Chat and embeddings are configured separately because they are not always the
10
+ same service. A gateway that serves chat may serve no embedding model at all,
11
+ leaving embeddings to a second deployment with its own URL, key, and dimension.
12
+ """
13
+
14
+ from functools import lru_cache
15
+
16
+ from pydantic import Field
17
+ from pydantic_settings import BaseSettings, SettingsConfigDict
18
+
19
+
20
+ class Settings(BaseSettings):
21
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")
22
+
23
+ app_name: str = "Synapse"
24
+ environment: str = "development"
25
+ database_url: str = "postgresql+asyncpg://synapse:synapse@localhost:5432/synapse"
26
+ chat_base_url: str = "http://localhost:8080/v1"
27
+ embedding_base_url: str = "http://localhost:8080/v1"
28
+ chat_model: str = "google/gemma-4-E2B-it"
29
+ embedding_model: str = "google/gemma-4-E2B-it"
30
+ # A local Cactus ignores the bearer token, so the default is a placeholder
31
+ # rather than a secret. A hosted endpoint checks it.
32
+ chat_api_key: str = "cactus-local"
33
+ embedding_api_key: str = "cactus-local"
34
+ embedding_dimensions: int = 1536
35
+ # Extra completion budget for models that think before answering. Every
36
+ # call site sizes its own budget for the answer it expects, which is all a
37
+ # model like Gemma spends tokens on. A reasoning model bills its private
38
+ # reasoning against the same budget and can exhaust it before writing a
39
+ # single character, so those budgets need to be raised — by a lot, and
40
+ # without changing what each call site asks for. Set this to a few thousand
41
+ # when pointing at one; it costs nothing on a model that doesn't reason,
42
+ # which stops when it is done rather than when it hits the cap.
43
+ chat_reasoning_tokens: int = Field(default=0, ge=0)
44
+ inference_mode: str = "auto"
45
+ consolidation_mode: str = "auto"
46
+ crustdata_api_key: str | None = None
47
+ crustdata_base_url: str = "https://api.crustdata.com"
48
+ crustdata_api_version: str = "2025-11-01"
49
+ enrichment_cache_hours: int = Field(default=24, ge=1)
50
+ procedural_min_occurrences: int = Field(default=2, ge=2)
51
+ memory_hit_threshold: float = Field(default=0.62, ge=0, le=1)
52
+ mem0_collection: str = "synapse_episodes_gemma_1536"
53
+
54
+ @property
55
+ def mem0_database_url(self) -> str:
56
+ return self.database_url.replace("postgresql+asyncpg://", "postgresql://", 1)
57
+
58
+
59
+ @lru_cache
60
+ def get_settings() -> Settings:
61
+ return Settings()
@@ -0,0 +1 @@
1
+ """System 2 semantic consolidation."""
@@ -0,0 +1,111 @@
1
+ from synapse.consolidation.models import ConsolidationPlan, EntitySpec, RelationshipSpec
2
+ from synapse.db.models import Episode
3
+ from synapse.domain.models import semantic_assertions
4
+
5
+
6
+ def normalize_name(value: str) -> str:
7
+ return " ".join(value.casefold().split())
8
+
9
+
10
+ def entity_key(kind: str, name: str) -> str:
11
+ return f"{kind}:{normalize_name(name)}"
12
+
13
+
14
+ def derive_semantic_plan(episodes: list[Episode]) -> ConsolidationPlan:
15
+ entities: dict[str, EntitySpec] = {}
16
+ relationships: dict[tuple[str, str, str], RelationshipSpec] = {}
17
+
18
+ def add_entity(
19
+ kind: str,
20
+ name: str,
21
+ *,
22
+ aliases: list[str] | None = None,
23
+ description: str | None = None,
24
+ attributes: dict[str, str] | None = None,
25
+ confidence: float = 1.0,
26
+ ) -> str:
27
+ key = entity_key(kind, name)
28
+ current = entities.get(key)
29
+ if current is None:
30
+ entities[key] = EntitySpec(
31
+ key=key,
32
+ kind=kind,
33
+ canonical_name=name,
34
+ aliases=aliases or [],
35
+ description=description,
36
+ attributes=attributes or {},
37
+ confidence=confidence,
38
+ )
39
+ else:
40
+ current.aliases = sorted(set(current.aliases) | set(aliases or []))
41
+ current.attributes.update(attributes or {})
42
+ current.description = current.description or description
43
+ current.confidence = max(current.confidence, confidence)
44
+ return key
45
+
46
+ def add_relationship(
47
+ subject_key: str,
48
+ predicate: str,
49
+ object_key: str,
50
+ source_id: str | None,
51
+ *,
52
+ attributes: dict[str, str] | None = None,
53
+ confidence: float = 1.0,
54
+ ) -> None:
55
+ identity = (subject_key, predicate, object_key)
56
+ current = relationships.get(identity)
57
+ if current is None:
58
+ relationships[identity] = RelationshipSpec(
59
+ subject_key=subject_key,
60
+ predicate=predicate,
61
+ object_key=object_key,
62
+ evidence_source_ids=[source_id] if source_id else [],
63
+ attributes=attributes or {},
64
+ confidence=confidence,
65
+ )
66
+ return
67
+ if source_id and source_id not in current.evidence_source_ids:
68
+ current.evidence_source_ids.append(source_id)
69
+ current.attributes.update(attributes or {})
70
+ current.confidence = max(current.confidence, confidence)
71
+
72
+ for episode in episodes:
73
+ for participant in episode.participants:
74
+ add_entity("Person", participant)
75
+
76
+ assertions = semantic_assertions(episode.details)
77
+ for entity_assertion in assertions.entities:
78
+ add_entity(
79
+ entity_assertion.kind,
80
+ entity_assertion.name,
81
+ aliases=entity_assertion.aliases,
82
+ description=entity_assertion.description,
83
+ attributes=entity_assertion.attributes,
84
+ confidence=entity_assertion.confidence,
85
+ )
86
+
87
+ for relationship_assertion in assertions.relationships:
88
+ subject_key = add_entity(
89
+ relationship_assertion.subject_kind,
90
+ relationship_assertion.subject_name,
91
+ )
92
+ object_key = add_entity(
93
+ relationship_assertion.object_kind,
94
+ relationship_assertion.object_name,
95
+ )
96
+ add_relationship(
97
+ subject_key,
98
+ relationship_assertion.predicate,
99
+ object_key,
100
+ episode.source_id,
101
+ attributes=relationship_assertion.attributes,
102
+ confidence=relationship_assertion.confidence,
103
+ )
104
+
105
+ return ConsolidationPlan(
106
+ entities=sorted(entities.values(), key=lambda item: item.key),
107
+ relationships=sorted(
108
+ relationships.values(),
109
+ key=lambda item: (item.subject_key, item.predicate, item.object_key),
110
+ ),
111
+ )