ltcai 4.3.1 → 4.4.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 +191 -278
- package/docs/CHANGELOG.md +128 -0
- package/docs/V4_3_2_DEADCODE_AUDIT_REPORT.md +174 -0
- package/docs/V4_3_2_DOCUMENTATION_CLEANUP_REPORT.md +81 -0
- package/docs/V4_3_2_GITHUB_VERCEL_CHECK_REPORT.md +75 -0
- package/docs/V4_3_2_GRAPH_UX_REPORT.md +48 -0
- package/docs/V4_3_2_INDEPENDENT_AUDIT_PACKAGE.md +209 -0
- package/docs/V4_3_2_PRODUCT_POLISH_REPORT.md +57 -0
- package/docs/V4_3_2_SELF_AUDIT_REPORT.md +63 -0
- package/docs/V4_3_2_VALIDATION_REPORT.md +97 -0
- package/docs/V4_3_3_VALIDATION_REPORT.md +46 -0
- package/docs/V4_4_0_EXTRACTION_REPORT.md +239 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +18 -19
- package/frontend/openapi.json +1 -1
- package/frontend/src/components/primitives.tsx +92 -10
- package/frontend/src/pages/Act.tsx +11 -9
- package/frontend/src/pages/Ask.tsx +2 -2
- package/frontend/src/pages/Brain.tsx +607 -65
- package/frontend/src/pages/Capture.tsx +11 -7
- package/frontend/src/pages/Library.tsx +3 -3
- package/frontend/src/pages/System.tsx +186 -23
- package/lattice_brain/__init__.py +38 -23
- package/lattice_brain/_kg_common.py +11 -1
- package/lattice_brain/context.py +212 -2
- package/lattice_brain/conversations.py +234 -1
- package/lattice_brain/discovery.py +11 -1
- package/lattice_brain/documents.py +11 -1
- package/lattice_brain/graph/__init__.py +28 -0
- package/lattice_brain/graph/_kg_common.py +1123 -0
- package/lattice_brain/graph/curator.py +473 -0
- package/lattice_brain/graph/discovery.py +1455 -0
- package/lattice_brain/graph/documents.py +218 -0
- package/lattice_brain/graph/identity.py +175 -0
- package/lattice_brain/graph/ingest.py +644 -0
- package/lattice_brain/graph/network.py +205 -0
- package/lattice_brain/graph/projection.py +571 -0
- package/lattice_brain/graph/provenance.py +401 -0
- package/lattice_brain/graph/retrieval.py +1341 -0
- package/lattice_brain/graph/schema.py +640 -0
- package/lattice_brain/graph/store.py +237 -0
- package/lattice_brain/graph/write_master.py +225 -0
- package/lattice_brain/identity.py +11 -13
- package/lattice_brain/ingest.py +11 -1
- package/lattice_brain/ingestion.py +318 -0
- package/lattice_brain/memory.py +100 -1
- package/lattice_brain/network.py +11 -1
- package/lattice_brain/portability.py +431 -0
- package/lattice_brain/projection.py +11 -1
- package/lattice_brain/provenance.py +11 -1
- package/lattice_brain/retrieval.py +11 -1
- package/lattice_brain/runtime/__init__.py +32 -0
- package/lattice_brain/runtime/agent_runtime.py +569 -0
- package/lattice_brain/runtime/hooks.py +754 -0
- package/lattice_brain/runtime/multi_agent.py +795 -0
- package/lattice_brain/schema.py +11 -1
- package/lattice_brain/store.py +10 -2
- package/lattice_brain/workflow.py +461 -0
- package/lattice_brain/write_master.py +11 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agents.py +2 -2
- package/latticeai/api/browser.py +1 -1
- package/latticeai/api/chat.py +1 -1
- package/latticeai/api/computer_use.py +1 -1
- package/latticeai/api/hooks.py +2 -2
- package/latticeai/api/mcp.py +1 -1
- package/latticeai/api/tools.py +1 -1
- package/latticeai/api/workflow_designer.py +2 -2
- package/latticeai/app_factory.py +4 -4
- package/latticeai/brain/__init__.py +24 -6
- package/latticeai/brain/_kg_common.py +11 -1117
- package/latticeai/brain/context.py +12 -208
- package/latticeai/brain/conversations.py +12 -231
- package/latticeai/brain/discovery.py +13 -1451
- package/latticeai/brain/documents.py +13 -214
- package/latticeai/brain/identity.py +11 -169
- package/latticeai/brain/ingest.py +13 -640
- package/latticeai/brain/memory.py +12 -97
- package/latticeai/brain/network.py +12 -200
- package/latticeai/brain/projection.py +13 -567
- package/latticeai/brain/provenance.py +13 -397
- package/latticeai/brain/retrieval.py +13 -1337
- package/latticeai/brain/schema.py +12 -635
- package/latticeai/brain/store.py +13 -233
- package/latticeai/brain/write_master.py +13 -221
- package/latticeai/core/agent.py +1 -1
- package/latticeai/core/agent_registry.py +2 -2
- package/latticeai/core/builtin_hooks.py +2 -2
- package/latticeai/core/graph_curator.py +6 -468
- package/latticeai/core/hooks.py +6 -749
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +6 -790
- package/latticeai/core/workflow_engine.py +6 -456
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/services/agent_runtime.py +6 -564
- package/latticeai/services/ingestion.py +6 -313
- package/latticeai/services/kg_portability.py +6 -426
- package/latticeai/services/platform_runtime.py +3 -3
- package/latticeai/services/run_executor.py +1 -1
- package/latticeai/services/upload_service.py +1 -1
- package/p_reinforce.py +1 -1
- package/package.json +3 -6
- package/scripts/build_vercel_static.mjs +77 -0
- package/scripts/bump_version.py +1 -1
- package/scripts/check_markdown_links.mjs +75 -0
- package/scripts/wheel_smoke.py +7 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/src/main.rs +12 -2
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +5 -5
- package/static/app/assets/index-CHHal8Zl.css +2 -0
- package/static/app/assets/index-pdzil9ac.js +333 -0
- package/static/app/assets/index-pdzil9ac.js.map +1 -0
- package/static/app/index.html +2 -2
- package/latticeai/api/deps.py +0 -15
- package/scripts/capture/README.md +0 -28
- package/scripts/capture/capture_enterprise.js +0 -8
- package/scripts/capture/capture_graph.js +0 -8
- package/scripts/capture/capture_onboarding.js +0 -8
- package/scripts/capture/capture_page.js +0 -43
- package/scripts/capture/capture_release_media.js +0 -125
- package/scripts/capture/capture_skills.js +0 -8
- package/scripts/capture/capture_v340.js +0 -88
- package/scripts/capture/capture_workspace.js +0 -8
- package/scripts/generate_diagrams.py +0 -512
- package/scripts/release-0.3.1.sh +0 -105
- package/scripts/take_screenshots.js +0 -69
- package/static/app/assets/index-BhPuj8rT.js +0 -333
- package/static/app/assets/index-BhPuj8rT.js.map +0 -1
- package/static/app/assets/index-yZswHE3d.css +0 -2
- package/static/css/tokens.3ba22e37.css +0 -260
|
@@ -1,213 +1,17 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Deprecated shim: physically moved to lattice_brain.context.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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).
|
|
3
|
+
Kept only for the compatibility window. The module aliases itself to the
|
|
4
|
+
physical module so identity, singletons, and monkeypatching are preserved.
|
|
12
5
|
"""
|
|
13
6
|
|
|
14
|
-
|
|
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
|
|
7
|
+
import sys
|
|
8
|
+
import warnings
|
|
211
9
|
|
|
10
|
+
import lattice_brain.context as _impl
|
|
212
11
|
|
|
213
|
-
|
|
12
|
+
warnings.warn(
|
|
13
|
+
"latticeai.brain.context is deprecated; import lattice_brain.context instead",
|
|
14
|
+
DeprecationWarning,
|
|
15
|
+
stacklevel=2,
|
|
16
|
+
)
|
|
17
|
+
sys.modules[__name__] = _impl
|
|
@@ -1,236 +1,17 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Deprecated shim: physically moved to lattice_brain.conversations.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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).
|
|
3
|
+
Kept only for the compatibility window. The module aliases itself to the
|
|
4
|
+
physical module so identity, singletons, and monkeypatching are preserved.
|
|
13
5
|
"""
|
|
14
6
|
|
|
15
|
-
|
|
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
|
-
}
|
|
7
|
+
import sys
|
|
8
|
+
import warnings
|
|
234
9
|
|
|
10
|
+
import lattice_brain.conversations as _impl
|
|
235
11
|
|
|
236
|
-
|
|
12
|
+
warnings.warn(
|
|
13
|
+
"latticeai.brain.conversations is deprecated; import lattice_brain.conversations instead",
|
|
14
|
+
DeprecationWarning,
|
|
15
|
+
stacklevel=2,
|
|
16
|
+
)
|
|
17
|
+
sys.modules[__name__] = _impl
|