ltcai 11.9.0 → 12.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 (127) hide show
  1. package/README.md +80 -59
  2. package/docs/CHANGELOG.md +92 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +267 -119
  5. package/docs/ENTERPRISE.md +1 -1
  6. package/docs/MULTI_AGENT_RUNTIME.md +4 -4
  7. package/docs/ONBOARDING.md +13 -3
  8. package/docs/OPERATIONS.md +13 -4
  9. package/docs/REALTIME_COLLABORATION.md +1 -1
  10. package/docs/ROADMAP.md +113 -0
  11. package/docs/TRUST_MODEL.md +16 -2
  12. package/docs/WHY_LATTICE.md +8 -2
  13. package/docs/WORKFLOW_DESIGNER.md +2 -2
  14. package/docs/kg-schema.md +51 -5
  15. package/docs/mcp-tools.md +17 -0
  16. package/lattice_brain/__init__.py +1 -1
  17. package/lattice_brain/graph/_kg_common/extraction.py +459 -105
  18. package/lattice_brain/graph/_kg_common/normalize.py +305 -0
  19. package/lattice_brain/graph/_kg_common/patterns.py +275 -0
  20. package/lattice_brain/graph/_kg_common/relations.py +12 -3
  21. package/lattice_brain/graph/_kg_common/sections.py +107 -0
  22. package/lattice_brain/graph/_kg_constants.py +7 -0
  23. package/latticeai/__init__.py +1 -1
  24. package/latticeai/api/agent_worker_seam.py +32 -0
  25. package/latticeai/api/worker_compute.py +78 -3
  26. package/latticeai/core/embedding_providers/__init__.py +16 -0
  27. package/latticeai/core/embedding_providers/autodetect.py +302 -0
  28. package/latticeai/core/embedding_providers/base.py +25 -0
  29. package/latticeai/core/embedding_providers/profiles.py +44 -0
  30. package/latticeai/core/embedding_providers/text.py +74 -8
  31. package/latticeai/core/vector_index/__init__.py +61 -0
  32. package/latticeai/core/vector_index/hnsw.py +383 -0
  33. package/latticeai/core/vector_index/sidecar.py +329 -0
  34. package/latticeai/models/router/generation.py +101 -22
  35. package/latticeai/models/router/loading.py +109 -4
  36. package/latticeai/runtime/brain_runtime.py +43 -9
  37. package/latticeai/runtime/build_phases/worker_profile.py +13 -4
  38. package/latticeai/services/architecture_readiness.py +2 -2
  39. package/latticeai/services/product_readiness.py +10 -5
  40. package/latticeai/services/search_service.py +7 -0
  41. package/latticeai/tools/__init__.py +6 -1
  42. package/latticeai/tools/documents.py +12 -0
  43. package/latticeai/tools/markup.py +152 -0
  44. package/package.json +2 -1
  45. package/scripts/check_current_release_docs.mjs +1 -1
  46. package/scripts/compose_openapi.py +2 -0
  47. package/scripts/openapi_route_families.json +7 -3
  48. package/scripts/publish_release.mjs +157 -0
  49. package/scripts/release_screen_claims.json +12 -0
  50. package/src-tauri/Cargo.lock +44 -10
  51. package/src-tauri/Cargo.toml +1 -1
  52. package/src-tauri/tauri.conf.json +1 -1
  53. package/static/app/asset-manifest.json +47 -41
  54. package/static/app/assets/Act-Cf1L2709.js +2 -0
  55. package/static/app/assets/AdminConsole-DPAbLTYV.js +1 -0
  56. package/static/app/assets/Brain-DqamGrj-.js +2 -0
  57. package/static/app/assets/BrainHome-MHe2_RYs.js +2 -0
  58. package/static/app/assets/BrainSignals-CQPPfyyH.js +1 -0
  59. package/static/app/assets/Capture-DGdIH_Zc.js +1 -0
  60. package/static/app/assets/Chronicle-C-UlCJoJ.js +1 -0
  61. package/static/app/assets/CommandPalette-WNT4EqUX.js +1 -0
  62. package/static/app/assets/DigitalBrainExplorer-CEBH5Cwc.js +321 -0
  63. package/static/app/assets/Library-C6xd1dlf.js +1 -0
  64. package/static/app/assets/LivingBrain-BEk-0ohw.js +1 -0
  65. package/static/app/assets/ProductFlow-CZLm5iXh.js +1 -0
  66. package/static/app/assets/QueryClientProvider-B3OjqSyJ.js +1 -0
  67. package/static/app/assets/ReviewCard-CEHG6evf.js +3 -0
  68. package/static/app/assets/RunsListPanel-CLtEJSRW.js +1 -0
  69. package/static/app/assets/System-CAxwBUXw.js +1 -0
  70. package/static/app/assets/WorkflowGraph-Dj10RuGE.js +1 -0
  71. package/static/app/assets/WorkflowsPanel-Kyeh_LIT.js +2 -0
  72. package/static/app/assets/actHelpers-CtSmK9Dw.js +1 -0
  73. package/static/app/assets/arrow-left-CRl5EO4D.js +1 -0
  74. package/static/app/assets/{bot-Bc3Q27YR.js → bot-DhUGRel2.js} +1 -1
  75. package/static/app/assets/brain-CLkhHsHF.js +1 -0
  76. package/static/app/assets/button-CmaEqG1T.js +1 -0
  77. package/static/app/assets/circle-check-CFgejkOS.js +1 -0
  78. package/static/app/assets/{circle-pause-BGMiV8UU.js → circle-pause-l96izbxj.js} +1 -1
  79. package/static/app/assets/{circle-play-DoanLHnd.js → circle-play-CrZa25_q.js} +1 -1
  80. package/static/app/assets/{cpu-DwzNf82m.js → cpu-BaXudqwl.js} +1 -1
  81. package/static/app/assets/{download-Ddw49yCV.js → download-hCVFPiyc.js} +1 -1
  82. package/static/app/assets/{folder-open-Brd6Kvto.js → folder-open-CHL82Yp7.js} +1 -1
  83. package/static/app/assets/{hard-drive-Bu-DTJdB.js → hard-drive-DDzET7lk.js} +1 -1
  84. package/static/app/assets/{index-CGdg_aq9.css → index-CB93CZWW.css} +1 -1
  85. package/static/app/assets/index-D2H-wSl6.js +13 -0
  86. package/static/app/assets/input-Df1CAY_I.js +1 -0
  87. package/static/app/assets/jsx-runtime-bzQ4Vb5N.js +1 -0
  88. package/static/app/assets/{link-2-DZ4OA5tJ.js → link-2-xNnTIX1_.js} +1 -1
  89. package/static/app/assets/{permissionCopy-D9TR0F8b.js → permissionCopy-D3aWHco-.js} +1 -1
  90. package/static/app/assets/primitives-BioD2slS.js +1 -0
  91. package/static/app/assets/search-BzBw8YcW.js +1 -0
  92. package/static/app/assets/{share-2-BC5FirFv.js → share-2-FkzGf8Df.js} +1 -1
  93. package/static/app/assets/{shield-alert-DUbR2W2s.js → shield-alert-B3dwzik4.js} +1 -1
  94. package/static/app/assets/sourceMeta-DQSY_tah.js +1 -0
  95. package/static/app/assets/textarea-P8o6pvOP.js +1 -0
  96. package/static/app/assets/useFocusTrap-hswOIkXE.js +1 -0
  97. package/static/app/assets/useMutation-OJLrYSRA.js +1 -0
  98. package/static/app/assets/workspace-BCuk3Ku9.js +1 -0
  99. package/static/app/index.html +4 -4
  100. package/static/sw.js +1 -1
  101. package/static/app/assets/Act-B4WT81kh.js +0 -1
  102. package/static/app/assets/AdminConsole--Wf71m-o.js +0 -1
  103. package/static/app/assets/Brain-CtYaa26c.js +0 -321
  104. package/static/app/assets/BrainHome-Be2VPJEc.js +0 -2
  105. package/static/app/assets/BrainSignals-C0__xgpG.js +0 -1
  106. package/static/app/assets/Capture-DdNi5Peb.js +0 -1
  107. package/static/app/assets/Chronicle-B3hNveeI.js +0 -1
  108. package/static/app/assets/CommandPalette-CIsnSsFL.js +0 -1
  109. package/static/app/assets/Library-Bl5XClFV.js +0 -1
  110. package/static/app/assets/LivingBrain-DjkTB_gh.js +0 -1
  111. package/static/app/assets/ProductFlow-DCUNRNHs.js +0 -1
  112. package/static/app/assets/ReviewCard-CD3yWvUB.js +0 -3
  113. package/static/app/assets/System-BJ6jQ_SL.js +0 -1
  114. package/static/app/assets/arrow-left-6_28Z0qH.js +0 -1
  115. package/static/app/assets/brain-B9BDrMTe.js +0 -1
  116. package/static/app/assets/button-D6JcpYcf.js +0 -1
  117. package/static/app/assets/circle-check-BFu9lD-3.js +0 -1
  118. package/static/app/assets/index-CWKRRsLW.js +0 -10
  119. package/static/app/assets/input-CEqsxtil.js +0 -1
  120. package/static/app/assets/primitives-DORg7Z_7.js +0 -1
  121. package/static/app/assets/search-0NQ21wXe.js +0 -1
  122. package/static/app/assets/textarea-jtQcRSXo.js +0 -1
  123. package/static/app/assets/useFocusTrap-HRemcWId.js +0 -1
  124. package/static/app/assets/useMutation-DqlFE-Bw.js +0 -1
  125. package/static/app/assets/useQuery-BizqBNGw.js +0 -1
  126. package/static/app/assets/utils-WgW4V69R.js +0 -4
  127. package/static/app/assets/workspace-DSek3jCY.js +0 -1
@@ -13,7 +13,16 @@ was requested.
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
- from typing import Any, Callable, Mapping
16
+ from typing import Any, Callable, Mapping, Optional, Tuple
17
+
18
+ from latticeai.core.embedding_providers.autodetect import (
19
+ AUTO_PROVIDER,
20
+ Detection,
21
+ detect_embedder,
22
+ resolve_auto_provider,
23
+ )
24
+
25
+ _FALLBACK_NAMES = {"", "hash", "local", "fallback"}
17
26
 
18
27
 
19
28
  def build_embedder_runtime(
@@ -21,16 +30,26 @@ def build_embedder_runtime(
21
30
  config: Any,
22
31
  profile: Mapping[str, Any],
23
32
  resolve_embedder: Callable[..., Any],
33
+ detect: Optional[Callable[..., Detection]] = None,
24
34
  ) -> Any:
25
- """Resolve the configured embedding provider once."""
35
+ """Resolve the configured embedding provider once.
26
36
 
27
- provider = config.embedding_provider
28
- model = config.embedding_model or str(profile.get("model") or "")
29
- dim = config.embedding_dim or int(profile.get("dimensions") or 0)
30
- if config.embedding_profile and provider in {"", "hash", "local", "fallback"}:
31
- provider = str(profile.get("provider") or provider)
37
+ The resolution order is the product's, unchanged, with one addition at the
38
+ end: detection. What is on this machine is *always* looked up (so
39
+ ``GET /api/embeddings/status`` can say "a real embedder is right there"),
40
+ and it changes the resolution only when the operator asked for it with
41
+ ``LATTICEAI_EMBEDDING_PROVIDER=auto``. The finding rides back on the
42
+ resolved object as ``detected`` — an added attribute, so every existing
43
+ reader of ``ResolvedEmbedder`` is untouched.
44
+ """
45
+
46
+ provider, model, dim = _configured(config, profile)
47
+ detection = (detect or detect_embedder)(
48
+ configured_provider=provider, configured_model=model
49
+ )
50
+ provider, model, dim = resolve_auto_provider(provider, model, dim, detection)
32
51
 
33
- return resolve_embedder(
52
+ resolved = resolve_embedder(
34
53
  provider,
35
54
  model=model,
36
55
  base_url=config.embedding_base_url,
@@ -38,8 +57,23 @@ def build_embedder_runtime(
38
57
  dim=dim,
39
58
  timeout=config.embedding_timeout,
40
59
  extra={"target": config.embedding_custom_target},
41
- probe=provider not in {"", "hash", "local", "fallback"},
60
+ probe=provider not in _FALLBACK_NAMES,
42
61
  )
62
+ try:
63
+ resolved.detected = detection
64
+ except AttributeError: # pragma: no cover - a stand-in without __dict__
65
+ pass
66
+ return resolved
67
+
68
+
69
+ def _configured(config: Any, profile: Mapping[str, Any]) -> Tuple[str, str, int]:
70
+ """``(provider, model, dim)`` from configuration and the named profile."""
71
+ provider = config.embedding_provider
72
+ model = config.embedding_model or str(profile.get("model") or "")
73
+ dim = config.embedding_dim or int(profile.get("dimensions") or 0)
74
+ if config.embedding_profile and provider in _FALLBACK_NAMES | {AUTO_PROVIDER}:
75
+ provider = str(profile.get("provider") or provider)
76
+ return provider, model, dim
43
77
 
44
78
 
45
79
  __all__ = ["build_embedder_runtime"]
@@ -86,10 +86,11 @@ WORKER_SEAM_ROUTES: Tuple[Tuple[str, str], ...] = (
86
86
  #: The pure-compute seams of Wave 2.5 §W2 (``latticeai/api/worker_compute.py``).
87
87
  #: Kept apart from :data:`WORKER_SEAM_ROUTES` because they age in the opposite
88
88
  #: direction: the state seams above are retired once §W3 lands the native write
89
- #: engine, while these eight are what the worker is *for* once Rust owns every
90
- #: write. Mounted here and nowhere else. ``/worker/multimodal/describe`` was the
91
- #: ninth until v11.8.0 — it described an image for a native image ingest that
92
- #: was never built, and no caller ever appeared.
89
+ #: engine, while these nine are what the worker is *for* once Rust owns every
90
+ #: write. Mounted here and nowhere else. ``/worker/multimodal/describe`` was
91
+ #: here until v11.8.0 — it described an image for a native image ingest that
92
+ #: was never built. ``/worker/vector/query`` is the HNSW sidecar door
93
+ #: (v12.0.0): approximate candidates, exact rescore in lattice-retrieval.
93
94
  WORKER_COMPUTE_ROUTES: Tuple[Tuple[str, str], ...] = (
94
95
  ("POST", "/worker/embed"),
95
96
  ("POST", "/worker/parse"),
@@ -99,6 +100,7 @@ WORKER_COMPUTE_ROUTES: Tuple[Tuple[str, str], ...] = (
99
100
  ("POST", "/worker/render/pdf"),
100
101
  ("POST", "/worker/asr"),
101
102
  ("POST", "/worker/extract"),
103
+ ("POST", "/worker/vector/query"),
102
104
  )
103
105
 
104
106
 
@@ -147,12 +149,19 @@ def phase_worker_routes(ctx: Any) -> None:
147
149
  # ``getattr`` because a worker built without a Brain still boots — each seam
148
150
  # reports the absence rather than failing construction.
149
151
  ports = getattr(ctx, "MULTIMODAL_PORTS", None)
152
+ data_dir = getattr(ctx, "DATA_DIR", None)
153
+ db_path = None
154
+ if data_dir is not None:
155
+ from pathlib import Path
156
+
157
+ db_path = Path(data_dir) / "knowledge_graph.sqlite"
150
158
  app.include_router(
151
159
  create_worker_compute_router(
152
160
  embedder=getattr(ctx, "EMBEDDER", None),
153
161
  transcriber=getattr(ports, "transcriber", None),
154
162
  require_user=ctx.require_user,
155
163
  enforce_rate_limit=ctx.enforce_rate_limit,
164
+ db_path=db_path,
156
165
  )
157
166
  )
158
167
  apply_worker_route_filter(app)
@@ -19,7 +19,7 @@ from dataclasses import dataclass
19
19
  from pathlib import Path
20
20
  from typing import Any, Dict, List
21
21
 
22
- ARCHITECTURE_VERSION_TARGET = "11.9.0"
22
+ ARCHITECTURE_VERSION_TARGET = "12.0.0"
23
23
 
24
24
  PREFERRED_REFACTORING_ORDER = [
25
25
  "agent-runtime",
@@ -177,7 +177,7 @@ def architecture_readiness(root: Path | None = None) -> Dict[str, Any]:
177
177
  and not agent_alias_forbidden
178
178
  ) else "incomplete",
179
179
  evidence=[
180
- "rust/lattice-agent/src/permission.rs block_reason_for_tool (native owner)",
180
+ "rust/lattice-agent/src/kernel/permission.rs block_reason_for_tool (native owner)",
181
181
  "latticeai.api.agent_worker_seam.create_agent_worker_seam_router",
182
182
  "tests/unit/test_agent_worker_seam.py",
183
183
  ],
@@ -13,6 +13,11 @@ generator whose subject is now native: they point at the cargo test or the
13
13
  committed golden that actually guards the behaviour. The assertions themselves
14
14
  were not deleted — an evidence path is repointed at the owner or it is a gate
15
15
  that has stopped meaning anything.
16
+
17
+ 12.0.0 regrouped the Rust crates into domain directories, so four needles that
18
+ named a top-level file in ``rust/lattice-agent/src/`` now name the same symbol
19
+ in ``src/kernel/``. The needle text is unchanged — only the path moved, and
20
+ each one was re-checked against the file it now names rather than assumed.
16
21
  """
17
22
 
18
23
  from __future__ import annotations
@@ -23,7 +28,7 @@ from typing import Any, Dict, List
23
28
 
24
29
  from latticeai.services.architecture_readiness import architecture_readiness
25
30
 
26
- PRODUCT_VERSION_TARGET = "11.9.0"
31
+ PRODUCT_VERSION_TARGET = "12.0.0"
27
32
 
28
33
 
29
34
  @dataclass(frozen=True)
@@ -74,7 +79,7 @@ PRODUCT_GATES: List[ProductGate] = [
74
79
  "latticeai/tools/documents.py::read_document",
75
80
  "latticeai/tools/filesystem.py::read_file",
76
81
  "latticeai/core/tool_governor.py::classify_tool_call",
77
- "rust/lattice-agent/src/permission.rs::pub fn block_reason_for_tool",
82
+ "rust/lattice-agent/src/kernel/permission.rs::pub fn block_reason_for_tool",
78
83
  "tests/unit/test_worker_compute.py::test_the_docx_bytes_open_as_a_document_with_the_blocks_that_were_sent",
79
84
  "tests/unit/test_tool_registry.py::test_execute_tool_uses_registry",
80
85
  ],
@@ -119,10 +124,10 @@ PRODUCT_GATES: List[ProductGate] = [
119
124
  f"docs/CHANGELOG.md::## [{PRODUCT_VERSION_TARGET}]",
120
125
  "FEATURE_STATUS.md",
121
126
  f"RELEASE_NOTES_v{PRODUCT_VERSION_TARGET}.md",
122
- "rust/lattice-agent/src/permission.rs::pub fn block_reason_for_tool",
123
- "rust/lattice-agent/src/lib.rs::pub mod agentloop",
127
+ "rust/lattice-agent/src/kernel/permission.rs::pub fn block_reason_for_tool",
128
+ "rust/lattice-agent/src/kernel/mod.rs::pub mod agentloop",
124
129
  "latticeai/api/agent_worker_seam.py::create_agent_worker_seam_router",
125
- "rust/lattice-agent/src/permission.rs::pub fn non_auto_plan_steps",
130
+ "rust/lattice-agent/src/kernel/permission.rs::pub fn non_auto_plan_steps",
126
131
  "latticeai/worker_app.py::create_worker_app",
127
132
  "latticeai/worker_app.py::def create_worker_app",
128
133
  "latticeai/services/architecture_readiness.py::lattice-architecture-contract/v1",
@@ -84,6 +84,13 @@ class SearchService:
84
84
  "state": state,
85
85
  "fell_back": fell_back,
86
86
  "health": health,
87
+ # v12.0.0, additive: is what this worker embeds with *semantic*, and
88
+ # if not, is a real embedder already sitting on the disk? The hash
89
+ # fallback is honest about being a fallback but said nothing about
90
+ # what it could have been, so nobody ever switched.
91
+ "semantic": grade != "fallback",
92
+ "detected": dict(resolved.get("detected") or {}),
93
+ "identity": f"{meta.get('model_id')}:{meta.get('dim')}",
87
94
  "detail": resolved.get("detail", ""),
88
95
  # Index completeness is native (lattice-jobs). Present and empty:
89
96
  # a reader learns this answer does not speak for the index, which
@@ -116,7 +116,12 @@ _CJK_FONT_CANDIDATES = [
116
116
  "/usr/share/fonts/truetype/nanum/NanumGothic.ttf",
117
117
  ]
118
118
 
119
- _SUPPORTED_READ_EXTENSIONS = {".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".csv"}
119
+ # `.html`/`.htm` joined in v12.0.0: they were classified as *code* and read
120
+ # verbatim, so the graph stored markup instead of what the page says. See
121
+ # `latticeai.tools.markup.html_to_text`.
122
+ _SUPPORTED_READ_EXTENSIONS = {
123
+ ".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".csv", ".html", ".htm",
124
+ }
120
125
  DOCUMENT_MAX_READ_BYTES = 10_000_000 # 10 MB
121
126
 
122
127
 
@@ -128,6 +128,18 @@ def read_document(path: str) -> Dict[str, Any]:
128
128
  except Exception as exc:
129
129
  raise ToolError(f"PPTX 읽기 실패: {exc}") from exc
130
130
 
131
+ elif ext in {".html", ".htm"}:
132
+ # A web page read verbatim is markup, not text: the graph filled up
133
+ # with `<div class=…>` and the contents of every `<script>`. The
134
+ # conversion keeps the page's headings as `#` lines so a fact
135
+ # extracted from it can still say which section it came from.
136
+ try:
137
+ from latticeai.tools.markup import html_to_text
138
+
139
+ text = html_to_text(target.read_text(encoding="utf-8", errors="replace"))
140
+ except Exception as exc:
141
+ raise ToolError(f"HTML 읽기 실패: {exc}") from exc
142
+
131
143
  elif ext in {".txt", ".md", ".csv"}:
132
144
  try:
133
145
  text = target.read_text(encoding="utf-8", errors="replace")
@@ -0,0 +1,152 @@
1
+ """HTML → readable text, with the document's own structure kept.
2
+
3
+ Until v12.0.0 an ``.html`` file was classified as *code* and read verbatim, so
4
+ what reached the graph was `<div class="wrapper">` and the contents of every
5
+ `<script>` tag. Chunks were markup, concepts were attribute names, and a search
6
+ for a sentence on the page could not find the page.
7
+
8
+ The conversion is deliberately small and dependency-free (``html.parser`` and
9
+ ``html.unescape`` are stdlib; adding BeautifulSoup for this would be a new
10
+ runtime dependency for a hundred lines of tag handling):
11
+
12
+ * ``<script>``, ``<style>``, ``<noscript>``, ``<template>`` and everything in
13
+ ``<head>`` except ``<title>`` are dropped — they are not what the page says;
14
+ * ``<h1>``–``<h6>`` become markdown headings, so the section machinery in
15
+ :mod:`lattice_brain.graph._kg_common.sections` gives every extracted fact the
16
+ heading it lived under, exactly as it does for a ``.md`` file;
17
+ * ``<li>`` becomes ``- ``, table cells are tab-separated, and block elements
18
+ end a line, so paragraph and list boundaries survive into chunking;
19
+ * entities are unescaped once, at the end of each text run.
20
+
21
+ The output is plain text that happens to carry markdown headings. It is *not*
22
+ a markdown converter and does not try to be: bold, links and images are read
23
+ for their text, because a knowledge graph wants the sentence, not the styling.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ import re
30
+ from html import unescape
31
+ from html.parser import HTMLParser
32
+ from typing import List, Set
33
+
34
+ #: Elements whose *content* is never page text.
35
+ SKIPPED_ELEMENTS: Set[str] = {"script", "style", "noscript", "template", "svg"}
36
+ #: Elements that end the current line.
37
+ BLOCK_ELEMENTS: Set[str] = {
38
+ "p", "div", "section", "article", "header", "footer", "nav", "aside",
39
+ "main", "ul", "ol", "li", "table", "tr", "blockquote", "pre", "form",
40
+ "figure", "figcaption", "dl", "dt", "dd", "hr", "br", "h1", "h2", "h3",
41
+ "h4", "h5", "h6", "title",
42
+ }
43
+ #: Elements that separate cells with a tab rather than a newline.
44
+ CELL_ELEMENTS: Set[str] = {"td", "th"}
45
+
46
+ _HEADINGS = {f"h{level}": level for level in range(1, 7)}
47
+ _BLANK_RUN = re.compile(r"\n{3,}")
48
+ _TRAILING_SPACE = re.compile(r"[ \t]+\n")
49
+
50
+
51
+ class _TextExtractor(HTMLParser):
52
+ """Collect a page's readable text, marking its headings."""
53
+
54
+ def __init__(self) -> None:
55
+ super().__init__(convert_charrefs=False)
56
+ self.parts: List[str] = []
57
+ self._skip_depth = 0
58
+ self._heading: int = 0
59
+ self._in_head = False
60
+
61
+ # ── structure ────────────────────────────────────────────────────────
62
+ def handle_starttag(self, tag: str, attrs: object) -> None:
63
+ if tag in SKIPPED_ELEMENTS:
64
+ self._skip_depth += 1
65
+ return
66
+ if tag == "head":
67
+ self._in_head = True
68
+ return
69
+ if self._skip_depth:
70
+ return
71
+ level = _HEADINGS.get(tag)
72
+ # `<title>` is the page's name; treat it as the top heading so the
73
+ # section path of a page with no `<h1>` is still its own title.
74
+ if tag == "title":
75
+ level = 1
76
+ if level:
77
+ self._heading = level
78
+ self.parts.append("\n\n" + "#" * level + " ")
79
+ return
80
+ if tag == "li":
81
+ self.parts.append("\n- ")
82
+ elif tag in CELL_ELEMENTS:
83
+ self.parts.append("\t")
84
+ elif tag in BLOCK_ELEMENTS:
85
+ self.parts.append("\n")
86
+
87
+ def handle_endtag(self, tag: str) -> None:
88
+ if tag in SKIPPED_ELEMENTS:
89
+ self._skip_depth = max(0, self._skip_depth - 1)
90
+ return
91
+ if tag == "head":
92
+ self._in_head = False
93
+ return
94
+ if self._skip_depth:
95
+ return
96
+ if tag in _HEADINGS or tag == "title":
97
+ self._heading = 0
98
+ self.parts.append("\n\n")
99
+ elif tag == "li":
100
+ # `<li>` already opens its own line; closing one too would put a
101
+ # blank line between every bullet and turn a list into a page.
102
+ return
103
+ elif tag in BLOCK_ELEMENTS:
104
+ self.parts.append("\n")
105
+
106
+ # ── text ─────────────────────────────────────────────────────────────
107
+ def handle_data(self, data: str) -> None:
108
+ if self._skip_depth:
109
+ return
110
+ # Inside `<head>`, only `<title>` text is page content; `<meta>` and
111
+ # friends carry no data, but stray whitespace and CDATA do.
112
+ if self._in_head and not self._heading:
113
+ return
114
+ text = unescape(data)
115
+ if self._heading:
116
+ # A heading must stay on one line or it stops being a heading.
117
+ text = " ".join(text.split())
118
+ if text:
119
+ self.parts.append(text)
120
+ return
121
+ cleaned = re.sub(r"[ \t\r\f\v]+", " ", text.replace("\n", " "))
122
+ if cleaned.strip():
123
+ self.parts.append(cleaned)
124
+ elif cleaned:
125
+ self.parts.append(" ")
126
+
127
+ def handle_entityref(self, name: str) -> None:
128
+ self.handle_data(f"&{name};")
129
+
130
+ def handle_charref(self, name: str) -> None:
131
+ self.handle_data(f"&#{name};")
132
+
133
+
134
+ def html_to_text(markup: str) -> str:
135
+ """Readable text for ``markup``, with headings kept as ``#`` lines.
136
+
137
+ Never raises: a truncated or malformed page yields whatever text was
138
+ recoverable, because half a document in the graph beats a failed ingest.
139
+ """
140
+ extractor = _TextExtractor()
141
+ try:
142
+ extractor.feed(str(markup or ""))
143
+ extractor.close()
144
+ except Exception as exc: # pragma: no cover - html.parser is very tolerant
145
+ logging.debug("HTML parse stopped early; keeping what was read: %s", exc)
146
+ text = "".join(extractor.parts)
147
+ text = _TRAILING_SPACE.sub("\n", text)
148
+ text = _BLANK_RUN.sub("\n\n", text)
149
+ return "\n".join(line.rstrip() for line in text.split("\n")).strip()
150
+
151
+
152
+ __all__ = ["BLOCK_ELEMENTS", "CELL_ELEMENTS", "SKIPPED_ELEMENTS", "html_to_text"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ltcai",
3
- "version": "11.9.0",
3
+ "version": "12.0.0",
4
4
  "description": "Lattice AI — local-first Digital Brain that keeps your knowledge durable across any AI model.",
5
5
  "homepage": "https://github.com/TaeSooPark-PTS/LatticeAI#readme",
6
6
  "repository": {
@@ -57,6 +57,7 @@
57
57
  "release:artifacts": "node scripts/clean_release_artifacts.mjs $npm_package_version && npm run build:assets && npm run build:python && npm pack && npm run package:vsix && npm run desktop:tauri:build",
58
58
  "release:validate": "node scripts/run_python.mjs scripts/validate_release_artifacts.py $npm_package_version --require-vsix --require-tgz --require-dmg",
59
59
  "release:smoke": "node scripts/run_python.mjs scripts/release_smoke.py $npm_package_version",
60
+ "publish:release": "node scripts/publish_release.mjs",
60
61
  "publish:npm": "npm pack && npm publish ltcai-$npm_package_version.tgz --access public",
61
62
  "publish:pypi": "npm run build:python && node scripts/run_python.mjs -m twine upload --skip-existing dist/ltcai-$npm_package_version.tar.gz dist/ltcai-$npm_package_version-py3-none-any.whl",
62
63
  "publish:vscode": "cd vscode-extension && npm run package:vsix && npm run publish:vscode",
@@ -6,7 +6,7 @@ const root = process.cwd();
6
6
  const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
7
7
  const version = pkg.version;
8
8
  const releaseDir = `output/release/v${version}`;
9
- const releaseTheme = "Working Order";
9
+ const releaseTheme = "Open House";
10
10
  const title = `${version} — ${releaseTheme}`;
11
11
  const escapedVersion = version.replaceAll(".", "\\.");
12
12
 
@@ -122,6 +122,8 @@ INTERNAL_WORKER_PATHS = frozenset(
122
122
  # ``/worker/multimodal/describe`` was here until v11.8.0 deleted the
123
123
  # seam: it described an image for a native ingest nothing ever built.
124
124
  "/worker/extract",
125
+ # v12.0.0 — HNSW sidecar query. Same loopback-only reasoning.
126
+ "/worker/vector/query",
125
127
  }
126
128
  )
127
129
 
@@ -18,8 +18,8 @@
18
18
  "source": {
19
19
  "spec": "frontend/openapi.json",
20
20
  "spec_version": "11.8.0",
21
- "paths": 414,
22
- "operations": 456,
21
+ "paths": 415,
22
+ "operations": 457,
23
23
  "classification": "scout_routes.md (v11.6.0 route inventory) + docs/v11.6.0_ONE_DOOR_PLAN.md",
24
24
  "module_attribution": "FastAPI route.endpoint.__module__ of the app built by scripts/export_openapi.py"
25
25
  },
@@ -82,7 +82,7 @@
82
82
  "wp": "R8",
83
83
  "target": "lattice-platform / lattice-agent",
84
84
  "summary": "MCP, marketplace, plugin SDK, agent registry, multi-agent runtime and the direct tool surface.",
85
- "operations": 86
85
+ "operations": 87
86
86
  },
87
87
  "portability_network": {
88
88
  "wp": "R9",
@@ -1746,6 +1746,10 @@
1746
1746
  "module": "latticeai.api.marketplace",
1747
1747
  "family": "mcp_market"
1748
1748
  },
1749
+ "POST /mcp": {
1750
+ "module": "latticeai.api.mcp",
1751
+ "family": "mcp_market"
1752
+ },
1749
1753
  "POST /mcp/call": {
1750
1754
  "module": "latticeai.api.mcp",
1751
1755
  "family": "mcp_market"
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+ // One-command publish for a built release: npm, PyPI, VS Code Marketplace, OpenVSX.
3
+ //
4
+ // node scripts/publish_release.mjs # publish every target not yet at this version
5
+ // node scripts/publish_release.mjs --dry-run # report what would happen, publish nothing
6
+ //
7
+ // Run it from anywhere — the script cd's to the repo root itself. It publishes the
8
+ // artifacts `npm run release:artifacts` already built and never rebuilds them.
9
+ // Each registry is checked first, so re-running after a partial failure only
10
+ // retries what is actually missing.
11
+ //
12
+ // Credentials (never passed as argv — argv leaks into shell history and npm logs):
13
+ // npm — `npm login` session;每 publish마다 브라우저 2FA 승인이 뜬다.
14
+ // PyPI — ~/.pypirc, TWINE_USERNAME/TWINE_PASSWORD, or interactive prompt.
15
+ // Marketplace — VSCE_PAT environment variable.
16
+ // OpenVSX — OVSX_PAT environment variable.
17
+ import { spawnSync } from "node:child_process";
18
+ import { existsSync, readFileSync } from "node:fs";
19
+ import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
23
+ process.chdir(ROOT);
24
+
25
+ const DRY = process.argv.includes("--dry-run");
26
+ const pkg = JSON.parse(readFileSync(path.join(ROOT, "package.json"), "utf8"));
27
+ const V = pkg.version;
28
+ const PUBLISHER = JSON.parse(
29
+ readFileSync(path.join(ROOT, "vscode-extension", "package.json"), "utf8"),
30
+ ).publisher;
31
+
32
+ const TGZ = path.join(ROOT, `ltcai-${V}.tgz`);
33
+ const WHEEL = path.join(ROOT, "dist", `ltcai-${V}-py3-none-any.whl`);
34
+ const SDIST = path.join(ROOT, "dist", `ltcai-${V}.tar.gz`);
35
+ const VSIX = path.join(ROOT, "dist", `ltcai-${V}.vsix`);
36
+
37
+ function run(cmd, args, env = {}) {
38
+ const r = spawnSync(cmd, args, {
39
+ stdio: "inherit",
40
+ cwd: ROOT,
41
+ env: { ...process.env, ...env },
42
+ });
43
+ return r.status === 0;
44
+ }
45
+
46
+ async function json(url, init) {
47
+ try {
48
+ const res = await fetch(url, init);
49
+ if (!res.ok) return null;
50
+ return await res.json();
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ const checks = {
57
+ async npm() {
58
+ const d = await json(`https://registry.npmjs.org/ltcai/${V}`);
59
+ return d?.version === V;
60
+ },
61
+ async pypi() {
62
+ return (await json(`https://pypi.org/pypi/ltcai/${V}/json`)) !== null;
63
+ },
64
+ async marketplace() {
65
+ const d = await json(
66
+ "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery",
67
+ {
68
+ method: "POST",
69
+ headers: {
70
+ "Content-Type": "application/json",
71
+ Accept: "application/json;api-version=3.0-preview.1",
72
+ },
73
+ body: JSON.stringify({
74
+ filters: [{ criteria: [{ filterType: 7, value: `${PUBLISHER}.ltcai` }] }],
75
+ flags: 1,
76
+ }),
77
+ },
78
+ );
79
+ const ext = d?.results?.[0]?.extensions?.[0];
80
+ return ext?.versions?.some((x) => x.version === V) ?? false;
81
+ },
82
+ async openvsx() {
83
+ return (await json(`https://open-vsx.org/api/${PUBLISHER}/ltcai/${V}`)) !== null;
84
+ },
85
+ };
86
+
87
+ const targets = [
88
+ {
89
+ name: "npm",
90
+ artifacts: [TGZ],
91
+ credsMissing: () => null, // npm prompts its own browser 2FA
92
+ publish: () => run("npm", ["publish", TGZ]),
93
+ note: "브라우저 2FA 승인 창이 뜨면 승인해야 완료됩니다.",
94
+ },
95
+ {
96
+ name: "pypi",
97
+ artifacts: [SDIST, WHEEL],
98
+ credsMissing: () => null, // twine falls back to ~/.pypirc or an interactive prompt
99
+ publish: () =>
100
+ run("node", [
101
+ "scripts/run_python.mjs",
102
+ "-m",
103
+ "twine",
104
+ "upload",
105
+ "--skip-existing",
106
+ SDIST,
107
+ WHEEL,
108
+ ]),
109
+ },
110
+ {
111
+ name: "marketplace",
112
+ artifacts: [VSIX],
113
+ credsMissing: () => (process.env.VSCE_PAT ? null : "VSCE_PAT 환경변수가 필요합니다"),
114
+ publish: () => run("npx", ["--yes", "@vscode/vsce", "publish", "--packagePath", VSIX]),
115
+ note: "게시 후 마켓플레이스 검증에 몇 분 걸릴 수 있습니다.",
116
+ },
117
+ {
118
+ name: "openvsx",
119
+ artifacts: [VSIX],
120
+ credsMissing: () => (process.env.OVSX_PAT ? null : "OVSX_PAT 환경변수가 필요합니다"),
121
+ publish: () => run("npx", ["--yes", "ovsx", "publish", VSIX]),
122
+ },
123
+ ];
124
+
125
+ const results = [];
126
+ for (const t of targets) {
127
+ const already = await checks[t.name]();
128
+ if (already) {
129
+ results.push([t.name, "already-published"]);
130
+ continue;
131
+ }
132
+ const missing = t.artifacts.filter((a) => !existsSync(a));
133
+ if (missing.length) {
134
+ results.push([t.name, `artifact missing: ${missing.map((m) => path.basename(m)).join(", ")}`]);
135
+ continue;
136
+ }
137
+ const creds = t.credsMissing();
138
+ if (creds) {
139
+ results.push([t.name, `skipped: ${creds}`]);
140
+ continue;
141
+ }
142
+ if (DRY) {
143
+ results.push([t.name, "would publish"]);
144
+ continue;
145
+ }
146
+ if (t.note) console.log(`\n[${t.name}] ${t.note}`);
147
+ console.log(`[${t.name}] publishing v${V}…`);
148
+ results.push([t.name, t.publish() ? "published" : "FAILED"]);
149
+ }
150
+
151
+ console.log(`\n=== publish report — ltcai v${V} ===`);
152
+ for (const [name, status] of results) console.log(` ${name.padEnd(12)} ${status}`);
153
+
154
+ const bad = results.filter(
155
+ ([, s]) => s === "FAILED" || s.startsWith("artifact missing") || s.startsWith("skipped"),
156
+ );
157
+ process.exit(bad.length ? 1 : 0);
@@ -20,6 +20,18 @@
20
20
  "whole-image delta: naming where the change is makes the claim more",
21
21
  "specific, not less."
22
22
  ],
23
+ "12.0.0": {
24
+ "_why": [
25
+ "Open House brings component and resilience updates across the frontend:",
26
+ "FolderMemoryHealth gained the 「삭제된 파일 정리 (N)」 button + preview dialog,",
27
+ "error boundaries added around views, preview pattern extended, and code",
28
+ "splitting (lazy view chunks).",
29
+ "",
30
+ "Brain/Chat home is unchanged since 11.8.0. Screens not touched are claimed",
31
+ "unchanged. The prune button renders conditionally and preview is user-triggered,",
32
+ "so static baseline captures remain unchanged. Empty claim set on purpose."
33
+ ]
34
+ },
23
35
  "11.9.0": {
24
36
  "_why": [
25
37
  "Working Order is a backend-truth release. The product work is live",