ltcai 3.5.0 → 4.0.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 (181) hide show
  1. package/README.md +73 -35
  2. package/docs/CARRYOVER_AUDIT_v3.6.0.md +61 -0
  3. package/docs/CHANGELOG.md +32 -0
  4. package/docs/HANDOVER_v3.6.0.md +46 -0
  5. package/docs/RUNTIME_HOOK_COVERAGE_v3.6.0.md +49 -0
  6. package/docs/V4_BRAIN_ARCHITECTURE.md +322 -0
  7. package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +509 -0
  8. package/docs/V4_IMPLEMENTATION_PLAN.md +470 -0
  9. package/docs/architecture.md +13 -12
  10. package/docs/kg-schema.md +102 -53
  11. package/docs/privacy.md +18 -2
  12. package/docs/security-model.md +17 -0
  13. package/kg_schema.py +139 -10
  14. package/knowledge_graph.py +874 -26
  15. package/knowledge_graph_api.py +11 -127
  16. package/latticeai/__init__.py +1 -1
  17. package/latticeai/api/admin.py +1 -1
  18. package/latticeai/api/agents.py +7 -1
  19. package/latticeai/api/auth.py +27 -4
  20. package/latticeai/api/browser.py +217 -0
  21. package/latticeai/api/chat.py +112 -76
  22. package/latticeai/api/health.py +1 -1
  23. package/latticeai/api/hooks.py +1 -1
  24. package/latticeai/api/knowledge_graph.py +146 -0
  25. package/latticeai/api/local_files.py +1 -1
  26. package/latticeai/api/mcp.py +23 -11
  27. package/latticeai/api/memory.py +1 -1
  28. package/latticeai/api/models.py +1 -1
  29. package/latticeai/api/network.py +81 -0
  30. package/latticeai/api/portability.py +93 -0
  31. package/latticeai/api/realtime.py +1 -1
  32. package/latticeai/api/search.py +26 -2
  33. package/latticeai/api/security_dashboard.py +2 -3
  34. package/latticeai/api/setup.py +2 -2
  35. package/latticeai/api/static_routes.py +2 -4
  36. package/latticeai/api/tools.py +3 -0
  37. package/latticeai/api/workflow_designer.py +46 -0
  38. package/latticeai/api/workspace.py +71 -49
  39. package/latticeai/app_factory.py +1710 -0
  40. package/latticeai/brain/__init__.py +18 -0
  41. package/latticeai/brain/context.py +213 -0
  42. package/latticeai/brain/conversations.py +236 -0
  43. package/latticeai/brain/identity.py +175 -0
  44. package/latticeai/brain/memory.py +102 -0
  45. package/latticeai/brain/network.py +205 -0
  46. package/latticeai/core/agent.py +31 -7
  47. package/latticeai/core/audit.py +0 -7
  48. package/latticeai/core/config.py +1 -1
  49. package/latticeai/core/context_builder.py +1 -2
  50. package/latticeai/core/enterprise.py +1 -1
  51. package/latticeai/core/graph_curator.py +2 -2
  52. package/latticeai/core/marketplace.py +1 -1
  53. package/latticeai/core/mcp_registry.py +791 -0
  54. package/latticeai/core/model_compat.py +1 -1
  55. package/latticeai/core/model_resolution.py +0 -1
  56. package/latticeai/core/multi_agent.py +238 -4
  57. package/latticeai/core/security.py +1 -1
  58. package/latticeai/core/sessions.py +37 -7
  59. package/latticeai/core/workflow_engine.py +114 -2
  60. package/latticeai/core/workspace_os.py +58 -10
  61. package/latticeai/models/__init__.py +7 -0
  62. package/latticeai/models/router.py +779 -0
  63. package/latticeai/server_app.py +29 -1504
  64. package/latticeai/services/agent_runtime.py +1 -0
  65. package/latticeai/services/app_context.py +75 -14
  66. package/latticeai/services/ingestion.py +318 -0
  67. package/latticeai/services/kg_portability.py +207 -0
  68. package/latticeai/services/memory_service.py +39 -11
  69. package/latticeai/services/model_runtime.py +2 -5
  70. package/latticeai/services/platform_runtime.py +100 -23
  71. package/latticeai/services/search_service.py +17 -8
  72. package/latticeai/services/tool_dispatch.py +12 -2
  73. package/latticeai/services/triggers.py +241 -0
  74. package/latticeai/services/upload_service.py +37 -12
  75. package/latticeai/services/workspace_service.py +31 -0
  76. package/llm_router.py +29 -772
  77. package/ltcai_cli.py +1 -2
  78. package/mcp_registry.py +25 -788
  79. package/p_reinforce.py +124 -14
  80. package/package.json +11 -8
  81. package/scripts/build_vsix.mjs +72 -0
  82. package/scripts/bump_version.py +99 -0
  83. package/scripts/generate_diagrams.py +0 -1
  84. package/scripts/lint_v3.mjs +82 -18
  85. package/scripts/validate_release_artifacts.py +0 -1
  86. package/scripts/wheel_smoke.py +142 -0
  87. package/server.py +11 -7
  88. package/setup_wizard.py +1142 -0
  89. package/static/account.html +2 -4
  90. package/static/admin.html +3 -5
  91. package/static/chat.html +3 -6
  92. package/static/graph.html +2 -4
  93. package/static/sw.js +81 -52
  94. package/static/v3/asset-manifest.json +20 -19
  95. package/static/v3/css/{lattice.base.e4cdd05d.css → lattice.base.49deefb5.css} +1 -1
  96. package/static/v3/css/lattice.base.css +1 -1
  97. package/static/v3/css/{lattice.components.9b49d614.css → lattice.components.cde18231.css} +1 -1
  98. package/static/v3/css/lattice.components.css +1 -1
  99. package/static/v3/css/{lattice.shell.8fcc9d33.css → lattice.shell.29d36d85.css} +1 -1
  100. package/static/v3/css/lattice.shell.css +1 -1
  101. package/static/v3/css/{lattice.tokens.e7018963.css → lattice.tokens.304cbc40.css} +3 -0
  102. package/static/v3/css/lattice.tokens.css +3 -0
  103. package/static/v3/css/{lattice.views.22f69117.css → lattice.views.0a18b6c5.css} +2 -2
  104. package/static/v3/css/lattice.views.css +2 -2
  105. package/static/v3/index.html +3 -4
  106. package/static/v3/js/{app.d086489d.js → app.356e6452.js} +1 -1
  107. package/static/v3/js/core/{api.12b568ad.js → api.7a308b89.js} +39 -1
  108. package/static/v3/js/core/api.js +38 -0
  109. package/static/v3/js/core/{routes.d214b399.js → routes.7222343d.js} +22 -22
  110. package/static/v3/js/core/routes.js +22 -22
  111. package/static/v3/js/core/{shell.d05266f5.js → shell.a1657f20.js} +4 -4
  112. package/static/v3/js/core/shell.js +1 -1
  113. package/static/v3/js/core/{store.34ebd5e6.js → store.204a08b2.js} +1 -1
  114. package/static/v3/js/core/store.js +1 -1
  115. package/static/v3/js/views/graph-canvas.17c15d65.js +509 -0
  116. package/static/v3/js/views/graph-canvas.js +509 -0
  117. package/static/v3/js/views/{hybrid-search.b22b97e0.js → hybrid-search.2fb63ed9.js} +1 -2
  118. package/static/v3/js/views/hybrid-search.js +1 -2
  119. package/static/v3/js/views/knowledge-graph.5e40cbeb.js +509 -0
  120. package/static/v3/js/views/knowledge-graph.js +326 -54
  121. package/static/vendor/chart.umd.min.js +20 -0
  122. package/static/vendor/fonts/inter-latin-300-normal.woff2 +0 -0
  123. package/static/vendor/fonts/inter-latin-400-normal.woff2 +0 -0
  124. package/static/vendor/fonts/inter-latin-500-normal.woff2 +0 -0
  125. package/static/vendor/fonts/inter-latin-600-normal.woff2 +0 -0
  126. package/static/vendor/fonts/inter-latin-700-normal.woff2 +0 -0
  127. package/static/vendor/fonts/inter-latin-800-normal.woff2 +0 -0
  128. package/static/vendor/fonts/inter.css +44 -0
  129. package/static/vendor/icons/tabler-icons.min.css +4 -0
  130. package/static/vendor/icons/tabler-icons.woff2 +0 -0
  131. package/static/vendor/marked.min.js +69 -0
  132. package/static/workspace.html +2 -2
  133. package/telegram_bot.py +1 -2
  134. package/tools/commands.py +4 -2
  135. package/tools/computer.py +1 -1
  136. package/tools/documents.py +1 -3
  137. package/tools/filesystem.py +0 -4
  138. package/tools/knowledge.py +1 -3
  139. package/tools/network.py +1 -3
  140. package/codex_telegram_bot.py +0 -195
  141. package/docs/assets/v3.4.0/agent-run.png +0 -0
  142. package/docs/assets/v3.4.0/agents.png +0 -0
  143. package/docs/assets/v3.4.0/before/chat-before.png +0 -0
  144. package/docs/assets/v3.4.0/before/files-before.png +0 -0
  145. package/docs/assets/v3.4.0/chat.png +0 -0
  146. package/docs/assets/v3.4.0/connect-folder.png +0 -0
  147. package/docs/assets/v3.4.0/files.png +0 -0
  148. package/docs/assets/v3.4.0/home.png +0 -0
  149. package/docs/assets/v3.4.0/hooks-dispatch.png +0 -0
  150. package/docs/assets/v3.4.0/knowledge-graph.png +0 -0
  151. package/docs/assets/v3.4.0/local-agent.png +0 -0
  152. package/docs/assets/v3.4.0/memory.png +0 -0
  153. package/docs/assets/v3.4.0/settings.png +0 -0
  154. package/docs/assets/v3.4.0/vision-input.png +0 -0
  155. package/docs/assets/v3.4.0/workflows.png +0 -0
  156. package/docs/assets/v3.4.1/e2e_runtime_log.txt +0 -42
  157. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  158. package/docs/assets/v3.4.1/local-agent.png +0 -0
  159. package/docs/images/admin-dashboard.png +0 -0
  160. package/docs/images/architecture.png +0 -0
  161. package/docs/images/enterprise.png +0 -0
  162. package/docs/images/graph.png +0 -0
  163. package/docs/images/hero.gif +0 -0
  164. package/docs/images/knowledge-graph.png +0 -0
  165. package/docs/images/lattice-ai-demo.gif +0 -0
  166. package/docs/images/lattice-ai-hero.png +0 -0
  167. package/docs/images/logo.svg +0 -33
  168. package/docs/images/mobile-responsive.png +0 -0
  169. package/docs/images/model-recommendation.png +0 -0
  170. package/docs/images/onboarding.png +0 -0
  171. package/docs/images/organization.png +0 -0
  172. package/docs/images/pipeline.png +0 -0
  173. package/docs/images/screenshot-admin.png +0 -0
  174. package/docs/images/screenshot-chat.png +0 -0
  175. package/docs/images/screenshot-graph.png +0 -0
  176. package/docs/images/skills.png +0 -0
  177. package/docs/images/workspace-dark.png +0 -0
  178. package/docs/images/workspace-light.png +0 -0
  179. package/docs/images/workspace.png +0 -0
  180. package/requirements.txt +0 -16
  181. package/static/v3/js/views/knowledge-graph.a14ea7e7.js +0 -237
@@ -0,0 +1,18 @@
1
+ """latticeai.brain — the durable substrate of the Digital Brain.
2
+
3
+ v4 home for the brain's storage modules. The knowledge-graph store itself
4
+ still lives in the root ``knowledge_graph`` module pending its decomposition
5
+ (T3d); new brain components land here first.
6
+ """
7
+
8
+ from latticeai.brain.context import AssembledContext, ContextAssembler, ContextSection
9
+ from latticeai.brain.conversations import ConversationStore
10
+ from latticeai.brain.memory import BrainMemory
11
+
12
+ __all__ = [
13
+ "AssembledContext",
14
+ "BrainMemory",
15
+ "ContextAssembler",
16
+ "ContextSection",
17
+ "ConversationStore",
18
+ ]
@@ -0,0 +1,213 @@
1
+ """Context System — one budgeted, provenance-carrying assembly pipeline.
2
+
3
+ Replaces the ad-hoc string concatenation that built chat context (language
4
+ hint + vault scan + KG LIKE search + recent chat appended in fixed order
5
+ with no size control). Every section the assembler emits records WHY it is
6
+ in the prompt (source, ids, scores), so "why is this in my context?" is
7
+ answerable, and the whole assembly respects a token budget.
8
+
9
+ Token counts are an explicit approximation: ``approx_tokens = ceil(len/4)``
10
+ (the stack ships no model-agnostic tokenizer; the field name says so —
11
+ design-review amendment T5).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, Callable, Dict, List, Optional
19
+
20
+
21
+ def approx_tokens(text: str) -> int:
22
+ """Documented chars/4 approximation — NOT a real tokenizer count."""
23
+ return max(0, (len(text or "") + 3) // 4)
24
+
25
+
26
+ @dataclass
27
+ class ContextSection:
28
+ name: str
29
+ content: str
30
+ source: str # memory | knowledge | notes | recent_chat | system
31
+ provenance: List[Dict[str, Any]] = field(default_factory=list)
32
+ truncated: bool = False
33
+
34
+ @property
35
+ def approx_tokens(self) -> int:
36
+ return approx_tokens(self.content)
37
+
38
+ def as_trace(self) -> Dict[str, Any]:
39
+ return {
40
+ "name": self.name,
41
+ "source": self.source,
42
+ "approx_tokens": self.approx_tokens,
43
+ "truncated": self.truncated,
44
+ "provenance": self.provenance,
45
+ }
46
+
47
+
48
+ @dataclass
49
+ class AssembledContext:
50
+ sections: List[ContextSection]
51
+ budget_approx_tokens: int
52
+
53
+ @property
54
+ def text(self) -> str:
55
+ parts = []
56
+ for section in self.sections:
57
+ if section.content.strip():
58
+ parts.append(f"[{section.name}]\n{section.content.strip()}")
59
+ return "\n\n".join(parts)
60
+
61
+ @property
62
+ def approx_tokens(self) -> int:
63
+ return sum(s.approx_tokens for s in self.sections)
64
+
65
+ def trace(self) -> Dict[str, Any]:
66
+ return {
67
+ "budget_approx_tokens": self.budget_approx_tokens,
68
+ "used_approx_tokens": self.approx_tokens,
69
+ "sections": [s.as_trace() for s in self.sections],
70
+ }
71
+
72
+
73
+ class ContextAssembler:
74
+ """Ordered, budgeted context assembly over injected retrieval seams.
75
+
76
+ Section priority (kept under budget in this order — semantic memories
77
+ first because they are cheap and durable; recency last):
78
+ 1. memories — workspace semantic memories (preferences/decisions/…)
79
+ 2. knowledge — hybrid search over the brain (the product's own engine)
80
+ 3. notes — garden-note context
81
+ 4. recent — the user's recent exchange
82
+ Every seam is optional; an absent seam contributes nothing (honest
83
+ absence), never a fabricated section.
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ *,
89
+ memory_recall: Optional[Callable[..., Dict[str, Any]]] = None,
90
+ hybrid_search: Optional[Callable[..., Dict[str, Any]]] = None,
91
+ notes_context: Optional[Callable[..., str]] = None,
92
+ recent_chat: Optional[Callable[..., str]] = None,
93
+ ) -> None:
94
+ self._memory_recall = memory_recall
95
+ self._hybrid_search = hybrid_search
96
+ self._notes_context = notes_context
97
+ self._recent_chat = recent_chat
98
+
99
+ def assemble(
100
+ self,
101
+ query: str,
102
+ *,
103
+ user_email: Optional[str] = None,
104
+ workspace_id: Optional[str] = None,
105
+ conversation_id: Optional[str] = None,
106
+ budget: int = 2000,
107
+ memory_limit: int = 5,
108
+ knowledge_limit: int = 5,
109
+ ) -> AssembledContext:
110
+ sections: List[ContextSection] = []
111
+
112
+ if self._memory_recall is not None:
113
+ sections.append(self._memories_section(query, user_email, workspace_id, memory_limit))
114
+ if self._hybrid_search is not None:
115
+ sections.append(self._knowledge_section(query, knowledge_limit, user_email))
116
+ if self._notes_context is not None:
117
+ sections.append(self._notes_section(query))
118
+ if self._recent_chat is not None:
119
+ sections.append(self._recent_section(user_email, conversation_id))
120
+
121
+ sections = [s for s in sections if s.content.strip()]
122
+ self._apply_budget(sections, budget)
123
+ return AssembledContext(sections=sections, budget_approx_tokens=budget)
124
+
125
+ # ── section builders (each failure-isolated and honest) ───────────────
126
+ def _memories_section(self, query, user_email, workspace_id, limit) -> ContextSection:
127
+ try:
128
+ recall = self._memory_recall(query, user_email=user_email, workspace_id=workspace_id, limit=limit)
129
+ results = [r for r in recall.get("results", []) if r.get("source") == "workspace"][:limit]
130
+ except Exception as exc:
131
+ logging.debug("context: memory recall failed: %s", exc)
132
+ results = []
133
+ lines = [f"- ({r.get('kind') or 'memory'}) {r.get('snippet') or ''}" for r in results]
134
+ return ContextSection(
135
+ name="User memories",
136
+ content="\n".join(lines),
137
+ source="memory",
138
+ provenance=[
139
+ {"id": r.get("id"), "kind": r.get("kind"), "score": r.get("score")}
140
+ for r in results
141
+ ],
142
+ )
143
+
144
+ def _knowledge_section(self, query, limit, user_email=None) -> ContextSection:
145
+ try:
146
+ hybrid = self._hybrid_search(query, limit=limit, user_email=user_email)
147
+ matches = hybrid.get("matches", [])[:limit]
148
+ except Exception as exc:
149
+ logging.debug("context: hybrid search failed: %s", exc)
150
+ matches = []
151
+ lines = []
152
+ provenance = []
153
+ for m in matches:
154
+ title = m.get("title") or m.get("id") or "item"
155
+ body = (m.get("summary") or m.get("snippet") or "")[:400]
156
+ lines.append(f"- {title}: {body}" if body else f"- {title}")
157
+ provenance.append({
158
+ "id": m.get("id"),
159
+ "score": m.get("score"),
160
+ "sources": m.get("sources"),
161
+ })
162
+ return ContextSection(
163
+ name="Knowledge",
164
+ content="\n".join(lines),
165
+ source="knowledge",
166
+ provenance=provenance,
167
+ )
168
+
169
+ def _notes_section(self, query) -> ContextSection:
170
+ try:
171
+ content = self._notes_context(query) or ""
172
+ except Exception as exc:
173
+ logging.debug("context: notes context failed: %s", exc)
174
+ content = ""
175
+ return ContextSection(
176
+ name="Garden notes",
177
+ content=content,
178
+ source="notes",
179
+ provenance=[{"source": "garden", "included": bool(content)}],
180
+ )
181
+
182
+ def _recent_section(self, user_email, conversation_id) -> ContextSection:
183
+ try:
184
+ content = self._recent_chat(user_email=user_email, conversation_id=conversation_id) or ""
185
+ except Exception as exc:
186
+ logging.debug("context: recent chat failed: %s", exc)
187
+ content = ""
188
+ return ContextSection(
189
+ name="Recent conversation",
190
+ content=content,
191
+ source="recent_chat",
192
+ provenance=[{"conversation_id": conversation_id, "user_email": user_email}],
193
+ )
194
+
195
+ # ── budget ─────────────────────────────────────────────────────────────
196
+ @staticmethod
197
+ def _apply_budget(sections: List[ContextSection], budget: int) -> None:
198
+ """Trim from the END (lowest priority) until under budget."""
199
+ budget = max(1, int(budget))
200
+ used = 0
201
+ for section in sections:
202
+ remaining = budget - used
203
+ if remaining <= 0:
204
+ section.content = ""
205
+ section.truncated = True
206
+ continue
207
+ if section.approx_tokens > remaining:
208
+ section.content = section.content[: remaining * 4]
209
+ section.truncated = True
210
+ used += section.approx_tokens
211
+
212
+
213
+ __all__ = ["ContextAssembler", "ContextSection", "AssembledContext", "approx_tokens"]
@@ -0,0 +1,236 @@
1
+ """Durable conversation store — kills the 50-message chat_history.json cap.
2
+
3
+ Conversations are episodic memory, the most valuable raw input a Digital
4
+ Brain has; truncating them contradicted "knowledge is durable". This store
5
+ keeps every message in SQLite — by default in the same database file as the
6
+ knowledge graph, so the existing kg_portability backup/restore covers
7
+ conversations with no manifest changes.
8
+
9
+ The public item shape is exactly the legacy chat_history.json entry
10
+ (role/content/timestamp + optional user_email/user_nickname/source/
11
+ conversation_id), so every existing consumer of ``get_history()`` keeps
12
+ working. Legacy history is imported once, idempotently (content-hash dedup).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import json
19
+ import logging
20
+ import sqlite3
21
+ import threading
22
+ from pathlib import Path
23
+ from typing import Any, Dict, List, Optional
24
+
25
+
26
+ def _message_hash(item: Dict[str, Any]) -> str:
27
+ basis = "|".join(
28
+ str(item.get(key) or "")
29
+ for key in ("role", "content", "timestamp", "user_email", "conversation_id", "source")
30
+ )
31
+ return hashlib.sha256(basis.encode("utf-8", "ignore")).hexdigest()
32
+
33
+
34
+ class ConversationStore:
35
+ """Unbounded, per-conversation chat history in SQLite."""
36
+
37
+ def __init__(self, db_path: Path):
38
+ self.db_path = Path(db_path)
39
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
40
+ self._lock = threading.RLock()
41
+ self._init_db()
42
+
43
+ def _connect(self) -> sqlite3.Connection:
44
+ conn = sqlite3.connect(str(self.db_path))
45
+ conn.row_factory = sqlite3.Row
46
+ conn.execute("PRAGMA journal_mode=WAL")
47
+ return conn
48
+
49
+ def _init_db(self) -> None:
50
+ with self._lock, self._connect() as conn:
51
+ conn.executescript(
52
+ """
53
+ CREATE TABLE IF NOT EXISTS conversation_messages (
54
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
55
+ message_hash TEXT NOT NULL UNIQUE,
56
+ conversation_id TEXT,
57
+ role TEXT NOT NULL,
58
+ content TEXT NOT NULL,
59
+ user_email TEXT,
60
+ user_nickname TEXT,
61
+ source TEXT,
62
+ timestamp TEXT NOT NULL,
63
+ metadata_json TEXT NOT NULL DEFAULT '{}'
64
+ );
65
+ CREATE INDEX IF NOT EXISTS idx_conv_messages_conv
66
+ ON conversation_messages(conversation_id);
67
+ CREATE INDEX IF NOT EXISTS idx_conv_messages_time
68
+ ON conversation_messages(timestamp);
69
+ """
70
+ )
71
+
72
+ # ── writes ────────────────────────────────────────────────────────────
73
+ def append(self, item: Dict[str, Any]) -> Dict[str, Any]:
74
+ """Persist one chat item (the legacy chat_history.json entry shape)."""
75
+ known = {"role", "content", "timestamp", "user_email", "user_nickname", "source", "conversation_id"}
76
+ extra = {k: v for k, v in item.items() if k not in known}
77
+ with self._lock, self._connect() as conn:
78
+ conn.execute(
79
+ """
80
+ INSERT OR IGNORE INTO conversation_messages
81
+ (message_hash, conversation_id, role, content, user_email,
82
+ user_nickname, source, timestamp, metadata_json)
83
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
84
+ """,
85
+ (
86
+ _message_hash(item),
87
+ item.get("conversation_id"),
88
+ str(item.get("role") or "user"),
89
+ str(item.get("content") or ""),
90
+ item.get("user_email"),
91
+ item.get("user_nickname"),
92
+ item.get("source"),
93
+ str(item.get("timestamp") or ""),
94
+ json.dumps(extra, ensure_ascii=False) if extra else "{}",
95
+ ),
96
+ )
97
+ return item
98
+
99
+ def import_legacy_json(self, history_file: Path) -> int:
100
+ """One-time, idempotent import of a chat_history.json file.
101
+
102
+ Re-running never duplicates (message_hash UNIQUE + INSERT OR IGNORE);
103
+ the source file is left untouched on disk.
104
+ """
105
+ path = Path(history_file)
106
+ if not path.exists():
107
+ return 0
108
+ try:
109
+ with open(path, "r", encoding="utf-8") as fh:
110
+ items = json.load(fh)
111
+ except Exception as exc:
112
+ logging.warning("conversation store: legacy import failed to read %s: %s", path, exc)
113
+ return 0
114
+ if not isinstance(items, list):
115
+ return 0
116
+ imported = 0
117
+ with self._lock, self._connect() as conn:
118
+ for item in items:
119
+ if not isinstance(item, dict):
120
+ continue
121
+ cur = conn.execute(
122
+ """
123
+ INSERT OR IGNORE INTO conversation_messages
124
+ (message_hash, conversation_id, role, content, user_email,
125
+ user_nickname, source, timestamp, metadata_json)
126
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, '{}')
127
+ """,
128
+ (
129
+ _message_hash(item),
130
+ item.get("conversation_id"),
131
+ str(item.get("role") or "user"),
132
+ str(item.get("content") or ""),
133
+ item.get("user_email"),
134
+ item.get("user_nickname"),
135
+ item.get("source"),
136
+ str(item.get("timestamp") or ""),
137
+ ),
138
+ )
139
+ imported += cur.rowcount if cur.rowcount > 0 else 0
140
+ if imported:
141
+ logging.info("conversation store: imported %d legacy chat messages from %s", imported, path)
142
+ return imported
143
+
144
+ # ── reads (legacy item shape) ─────────────────────────────────────────
145
+ @staticmethod
146
+ def _row_to_item(row: sqlite3.Row) -> Dict[str, Any]:
147
+ item: Dict[str, Any] = {
148
+ "role": row["role"],
149
+ "content": row["content"],
150
+ "timestamp": row["timestamp"],
151
+ }
152
+ for key in ("user_email", "user_nickname", "source", "conversation_id"):
153
+ if row[key]:
154
+ item[key] = row[key]
155
+ try:
156
+ extra = json.loads(row["metadata_json"] or "{}")
157
+ except Exception:
158
+ extra = {}
159
+ item.update(extra)
160
+ return item
161
+
162
+ def history(self, *, conversation_id: Optional[str] = None, limit: Optional[int] = None) -> List[Dict[str, Any]]:
163
+ """Chronological items; the unbounded successor of get_history()."""
164
+ query = "SELECT * FROM conversation_messages"
165
+ params: List[Any] = []
166
+ if conversation_id is not None:
167
+ query += " WHERE conversation_id IS ?" if conversation_id == "" else " WHERE conversation_id = ?"
168
+ params.append(None if conversation_id == "" else conversation_id)
169
+ query += " ORDER BY id ASC"
170
+ if limit is not None:
171
+ query += " LIMIT ?"
172
+ params.append(max(1, int(limit)))
173
+ with self._connect() as conn:
174
+ rows = conn.execute(query, params).fetchall()
175
+ return [self._row_to_item(row) for row in rows]
176
+
177
+ def count(self) -> int:
178
+ with self._connect() as conn:
179
+ return conn.execute("SELECT COUNT(*) FROM conversation_messages").fetchone()[0]
180
+
181
+ def size_bytes(self) -> int:
182
+ try:
183
+ return self.db_path.stat().st_size if self.db_path.exists() else 0
184
+ except OSError:
185
+ return 0
186
+
187
+ # ── clears (legacy semantics preserved) ───────────────────────────────
188
+ def clear_all(self, keep_last: int = 0) -> Dict[str, Any]:
189
+ keep_last = max(0, min(int(keep_last or 0), 20))
190
+ with self._lock, self._connect() as conn:
191
+ total = conn.execute("SELECT COUNT(*) FROM conversation_messages").fetchone()[0]
192
+ if keep_last:
193
+ conn.execute(
194
+ """
195
+ DELETE FROM conversation_messages WHERE id NOT IN (
196
+ SELECT id FROM conversation_messages ORDER BY id DESC LIMIT ?
197
+ )
198
+ """,
199
+ (keep_last,),
200
+ )
201
+ else:
202
+ conn.execute("DELETE FROM conversation_messages")
203
+ kept = conn.execute("SELECT COUNT(*) FROM conversation_messages").fetchone()[0]
204
+ return {"status": "cleared", "removed": max(0, total - kept), "kept": kept}
205
+
206
+ def clear_conversation(self, conversation_id: str, started_at: Optional[str] = None) -> Dict[str, Any]:
207
+ """Remove one conversation.
208
+
209
+ ``legacy-previous-history`` targets unattributed messages; when
210
+ ``started_at`` is given, unattributed messages from that point on are
211
+ removed too (mirrors the pre-v4 JSON behaviour exactly).
212
+ """
213
+ with self._lock, self._connect() as conn:
214
+ total = conn.execute("SELECT COUNT(*) FROM conversation_messages").fetchone()[0]
215
+ if conversation_id == "legacy-previous-history":
216
+ conn.execute("DELETE FROM conversation_messages WHERE conversation_id IS NULL")
217
+ else:
218
+ conn.execute(
219
+ "DELETE FROM conversation_messages WHERE conversation_id = ?",
220
+ (conversation_id,),
221
+ )
222
+ if started_at:
223
+ conn.execute(
224
+ "DELETE FROM conversation_messages WHERE conversation_id IS NULL AND timestamp >= ?",
225
+ (str(started_at),),
226
+ )
227
+ kept = conn.execute("SELECT COUNT(*) FROM conversation_messages").fetchone()[0]
228
+ return {
229
+ "status": "cleared",
230
+ "conversation_id": conversation_id,
231
+ "removed": max(0, total - kept),
232
+ "kept": kept,
233
+ }
234
+
235
+
236
+ __all__ = ["ConversationStore"]
@@ -0,0 +1,175 @@
1
+ """Device identity — the sovereignty primitive.
2
+
3
+ Every Lattice installation owns an Ed25519 keypair. Exports are signed by
4
+ it, peers pair against its public key, and imported knowledge records which
5
+ device it came from. The private key never leaves the machine: it lives in
6
+ the OS keyring when one is available, otherwise in a 0600 file under the
7
+ data directory (the storage backend is reported honestly).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import hashlib
14
+ import json
15
+ import logging
16
+ import os
17
+ from pathlib import Path
18
+ from typing import Any, Dict, Optional
19
+
20
+ from cryptography.hazmat.primitives import serialization
21
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
22
+ Ed25519PrivateKey,
23
+ Ed25519PublicKey,
24
+ )
25
+
26
+ _KEYRING_SERVICE = "lattice-ai-device-identity"
27
+ _KEYRING_ENTRY = "ed25519-private-key"
28
+
29
+
30
+ def _b64(data: bytes) -> str:
31
+ return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
32
+
33
+
34
+ def _unb64(text: str) -> bytes:
35
+ padded = text + "=" * (-len(text) % 4)
36
+ return base64.urlsafe_b64decode(padded.encode("ascii"))
37
+
38
+
39
+ def _keyring_opt_in() -> bool:
40
+ """Keyring storage is opt-in (LATTICEAI_DEVICE_KEY_KEYRING=1).
41
+
42
+ OS keychain access can block or prompt during startup/tests; the default
43
+ is a 0600 file under the data dir, and ``describe()`` reports which
44
+ backend holds the key — no silent security theater either way.
45
+ """
46
+ return os.getenv("LATTICEAI_DEVICE_KEY_KEYRING", "").strip() in {"1", "true", "yes"}
47
+
48
+
49
+ class DeviceIdentity:
50
+ """Loads-or-creates the installation's Ed25519 keypair."""
51
+
52
+ def __init__(self, data_dir: Path, *, use_keyring: Optional[bool] = None):
53
+ if use_keyring is None:
54
+ use_keyring = _keyring_opt_in()
55
+ self._data_dir = Path(data_dir)
56
+ self._key_file = self._data_dir / "device_identity.key"
57
+ self._private: Ed25519PrivateKey
58
+ self.storage: str # "keyring" | "file"
59
+ self._load_or_create(use_keyring)
60
+
61
+ # ── key material ───────────────────────────────────────────────────────
62
+ def _load_or_create(self, use_keyring: bool) -> None:
63
+ raw: Optional[bytes] = None
64
+ backend = "file"
65
+ if use_keyring:
66
+ try:
67
+ import keyring
68
+
69
+ stored = keyring.get_password(_KEYRING_SERVICE, _KEYRING_ENTRY)
70
+ if stored:
71
+ raw = _unb64(stored)
72
+ backend = "keyring"
73
+ except Exception as exc:
74
+ logging.debug("device identity: keyring unavailable (%s)", exc)
75
+ if raw is None and self._key_file.exists():
76
+ raw = _unb64(self._key_file.read_text().strip())
77
+ backend = "file"
78
+ if raw is None:
79
+ key = Ed25519PrivateKey.generate()
80
+ raw = key.private_bytes(
81
+ serialization.Encoding.Raw,
82
+ serialization.PrivateFormat.Raw,
83
+ serialization.NoEncryption(),
84
+ )
85
+ backend = self._persist_new(raw, use_keyring)
86
+ self._private = Ed25519PrivateKey.from_private_bytes(raw)
87
+ self.storage = backend
88
+
89
+ def _persist_new(self, raw: bytes, use_keyring: bool) -> str:
90
+ if use_keyring:
91
+ try:
92
+ import keyring
93
+
94
+ keyring.set_password(_KEYRING_SERVICE, _KEYRING_ENTRY, _b64(raw))
95
+ return "keyring"
96
+ except Exception as exc:
97
+ logging.debug("device identity: keyring store failed (%s); using file", exc)
98
+ self._data_dir.mkdir(parents=True, exist_ok=True)
99
+ self._key_file.write_text(_b64(raw))
100
+ os.chmod(self._key_file, 0o600)
101
+ return "file"
102
+
103
+ # ── public surface ─────────────────────────────────────────────────────
104
+ @property
105
+ def public_key_b64(self) -> str:
106
+ return _b64(
107
+ self._private.public_key().public_bytes(
108
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw
109
+ )
110
+ )
111
+
112
+ @property
113
+ def fingerprint(self) -> str:
114
+ """Short human-comparable id: sha256 of the raw public key."""
115
+ raw = self._private.public_key().public_bytes(
116
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw
117
+ )
118
+ digest = hashlib.sha256(raw).hexdigest()
119
+ return ":".join(digest[i : i + 4] for i in range(0, 16, 4))
120
+
121
+ def describe(self) -> Dict[str, Any]:
122
+ return {
123
+ "fingerprint": self.fingerprint,
124
+ "public_key": self.public_key_b64,
125
+ "algorithm": "ed25519",
126
+ "storage": self.storage,
127
+ }
128
+
129
+ # ── signing ────────────────────────────────────────────────────────────
130
+ def sign(self, payload: bytes) -> str:
131
+ return _b64(self._private.sign(payload))
132
+
133
+ def sign_manifest(self, manifest: Dict[str, Any]) -> Dict[str, Any]:
134
+ """Detached signature over the canonical JSON of a manifest."""
135
+ canonical = json.dumps(manifest, sort_keys=True, ensure_ascii=False).encode("utf-8")
136
+ return {
137
+ "algorithm": "ed25519",
138
+ "public_key": self.public_key_b64,
139
+ "fingerprint": self.fingerprint,
140
+ "signature": self.sign(canonical),
141
+ }
142
+
143
+
144
+ def fingerprint_of(public_key_b64: str) -> str:
145
+ """Human-comparable fingerprint of an Ed25519 public key.
146
+
147
+ Raises ValueError when the input is not a valid key — the pairing flow
148
+ uses this as its validation gate.
149
+ """
150
+ raw = _unb64(public_key_b64)
151
+ Ed25519PublicKey.from_public_bytes(raw) # validates; raises on garbage
152
+ digest = hashlib.sha256(raw).hexdigest()
153
+ return ":".join(digest[i : i + 4] for i in range(0, 16, 4))
154
+
155
+
156
+ def verify_signature(public_key_b64: str, payload: bytes, signature_b64: str) -> bool:
157
+ """True iff ``signature`` is valid for ``payload`` under the given key."""
158
+ try:
159
+ key = Ed25519PublicKey.from_public_bytes(_unb64(public_key_b64))
160
+ key.verify(_unb64(signature_b64), payload)
161
+ return True
162
+ except Exception:
163
+ return False
164
+
165
+
166
+ def verify_manifest(manifest: Dict[str, Any], signature_block: Dict[str, Any]) -> bool:
167
+ canonical = json.dumps(manifest, sort_keys=True, ensure_ascii=False).encode("utf-8")
168
+ return verify_signature(
169
+ str(signature_block.get("public_key") or ""),
170
+ canonical,
171
+ str(signature_block.get("signature") or ""),
172
+ )
173
+
174
+
175
+ __all__ = ["DeviceIdentity", "verify_signature", "verify_manifest"]