ltcai 8.7.0 → 8.9.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 (103) hide show
  1. package/README.md +38 -26
  2. package/auto_setup.py +73 -8
  3. package/docs/CHANGELOG.md +63 -0
  4. package/docs/CODE_REVIEW_2026-07-06.md +764 -0
  5. package/docs/COMMUNITY_AND_PLUGINS.md +4 -3
  6. package/docs/DEVELOPMENT.md +10 -10
  7. package/docs/LEGACY_COMPATIBILITY.md +33 -14
  8. package/docs/ONBOARDING.md +2 -2
  9. package/docs/PRODUCT_DIRECTION_REVIEW.md +3 -2
  10. package/docs/TRUST_MODEL.md +5 -1
  11. package/docs/WHY_LATTICE.md +4 -3
  12. package/docs/architecture.md +4 -0
  13. package/docs/kg-schema.md +1 -1
  14. package/kg_schema.py +1 -1
  15. package/knowledge_graph.py +2 -2
  16. package/lattice_brain/__init__.py +1 -1
  17. package/lattice_brain/conversations.py +156 -21
  18. package/lattice_brain/core.py +1 -1
  19. package/lattice_brain/graph/_kg_common.py +12 -34
  20. package/lattice_brain/graph/json_utils.py +25 -0
  21. package/lattice_brain/graph/retrieval.py +66 -27
  22. package/lattice_brain/graph/runtime.py +16 -0
  23. package/lattice_brain/ingestion.py +35 -20
  24. package/lattice_brain/runtime/agent_runtime.py +37 -3
  25. package/lattice_brain/runtime/multi_agent.py +1 -1
  26. package/latticeai/__init__.py +1 -1
  27. package/latticeai/api/chat.py +31 -14
  28. package/latticeai/api/mcp.py +3 -2
  29. package/latticeai/api/models.py +4 -1
  30. package/latticeai/api/permissions.py +69 -30
  31. package/latticeai/api/setup.py +17 -2
  32. package/latticeai/api/tools.py +104 -62
  33. package/latticeai/app_factory.py +93 -10
  34. package/latticeai/core/agent.py +25 -7
  35. package/latticeai/core/legacy_compatibility.py +36 -25
  36. package/latticeai/core/marketplace.py +1 -1
  37. package/latticeai/core/sessions.py +11 -3
  38. package/latticeai/core/tool_registry.py +15 -4
  39. package/latticeai/core/workspace_os.py +1 -1
  40. package/latticeai/runtime/bootstrap.py +1 -1
  41. package/latticeai/runtime/persistence_runtime.py +1 -1
  42. package/latticeai/runtime/platform_services_runtime.py +1 -1
  43. package/latticeai/services/app_context.py +1 -0
  44. package/latticeai/services/architecture_readiness.py +2 -2
  45. package/latticeai/services/memory_service.py +38 -7
  46. package/latticeai/services/model_engines.py +79 -12
  47. package/latticeai/services/model_runtime.py +24 -4
  48. package/latticeai/services/process_audit.py +208 -0
  49. package/latticeai/services/product_readiness.py +11 -11
  50. package/latticeai/services/search_service.py +106 -30
  51. package/latticeai/services/tool_dispatch.py +66 -0
  52. package/latticeai/services/workspace_service.py +15 -0
  53. package/package.json +1 -1
  54. package/scripts/check_i18n_literals.mjs +20 -8
  55. package/scripts/i18n_literal_allowlist.json +34 -0
  56. package/scripts/lint_frontend.mjs +6 -2
  57. package/setup_wizard.py +185 -19
  58. package/src-tauri/Cargo.lock +1 -1
  59. package/src-tauri/Cargo.toml +1 -1
  60. package/src-tauri/tauri.conf.json +1 -1
  61. package/static/app/asset-manifest.json +11 -11
  62. package/static/app/assets/{Act-_U7mhXir.js → Act-fZokUnC0.js} +1 -1
  63. package/static/app/assets/{Brain-BxyTHZ21.js → Brain-DtyuWubr.js} +2 -2
  64. package/static/app/assets/{Capture-DyDKWNh9.js → Capture-D5KV3Cu7.js} +1 -1
  65. package/static/app/assets/{Library-DJ8KioFM.js → Library-C9kyFkSt.js} +1 -1
  66. package/static/app/assets/{System-C0FIb3OO.js → System-VbChmX7r.js} +1 -1
  67. package/static/app/assets/index-DCh5AoXt.css +2 -0
  68. package/static/app/assets/index-DPdcPoF0.js +17 -0
  69. package/static/app/assets/{primitives-BywkNS3f.js → primitives-DFeanEV6.js} +1 -1
  70. package/static/app/assets/{textarea-CQ61Rycp.js → textarea-CD8UNKIy.js} +1 -1
  71. package/static/app/index.html +2 -2
  72. package/static/sw.js +1 -1
  73. package/lattice_brain/_kg_common.py +0 -11
  74. package/lattice_brain/discovery.py +0 -11
  75. package/lattice_brain/documents.py +0 -11
  76. package/lattice_brain/identity.py +0 -11
  77. package/lattice_brain/ingest.py +0 -11
  78. package/lattice_brain/network.py +0 -11
  79. package/lattice_brain/projection.py +0 -11
  80. package/lattice_brain/provenance.py +0 -11
  81. package/lattice_brain/retrieval.py +0 -11
  82. package/lattice_brain/schema.py +0 -11
  83. package/lattice_brain/store.py +0 -11
  84. package/lattice_brain/write_master.py +0 -11
  85. package/latticeai/brain/__init__.py +0 -36
  86. package/latticeai/brain/_kg_common.py +0 -17
  87. package/latticeai/brain/context.py +0 -17
  88. package/latticeai/brain/conversations.py +0 -17
  89. package/latticeai/brain/discovery.py +0 -17
  90. package/latticeai/brain/documents.py +0 -17
  91. package/latticeai/brain/identity.py +0 -17
  92. package/latticeai/brain/ingest.py +0 -17
  93. package/latticeai/brain/memory.py +0 -17
  94. package/latticeai/brain/network.py +0 -17
  95. package/latticeai/brain/projection.py +0 -17
  96. package/latticeai/brain/provenance.py +0 -17
  97. package/latticeai/brain/retrieval.py +0 -17
  98. package/latticeai/brain/schema.py +0 -17
  99. package/latticeai/brain/store.py +0 -17
  100. package/latticeai/brain/write_master.py +0 -17
  101. package/latticeai/services/agent_runtime.py +0 -11
  102. package/static/app/assets/index-Bh7IIlyY.js +0 -16
  103. package/static/app/assets/index-_M5aCv21.css +0 -2
@@ -1,10 +1,11 @@
1
1
  # Community And Plugins
2
2
 
3
- Current release: **8.7.0 — Runtime State Hygiene & Release Evidence Refresh**.
3
+ Current release: **8.9.0 — Scoped Memory & Tool Policy Hardening**.
4
4
 
5
5
  LatticeAI defines the path from a strong local-first framework (8.4.0
6
6
  action-aware baseline, 8.5.0 registry+DI hardening, 8.6.0 capture/navigation
7
- reliability, and 8.7.0 runtime-state hygiene) to a product ecosystem. The
7
+ reliability, 8.7.0 runtime-state hygiene, 8.8.0 Brain Core extraction
8
+ readiness, and 8.9.0 scoped Tool API hardening) to a product ecosystem. The
8
9
  immediate goal is small and practical: make it clear how
9
10
  contributors can extend the Brain without weakening local-first trust,
10
11
  workspace scoping, or release quality.
@@ -36,7 +37,7 @@ Plugins should add one bounded capability at a time:
36
37
  - Compatibility requirements belong in `lattice_version`, and examples should
37
38
  use current host-compatible values.
38
39
 
39
- ## 8.4.0 Ecosystem Tasks
40
+ ## Current Ecosystem Tasks
40
41
 
41
42
  - Keep the hello-world plugin valid and boring.
42
43
  - Expand examples only when they exercise real extension seams.
@@ -1,10 +1,10 @@
1
1
  # Lattice AI Development
2
2
 
3
- Current release: **8.7.0 — Runtime State Hygiene & Release Evidence Refresh**.
3
+ Current release: **8.9.0 — Scoped Memory & Tool Policy Hardening**.
4
4
 
5
5
  This document is for contributors working on the local-first Digital Brain
6
6
  codebase. Product positioning and quick start stay in `README.md`; release
7
- history is intentionally limited to 7.0.0-8.7.0 in `docs/CHANGELOG.md` and
7
+ history is intentionally limited to 7.0.0-8.9.0 in `docs/CHANGELOG.md` and
8
8
  `RELEASE.md`.
9
9
 
10
10
  ## Product Contract
@@ -16,8 +16,8 @@ Engineering work should preserve these boundaries:
16
16
 
17
17
  - the Brain is the durable asset;
18
18
  - models are replaceable voices;
19
- - SQLite is the default local store;
20
- - PostgreSQL, Docker, cloud models, downloads, update checks, Telegram, and
19
+ - SQLite is the live local Brain store;
20
+ - PostgreSQL scale/migration tooling, Docker, cloud models, downloads, update checks, Telegram, and
21
21
  Brain Network are opt-in;
22
22
  - import-only paths must not initialize MLX/GPU, write files, or make network
23
23
  calls;
@@ -114,10 +114,10 @@ For user-facing, API, runtime, release, or packaging changes, check:
114
114
  Release/publish examples must use exact target-version filenames. Do not
115
115
  document wildcard artifact upload commands.
116
116
 
117
- For 8.7.0 release work, exact artifacts are:
117
+ For 8.9.0 release work, exact artifacts are:
118
118
 
119
- - `dist/ltcai-8.7.0-py3-none-any.whl`
120
- - `dist/ltcai-8.7.0.tar.gz`
121
- - `ltcai-8.7.0.tgz`
122
- - `dist/ltcai-8.7.0.vsix`
123
- - `src-tauri/target/release/bundle/dmg/Lattice AI_8.7.0_aarch64.dmg`
119
+ - `dist/ltcai-8.9.0-py3-none-any.whl`
120
+ - `dist/ltcai-8.9.0.tar.gz`
121
+ - `ltcai-8.9.0.tgz`
122
+ - `dist/ltcai-8.9.0.vsix`
123
+ - `src-tauri/target/release/bundle/dmg/Lattice AI_8.9.0_aarch64.dmg`
@@ -1,21 +1,40 @@
1
1
  # Legacy Compatibility Map
2
2
 
3
- Current release: **8.7.0 — Runtime State Hygiene & Release Evidence Refresh**.
3
+ Current target: **8.9.0 — Scoped Memory & Tool Policy Hardening**.
4
4
 
5
5
  Lattice AI is moving toward a smaller, modular architecture centered on
6
6
  `lattice_brain`, `latticeai.services`, `latticeai.api`, and `latticeai.runtime`.
7
7
  Some root-level modules remain packaged for compatibility with older imports,
8
8
  CLI entrypoints, or extension workflows. Their presence does not define the
9
- current 8.7.0 architecture.
9
+ current architecture.
10
10
 
11
- 8.7.0 also tracks the inner compatibility layers that sit below the root
12
- modules. The managed inventory lives in `latticeai.core.legacy_compatibility`
13
- and groups shims by layer:
11
+ The managed inventory lives in `latticeai.core.legacy_compatibility` and
12
+ groups shims by layer:
14
13
 
15
- - `root`: historical repo-root imports such as `knowledge_graph.py`.
16
- - `brain-flat`: pre-graph-package imports such as `lattice_brain.store`.
17
- - `deprecated-namespace`: older `latticeai.brain.*` package imports.
18
- - `service-alias`: service paths that now alias Brain runtime modules.
14
+ - `root`: historical repo-root imports such as `knowledge_graph.py` — **kept**
15
+ (external entrypoints: `uvicorn server:app`, installed CLI, old scripts).
16
+ - `brain-flat`: pre-graph-package imports such as `lattice_brain.store`
17
+ **removed in 8.8.0** (internal-only).
18
+ - `deprecated-namespace`: older `latticeai.brain.*` package imports —
19
+ **removed in 8.8.0** (internal-only).
20
+ - `service-alias`: `latticeai.services.agent_runtime` —
21
+ **removed in 8.8.0** (internal-only).
22
+
23
+ Removed layers stay listed in `legacy_compatibility.REMOVED_SHIMS` and in the
24
+ `legacy_shim_report()` payload (`removed`, `lingering`), so tooling can tell
25
+ "removed on purpose" apart from "missing by accident".
26
+
27
+ ## Brain Core extraction readiness
28
+
29
+ Removing the internal layers gives `lattice_brain` exactly one import surface
30
+ (its physical module paths). Two structural guards keep it extractable as a
31
+ standalone package:
32
+
33
+ - `tests/unit/test_brain_core_isolation.py` — AST guard that fails if any
34
+ `lattice_brain` module imports `latticeai` (the product imports the Brain,
35
+ never the reverse).
36
+ - `tests/unit/test_legacy_root_shims.py::test_internal_shim_layers_are_gone`
37
+ — fails if a removed shim path becomes importable again.
19
38
 
20
39
  ## Current Policy
21
40
 
@@ -47,13 +66,13 @@ and groups shims by layer:
47
66
  | `setup_wizard.py` | setup and model recommendation services | Compatibility for first-run recommendation calls |
48
67
  | `server.py` | lazy proxy to `latticeai.server_app` / `latticeai.app_factory` | Preserves historical `server.app` imports without import-time construction |
49
68
 
50
- ## Inner Shim Layers
69
+ ## Inner Shim Layers (removed in 8.8.0)
51
70
 
52
- | Legacy layer | Example module | Current home / direction | Why it remains |
71
+ | Legacy layer | Example module | Replacement import | Status |
53
72
  | --- | --- | --- | --- |
54
- | `brain-flat` | `lattice_brain.store`, `lattice_brain.ingest`, `lattice_brain.retrieval` | `lattice_brain.graph.*` | Preserves imports from before graph modules were split into a package |
55
- | `deprecated-namespace` | `latticeai.brain.store`, `latticeai.brain.ingest` | `lattice_brain.graph.*` | Preserves the old application-package Brain namespace |
56
- | `service-alias` | `latticeai.services.agent_runtime` | `lattice_brain.runtime.agent_runtime` | Preserves service-layer imports while runtime ownership sits in Brain Core |
73
+ | `brain-flat` | `lattice_brain.store`, `lattice_brain.ingest`, `lattice_brain.retrieval` | `lattice_brain.graph.*` | Removed internal-only, no supported entrypoint depended on it |
74
+ | `deprecated-namespace` | `latticeai.brain.*` | `lattice_brain.*` | Removed namespace also dropped from `pyproject.toml` packages |
75
+ | `service-alias` | `latticeai.services.agent_runtime` | `lattice_brain.runtime.agent_runtime` | Removed runtime ownership sits in Brain Core |
57
76
 
58
77
  ## Packaging Notes
59
78
 
@@ -1,6 +1,6 @@
1
1
  # Lattice AI Onboarding
2
2
 
3
- Current release: **8.7.0 — Runtime State Hygiene & Release Evidence Refresh**.
3
+ Current release: **8.9.0 — Scoped Memory & Tool Policy Hardening**.
4
4
 
5
5
  The first-run goal is a five-minute path from "I opened the app" to "my Brain
6
6
  has a source, a question, and proof." This page is the product contract behind
@@ -32,7 +32,7 @@ read the docs first.
32
32
 
33
33
  ## Release Gate
34
34
 
35
- 8.7.0 treats onboarding as a release gate, not marketing copy. The current
35
+ 8.9.0 treats onboarding as a release gate, not marketing copy. The current
36
36
  machine-checkable product readiness report requires this five-minute contract,
37
37
  the Brain Home surface, setup helpers, graph ingestion tests, and exact release
38
38
  artifact documentation before the release can be called complete.
@@ -23,8 +23,9 @@ Already aligned:
23
23
  - The post-setup home is the living Brain plus conversation.
24
24
  - The graph appears only after progressive depth: Brain -> Memories ->
25
25
  Knowledge -> Relationships -> Graph.
26
- - Brain Core, SQLite default storage, optional PostgreSQL/pgvector scale mode,
27
- backup/restore, and encrypted `.latticebrain` archives support user ownership.
26
+ - Brain Core, SQLite live local storage, optional PostgreSQL/pgvector
27
+ scale/migration tooling, backup/restore, and encrypted `.latticebrain`
28
+ archives support user ownership.
28
29
  - Models are treated as swappable runtime workers rather than the durable asset.
29
30
 
30
31
  Gaps corrected in this review:
@@ -1,6 +1,6 @@
1
1
  # Lattice AI Trust Model
2
2
 
3
- Current release: **8.7.0 — Runtime State Hygiene & Release Evidence Refresh**.
3
+ Current release: **8.9.0 — Scoped Memory & Tool Policy Hardening**.
4
4
 
5
5
  Lattice AI is local-first, explicit about external communication, and honest
6
6
  when a capability is unavailable.
@@ -34,6 +34,10 @@ External communication requires configuration plus a user/admin action:
34
34
 
35
35
  Token presence alone must not start external communication.
36
36
 
37
+ Authenticated history, Knowledge Graph reads, and Tool API calls must also stay
38
+ inside the caller's user/workspace scope. Direct HTTP/MCP tool routes run
39
+ ToolRegistry policy before hooks or handlers execute.
40
+
37
41
  ## Consent And Honesty Gates
38
42
 
39
43
  Lattice AI should fail closed or show an unavailable state for:
@@ -1,6 +1,6 @@
1
1
  # Why Lattice AI Exists
2
2
 
3
- Current release: **8.7.0 — Runtime State Hygiene & Release Evidence Refresh**.
3
+ Current release: **8.9.0 — Scoped Memory & Tool Policy Hardening**.
4
4
 
5
5
  **Lattice AI is a local-first Digital Brain that keeps your knowledge durable
6
6
  across any AI model.**
@@ -32,8 +32,9 @@ The graph matters, but it is not the product identity. The product identity is a
32
32
  local-first Digital Brain: a place where the user can talk, add sources, watch
33
33
  memory grow, and inspect the Knowledge Graph when they need proof.
34
34
 
35
- In 8.7.0 the first screen is intentionally not a dashboard. The living Brain,
36
- conversation composer, evidence-backed Brain Brief, and five-minute onboarding
35
+ In 8.9.0 the first screen is intentionally not a dashboard. The living Brain,
36
+ conversation composer, evidence-backed Brain Brief, scoped memory isolation,
37
+ and five-minute onboarding
37
38
  path appear together so the user immediately knows what matters, what to add,
38
39
  and why the answer is grounded in local memory.
39
40
 
@@ -1,5 +1,9 @@
1
1
  # Lattice AI Architecture
2
2
 
3
+ > Historical subsystem note. The canonical current architecture document is
4
+ > [`ARCHITECTURE.md`](../ARCHITECTURE.md). This file preserves the v3.6.0
5
+ > Knowledge Graph First architecture detail for background.
6
+
3
7
  > v3.6.0 — **Knowledge Graph First.** Every data source converges into the graph
4
8
  > through one unified ingestion pipeline (`latticeai/services/ingestion.py`), with
5
9
  > formalized entities/relationships (`docs/kg-schema.md`), browser/web inputs,
package/docs/kg-schema.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Knowledge Graph Schema
2
2
 
3
- Current release: **8.7.0 — Runtime State Hygiene & Release Evidence Refresh**.
3
+ Current release: **8.9.0 — Scoped Memory & Tool Policy Hardening**.
4
4
 
5
5
  명세 출처: `lattice_ai_full_spec.pptx` 슬라이드 20·21·22
6
6
  구현: `kg_schema.py`
package/kg_schema.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Compatibility shim for the v4 Knowledge Graph schema."""
2
2
 
3
- from lattice_brain.schema import * # noqa: F403,F401
3
+ from lattice_brain.graph.schema import * # noqa: F403,F401
@@ -4,7 +4,7 @@ The implementation now lives under :mod:`lattice_brain`. Root imports are
4
4
  kept for older integrations and tests.
5
5
  """
6
6
 
7
- from lattice_brain._kg_common import ( # noqa: F401
7
+ from lattice_brain.graph._kg_common import ( # noqa: F401
8
8
  EDGE_VERB,
9
9
  GRAPH_SCHEMA_VERSION,
10
10
  LOCAL_CODE_EXTENSIONS,
@@ -24,7 +24,7 @@ from lattice_brain._kg_common import ( # noqa: F401
24
24
  _slug,
25
25
  set_llm_router,
26
26
  )
27
- from lattice_brain.store import KnowledgeGraphStore
27
+ from lattice_brain.graph.store import KnowledgeGraphStore
28
28
 
29
29
  __all__ = [
30
30
  "KnowledgeGraphStore",
@@ -26,7 +26,7 @@ from .storage import (
26
26
  storage_from_env,
27
27
  )
28
28
 
29
- __version__ = "8.7.0"
29
+ __version__ = "8.9.0"
30
30
 
31
31
  __all__ = [
32
32
  "AgentRuntime",
@@ -60,27 +60,76 @@ class ConversationStore:
60
60
  user_nickname TEXT,
61
61
  source TEXT,
62
62
  timestamp TEXT NOT NULL,
63
- metadata_json TEXT NOT NULL DEFAULT '{}'
63
+ metadata_json TEXT NOT NULL DEFAULT '{}',
64
+ workspace_id TEXT,
65
+ organization_id TEXT
64
66
  );
65
67
  CREATE INDEX IF NOT EXISTS idx_conv_messages_conv
66
68
  ON conversation_messages(conversation_id);
67
69
  CREATE INDEX IF NOT EXISTS idx_conv_messages_time
68
70
  ON conversation_messages(timestamp);
71
+ CREATE INDEX IF NOT EXISTS idx_conv_messages_user
72
+ ON conversation_messages(user_email);
69
73
  """
70
74
  )
75
+ columns = {row["name"] for row in conn.execute("PRAGMA table_info(conversation_messages)").fetchall()}
76
+ if "workspace_id" not in columns:
77
+ conn.execute("ALTER TABLE conversation_messages ADD COLUMN workspace_id TEXT")
78
+ if "organization_id" not in columns:
79
+ conn.execute("ALTER TABLE conversation_messages ADD COLUMN organization_id TEXT")
80
+ conn.execute(
81
+ """
82
+ CREATE INDEX IF NOT EXISTS idx_conv_messages_workspace
83
+ ON conversation_messages(workspace_id)
84
+ """
85
+ )
86
+ rows = conn.execute(
87
+ """
88
+ SELECT id, metadata_json FROM conversation_messages
89
+ WHERE workspace_id IS NULL OR organization_id IS NULL
90
+ """
91
+ ).fetchall()
92
+ for row in rows:
93
+ try:
94
+ meta = json.loads(row["metadata_json"] or "{}")
95
+ except Exception:
96
+ meta = {}
97
+ workspace_id = meta.get("workspace_id")
98
+ organization_id = meta.get("organization_id")
99
+ if workspace_id or organization_id:
100
+ conn.execute(
101
+ """
102
+ UPDATE conversation_messages
103
+ SET workspace_id=COALESCE(workspace_id, ?),
104
+ organization_id=COALESCE(organization_id, ?)
105
+ WHERE id=?
106
+ """,
107
+ (workspace_id, organization_id, row["id"]),
108
+ )
71
109
 
72
110
  # ── writes ────────────────────────────────────────────────────────────
73
111
  def append(self, item: Dict[str, Any]) -> Dict[str, Any]:
74
112
  """Persist one chat item (the legacy chat_history.json entry shape)."""
75
- known = {"role", "content", "timestamp", "user_email", "user_nickname", "source", "conversation_id"}
113
+ known = {
114
+ "role",
115
+ "content",
116
+ "timestamp",
117
+ "user_email",
118
+ "user_nickname",
119
+ "source",
120
+ "conversation_id",
121
+ "workspace_id",
122
+ "organization_id",
123
+ }
76
124
  extra = {k: v for k, v in item.items() if k not in known}
77
125
  with self._lock, self._connect() as conn:
78
126
  conn.execute(
79
127
  """
80
128
  INSERT OR IGNORE INTO conversation_messages
81
129
  (message_hash, conversation_id, role, content, user_email,
82
- user_nickname, source, timestamp, metadata_json)
83
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
130
+ user_nickname, source, timestamp, metadata_json, workspace_id,
131
+ organization_id)
132
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
84
133
  """,
85
134
  (
86
135
  _message_hash(item),
@@ -92,6 +141,8 @@ class ConversationStore:
92
141
  item.get("source"),
93
142
  str(item.get("timestamp") or ""),
94
143
  json.dumps(extra, ensure_ascii=False) if extra else "{}",
144
+ item.get("workspace_id"),
145
+ item.get("organization_id"),
95
146
  ),
96
147
  )
97
148
  return item
@@ -122,8 +173,9 @@ class ConversationStore:
122
173
  """
123
174
  INSERT OR IGNORE INTO conversation_messages
124
175
  (message_hash, conversation_id, role, content, user_email,
125
- user_nickname, source, timestamp, metadata_json)
126
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, '{}')
176
+ user_nickname, source, timestamp, metadata_json, workspace_id,
177
+ organization_id)
178
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, '{}', ?, ?)
127
179
  """,
128
180
  (
129
181
  _message_hash(item),
@@ -134,6 +186,8 @@ class ConversationStore:
134
186
  item.get("user_nickname"),
135
187
  item.get("source"),
136
188
  str(item.get("timestamp") or ""),
189
+ item.get("workspace_id"),
190
+ item.get("organization_id"),
137
191
  ),
138
192
  )
139
193
  imported += cur.rowcount if cur.rowcount > 0 else 0
@@ -149,7 +203,7 @@ class ConversationStore:
149
203
  "content": row["content"],
150
204
  "timestamp": row["timestamp"],
151
205
  }
152
- for key in ("user_email", "user_nickname", "source", "conversation_id"):
206
+ for key in ("user_email", "user_nickname", "source", "conversation_id", "workspace_id", "organization_id"):
153
207
  if row[key]:
154
208
  item[key] = row[key]
155
209
  try:
@@ -159,13 +213,62 @@ class ConversationStore:
159
213
  item.update(extra)
160
214
  return item
161
215
 
162
- def history(self, *, conversation_id: Optional[str] = None, limit: Optional[int] = None) -> List[Dict[str, Any]]:
216
+ @staticmethod
217
+ def _scope_sql(
218
+ *,
219
+ user_email: Optional[str] = None,
220
+ allowed_workspaces: Optional[Any] = None,
221
+ include_legacy_global: bool = True,
222
+ ) -> tuple[str, List[Any]]:
223
+ clauses: List[str] = []
224
+ params: List[Any] = []
225
+ if user_email:
226
+ if include_legacy_global:
227
+ clauses.append("(user_email = ? OR user_email IS NULL OR user_email = '')")
228
+ else:
229
+ clauses.append("user_email = ?")
230
+ params.append(user_email)
231
+ if allowed_workspaces is not None:
232
+ allowed = [str(item) for item in allowed_workspaces if item]
233
+ if allowed:
234
+ placeholders = ",".join("?" for _ in allowed)
235
+ if include_legacy_global:
236
+ clauses.append(f"(workspace_id IN ({placeholders}) OR workspace_id IS NULL OR workspace_id = '')")
237
+ else:
238
+ clauses.append(f"workspace_id IN ({placeholders})")
239
+ params.extend(allowed)
240
+ elif include_legacy_global:
241
+ clauses.append("(workspace_id IS NULL OR workspace_id = '')")
242
+ else:
243
+ clauses.append("1=0")
244
+ return " AND ".join(clauses), params
245
+
246
+ def history(
247
+ self,
248
+ *,
249
+ conversation_id: Optional[str] = None,
250
+ limit: Optional[int] = None,
251
+ user_email: Optional[str] = None,
252
+ allowed_workspaces: Optional[Any] = None,
253
+ include_legacy_global: bool = True,
254
+ ) -> List[Dict[str, Any]]:
163
255
  """Chronological items; the unbounded successor of get_history()."""
164
256
  query = "SELECT * FROM conversation_messages"
165
257
  params: List[Any] = []
258
+ where: List[str] = []
166
259
  if conversation_id is not None:
167
- query += " WHERE conversation_id IS ?" if conversation_id == "" else " WHERE conversation_id = ?"
260
+ where.append("conversation_id IS ?" if conversation_id == "" else "conversation_id = ?")
168
261
  params.append(None if conversation_id == "" else conversation_id)
262
+ scope_sql, scope_params = self._scope_sql(
263
+ user_email=user_email,
264
+ allowed_workspaces=allowed_workspaces,
265
+ include_legacy_global=include_legacy_global,
266
+ )
267
+ if scope_sql:
268
+ where.append(scope_sql)
269
+ params.extend(scope_params)
270
+ if where:
271
+ query += " WHERE " + " AND ".join(where)
169
272
  query += " ORDER BY id ASC"
170
273
  if limit is not None:
171
274
  query += " LIMIT ?"
@@ -185,25 +288,51 @@ class ConversationStore:
185
288
  return 0
186
289
 
187
290
  # ── clears (legacy semantics preserved) ───────────────────────────────
188
- def clear_all(self, keep_last: int = 0) -> Dict[str, Any]:
291
+ def clear_all(
292
+ self,
293
+ keep_last: int = 0,
294
+ *,
295
+ user_email: Optional[str] = None,
296
+ allowed_workspaces: Optional[Any] = None,
297
+ include_legacy_global: bool = True,
298
+ ) -> Dict[str, Any]:
189
299
  keep_last = max(0, min(int(keep_last or 0), 20))
190
300
  with self._lock, self._connect() as conn:
191
301
  total = conn.execute("SELECT COUNT(*) FROM conversation_messages").fetchone()[0]
302
+ scope_sql, scope_params = self._scope_sql(
303
+ user_email=user_email,
304
+ allowed_workspaces=allowed_workspaces,
305
+ include_legacy_global=include_legacy_global,
306
+ )
307
+ scope_where = f" WHERE {scope_sql}" if scope_sql else ""
192
308
  if keep_last:
193
309
  conn.execute(
194
- """
195
- DELETE FROM conversation_messages WHERE id NOT IN (
196
- SELECT id FROM conversation_messages ORDER BY id DESC LIMIT ?
310
+ f"""
311
+ DELETE FROM conversation_messages
312
+ WHERE id IN (
313
+ SELECT id FROM conversation_messages{scope_where}
314
+ )
315
+ AND id NOT IN (
316
+ SELECT id FROM conversation_messages{scope_where}
317
+ ORDER BY id DESC LIMIT ?
197
318
  )
198
319
  """,
199
- (keep_last,),
320
+ (*scope_params, *scope_params, keep_last),
200
321
  )
201
322
  else:
202
- conn.execute("DELETE FROM conversation_messages")
323
+ conn.execute(f"DELETE FROM conversation_messages{scope_where}", scope_params)
203
324
  kept = conn.execute("SELECT COUNT(*) FROM conversation_messages").fetchone()[0]
204
325
  return {"status": "cleared", "removed": max(0, total - kept), "kept": kept}
205
326
 
206
- def clear_conversation(self, conversation_id: str, started_at: Optional[str] = None) -> Dict[str, Any]:
327
+ def clear_conversation(
328
+ self,
329
+ conversation_id: str,
330
+ started_at: Optional[str] = None,
331
+ *,
332
+ user_email: Optional[str] = None,
333
+ allowed_workspaces: Optional[Any] = None,
334
+ include_legacy_global: bool = True,
335
+ ) -> Dict[str, Any]:
207
336
  """Remove one conversation.
208
337
 
209
338
  ``legacy-previous-history`` targets unattributed messages; when
@@ -212,17 +341,23 @@ class ConversationStore:
212
341
  """
213
342
  with self._lock, self._connect() as conn:
214
343
  total = conn.execute("SELECT COUNT(*) FROM conversation_messages").fetchone()[0]
344
+ scope_sql, scope_params = self._scope_sql(
345
+ user_email=user_email,
346
+ allowed_workspaces=allowed_workspaces,
347
+ include_legacy_global=include_legacy_global,
348
+ )
349
+ scoped = f" AND {scope_sql}" if scope_sql else ""
215
350
  if conversation_id == "legacy-previous-history":
216
- conn.execute("DELETE FROM conversation_messages WHERE conversation_id IS NULL")
351
+ conn.execute(f"DELETE FROM conversation_messages WHERE conversation_id IS NULL{scoped}", scope_params)
217
352
  else:
218
353
  conn.execute(
219
- "DELETE FROM conversation_messages WHERE conversation_id = ?",
220
- (conversation_id,),
354
+ f"DELETE FROM conversation_messages WHERE conversation_id = ?{scoped}",
355
+ (conversation_id, *scope_params),
221
356
  )
222
357
  if started_at:
223
358
  conn.execute(
224
- "DELETE FROM conversation_messages WHERE conversation_id IS NULL AND timestamp >= ?",
225
- (str(started_at),),
359
+ f"DELETE FROM conversation_messages WHERE conversation_id IS NULL AND timestamp >= ?{scoped}",
360
+ (str(started_at), *scope_params),
226
361
  )
227
362
  kept = conn.execute("SELECT COUNT(*) FROM conversation_messages").fetchone()[0]
228
363
  return {
@@ -40,7 +40,7 @@ class BrainCore:
40
40
  )
41
41
 
42
42
  from .conversations import ConversationStore
43
- from .store import KnowledgeGraphStore
43
+ from .graph.store import KnowledgeGraphStore
44
44
 
45
45
  self.knowledge = KnowledgeGraphStore(
46
46
  self.db_path,
@@ -34,6 +34,8 @@ except Exception: # pragma: no cover - v2 schema is optional at import time
34
34
  _exec_script = None # type: ignore[assignment]
35
35
 
36
36
  from ..embeddings import LocalEmbeddingModel
37
+ from .json_utils import _json, _safe_loads
38
+ from .runtime import get_llm_router, set_llm_router
37
39
 
38
40
  # Default read source for the graph queries: v2 reconstruction views.
39
41
  # Override with LATTICEAI_KG_READ_V2=0 to fall back to the legacy tables.
@@ -48,14 +50,6 @@ _KG_DB_FORMAT_VERSION = 4
48
50
  _KG_DB_FORMAT_KEY = "db_format_version"
49
51
  _V2_WRITE_MASTER_KEY = "v2_write_mastered_at"
50
52
 
51
- _llm_router_ref = None
52
-
53
-
54
- def set_llm_router(router_instance):
55
- global _llm_router_ref
56
- _llm_router_ref = router_instance
57
-
58
-
59
53
  GRAPH_SCHEMA_VERSION = 1
60
54
 
61
55
  LOCAL_TEXT_EXTENSIONS = {".txt", ".md"}
@@ -286,24 +280,6 @@ def _recency_score(
286
280
  return math.exp(-decay * age_days)
287
281
 
288
282
 
289
- def _json(data: Optional[Dict[str, Any]]) -> str:
290
- return json.dumps(data or {}, ensure_ascii=False, sort_keys=True)
291
-
292
-
293
- def _safe_loads(raw: Optional[str]) -> Dict[str, Any]:
294
- """Tolerantly parse a metadata_json column — returns {} on corrupt rows."""
295
- if not raw:
296
- return {}
297
- try:
298
- value = json.loads(raw)
299
- return value if isinstance(value, dict) else {}
300
- except (json.JSONDecodeError, TypeError) as e:
301
- logging.warning(
302
- "knowledge_graph: corrupt metadata_json (%s) — using empty dict", e
303
- )
304
- return {}
305
-
306
-
307
283
  def _slug(text: str, max_len: int = 96) -> str:
308
284
  value = re.sub(r"\s+", " ", str(text or "")).strip().lower()
309
285
  value = re.sub(r"[^0-9a-zA-Z가-힣._:@/-]+", "-", value).strip("-")
@@ -586,9 +562,10 @@ ENABLE_LLM_EXTRACTION = os.getenv("LATTICEAI_LLM_EXTRACTION", "true").lower() in
586
562
 
587
563
 
588
564
  def _llm_extract_concepts(text: str, limit: int = 12) -> Optional[List[str]]:
589
- if not ENABLE_LLM_EXTRACTION or not _llm_router_ref:
565
+ router = get_llm_router()
566
+ if not ENABLE_LLM_EXTRACTION or not router:
590
567
  return None
591
- if not _llm_router_ref.current_model_id:
568
+ if not router.current_model_id:
592
569
  return None
593
570
  prompt = _LLM_EXTRACT_CONCEPT_PROMPT.format(text=text[:3000], limit=limit)
594
571
  try:
@@ -599,12 +576,12 @@ def _llm_extract_concepts(text: str, limit: int = 12) -> Optional[List[str]]:
599
576
  with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
600
577
  future = pool.submit(
601
578
  asyncio.run,
602
- _llm_router_ref.generate(prompt, max_tokens=1024, temperature=0.1),
579
+ router.generate(prompt, max_tokens=1024, temperature=0.1),
603
580
  )
604
581
  raw = future.result(timeout=30)
605
582
  else:
606
583
  raw = asyncio.run(
607
- _llm_router_ref.generate(prompt, max_tokens=1024, temperature=0.1)
584
+ router.generate(prompt, max_tokens=1024, temperature=0.1)
608
585
  )
609
586
  raw = raw.strip()
610
587
  if raw.startswith("```"):
@@ -627,9 +604,10 @@ def _llm_extract_concepts(text: str, limit: int = 12) -> Optional[List[str]]:
627
604
  def _llm_extract_triples(
628
605
  text: str, concepts: List[str], limit: int = 20
629
606
  ) -> Optional[List[Dict[str, str]]]:
630
- if not ENABLE_LLM_EXTRACTION or not _llm_router_ref:
607
+ router = get_llm_router()
608
+ if not ENABLE_LLM_EXTRACTION or not router:
631
609
  return None
632
- if not _llm_router_ref.current_model_id:
610
+ if not router.current_model_id:
633
611
  return None
634
612
  prompt = _LLM_EXTRACT_TRIPLE_PROMPT.format(
635
613
  text=text[:3000],
@@ -644,12 +622,12 @@ def _llm_extract_triples(
644
622
  with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
645
623
  future = pool.submit(
646
624
  asyncio.run,
647
- _llm_router_ref.generate(prompt, max_tokens=2048, temperature=0.1),
625
+ router.generate(prompt, max_tokens=2048, temperature=0.1),
648
626
  )
649
627
  raw = future.result(timeout=30)
650
628
  else:
651
629
  raw = asyncio.run(
652
- _llm_router_ref.generate(prompt, max_tokens=2048, temperature=0.1)
630
+ router.generate(prompt, max_tokens=2048, temperature=0.1)
653
631
  )
654
632
  raw = raw.strip()
655
633
  if raw.startswith("```"):