ltcai 9.9.3 → 9.9.5
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 +39 -38
- package/docs/CHANGELOG.md +100 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/SURFACE_PARITY.md +53 -0
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/_kg_common.py +335 -0
- package/lattice_brain/graph/discovery_index.py +8 -1
- package/lattice_brain/graph/ingest.py +43 -9
- package/lattice_brain/graph/projection.py +221 -37
- package/lattice_brain/graph/rerank.py +160 -0
- package/lattice_brain/graph/retrieval.py +90 -455
- package/lattice_brain/graph/retrieval_policy.py +174 -0
- package/lattice_brain/graph/retrieval_reads.py +459 -0
- package/lattice_brain/graph/retrieval_vector.py +122 -2
- package/lattice_brain/graph/store.py +2 -0
- package/lattice_brain/portability.py +41 -12
- package/lattice_brain/quality.py +26 -6
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +6 -0
- package/latticeai/api/chat_agent_http.py +262 -52
- package/latticeai/api/chat_contracts.py +1 -1
- package/latticeai/api/chat_intents.py +37 -18
- package/latticeai/api/chat_stream.py +76 -2
- package/latticeai/api/knowledge_graph.py +35 -1
- package/latticeai/core/agent.py +420 -27
- package/latticeai/core/enterprise.py +5 -0
- package/latticeai/core/file_generation.py +130 -5
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/run_store.py +245 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/integrations/telegram_bot.py +82 -23
- package/latticeai/models/router.py +25 -12
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/command_center.py +90 -1
- package/latticeai/services/folder_watch.py +5 -0
- package/latticeai/services/funnel_metrics.py +9 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +43 -13
- package/latticeai/services/tool_dispatch.py +56 -0
- package/package.json +2 -1
- package/scripts/bench_agent_smoke.py +410 -0
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/funnel_soft_gate.py +192 -0
- package/scripts/run_sidecar_e2e.mjs +159 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +29 -28
- package/static/app/assets/Act-DmxfbqBp.js +1 -0
- package/static/app/assets/{Brain-DEY9jLVt.js → Brain-DK_zKCda.js} +2 -2
- package/static/app/assets/BrainHome-CCE_0hni.js +2 -0
- package/static/app/assets/BrainSignals-r_J68_zh.js +1 -0
- package/static/app/assets/{Capture-CVZ09QXi.js → Capture-CvxwzpJZ.js} +1 -1
- package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-CVLiFIQC.js} +1 -1
- package/static/app/assets/{Library-Bp0n-HlW.js → Library-4As9VUvy.js} +1 -1
- package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-BVWk58MW.js} +1 -1
- package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-B-Tz0Um4.js} +1 -1
- package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-Va3fPQuj.js} +1 -1
- package/static/app/assets/{System-ByQcmJW-.js → System-DSR2zKWd.js} +1 -1
- package/static/app/assets/{bot-BNDyZLR7.js → bot-VmP0kkeA.js} +1 -1
- package/static/app/assets/circle-pause-T9POo4qy.js +1 -0
- package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-Bz5iTD0p.js} +1 -1
- package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CJhjRtNq.js} +1 -1
- package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-B2K_22VI.js} +1 -1
- package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CjJtYqHf.js} +1 -1
- package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
- package/static/app/assets/{index-CHu7cgj3.js → index-CEu0Wqjl.js} +3 -3
- package/static/app/assets/{input-DVDI0YR3.js → input-CSi6OBJ9.js} +1 -1
- package/static/app/assets/{navigation-BddhEWA0.js → navigation-BPQqEQwr.js} +1 -1
- package/static/app/assets/{network-pYQt5oBu.js → network-Ck1nqc-v.js} +1 -1
- package/static/app/assets/{primitives-D7gCdEvS.js → primitives-B2f9N7Dh.js} +1 -1
- package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-yA_Y8lOB.js} +1 -1
- package/static/app/assets/{textarea-sqQmoBKL.js → textarea-C4wM0D-Z.js} +1 -1
- package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-CsnIhsDJ.js} +1 -1
- package/static/app/assets/utils-BqoznzGP.js +7 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-DmdruVKV.js +0 -1
- package/static/app/assets/BrainHome-CeNaxjP1.js +0 -2
- package/static/app/assets/BrainSignals-CStjIqYi.js +0 -1
- package/static/app/assets/utils-uQYKXNeq.js +0 -7
|
@@ -78,6 +78,27 @@ If context or old chat history mentions those names, treat them only as legacy a
|
|
|
78
78
|
You are a Vision-Language Model (VLM). If an image is provided, analyze it.
|
|
79
79
|
Be concise and respond in the user's language."""
|
|
80
80
|
|
|
81
|
+
# Appended ONLY when retrieved context exists (review 2026-07-25 Wave 2.3):
|
|
82
|
+
# grounded answers should cite their sources and admit gaps. Advisory prompt
|
|
83
|
+
# guidance — grounding assessment stays annotation-only and never blocks.
|
|
84
|
+
CITATION_INSTRUCTION = """The Context section above contains retrieved sources.
|
|
85
|
+
Ground your claims in those sources and cite them inline as [1], [2], ... matching the order they appear in the Context.
|
|
86
|
+
If the context does not cover the question, say so instead of inventing sources.
|
|
87
|
+
Never cite a source that is not in the Context."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _compose_system(base: str, context: str) -> str:
|
|
91
|
+
"""Compose the system prompt with optional retrieved context.
|
|
92
|
+
|
|
93
|
+
Byte-compatible with the historical prompt when ``context`` is empty:
|
|
94
|
+
the return value is exactly ``base``. When context exists, the Context
|
|
95
|
+
block plus :data:`CITATION_INSTRUCTION` are appended.
|
|
96
|
+
"""
|
|
97
|
+
if not context:
|
|
98
|
+
return base
|
|
99
|
+
return f"{base}\n\nContext:\n{context}\n\n{CITATION_INSTRUCTION}"
|
|
100
|
+
|
|
101
|
+
|
|
81
102
|
def normalize_branding(text: Optional[str]) -> str:
|
|
82
103
|
if not text:
|
|
83
104
|
return ""
|
|
@@ -497,10 +518,8 @@ class LLMRouter:
|
|
|
497
518
|
return raw
|
|
498
519
|
|
|
499
520
|
def _build_prompt(self, message: str, context: Optional[str], tokenizer) -> str:
|
|
500
|
-
system = SYSTEM_PROMPT
|
|
501
521
|
context = normalize_branding(context)
|
|
502
|
-
|
|
503
|
-
system += f"\n\nContext:\n{context}"
|
|
522
|
+
system = _compose_system(SYSTEM_PROMPT, context)
|
|
504
523
|
if hasattr(tokenizer, "apply_chat_template"):
|
|
505
524
|
try:
|
|
506
525
|
msgs = [{"role": "system", "content": system}, {"role": "user", "content": message}]
|
|
@@ -510,10 +529,8 @@ class LLMRouter:
|
|
|
510
529
|
return f"<|im_start|>system\n{system}<|im_end|>\n<|im_start|>user\n{message}<|im_end|>\n<|im_start|>assistant\n"
|
|
511
530
|
|
|
512
531
|
def _build_vlm_prompt(self, model, processor, message: str, context: Optional[str], num_images: int) -> str:
|
|
513
|
-
system = SYSTEM_PROMPT
|
|
514
532
|
context = normalize_branding(context)
|
|
515
|
-
|
|
516
|
-
system += f"\n\nContext:\n{context}"
|
|
533
|
+
system = _compose_system(SYSTEM_PROMPT, context)
|
|
517
534
|
try:
|
|
518
535
|
from mlx_vlm import apply_chat_template
|
|
519
536
|
|
|
@@ -616,10 +633,8 @@ class LLMRouter:
|
|
|
616
633
|
return normalize_branding(str(result))
|
|
617
634
|
|
|
618
635
|
async def _cloud_generate(self, cloud: CloudModel, message: str, context: Optional[str], max_tokens: int, temperature: float) -> str:
|
|
619
|
-
system = SYSTEM_PROMPT
|
|
620
636
|
context = normalize_branding(context)
|
|
621
|
-
|
|
622
|
-
system += f"\n\nContext:\n{context}"
|
|
637
|
+
system = _compose_system(SYSTEM_PROMPT, context)
|
|
623
638
|
try:
|
|
624
639
|
response = await cloud.client.chat.completions.create(
|
|
625
640
|
model=cloud.model,
|
|
@@ -703,10 +718,8 @@ class LLMRouter:
|
|
|
703
718
|
yield chunk
|
|
704
719
|
|
|
705
720
|
async def _cloud_stream_generate(self, cloud: CloudModel, message: str, context: Optional[str], max_tokens: int, temperature: float) -> AsyncIterator[str]:
|
|
706
|
-
system = SYSTEM_PROMPT
|
|
707
721
|
context = normalize_branding(context)
|
|
708
|
-
|
|
709
|
-
system += f"\n\nContext:\n{context}"
|
|
722
|
+
system = _compose_system(SYSTEM_PROMPT, context)
|
|
710
723
|
try:
|
|
711
724
|
stream = await cloud.client.chat.completions.create(
|
|
712
725
|
model=cloud.model,
|
|
@@ -23,9 +23,10 @@ calls a model.
|
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
25
|
import logging
|
|
26
|
+
from datetime import datetime, timedelta
|
|
26
27
|
from typing import Any, Dict, List, Optional
|
|
27
28
|
|
|
28
|
-
from latticeai.core.timeutil import now_iso as _now
|
|
29
|
+
from latticeai.core.timeutil import local_now, now_iso as _now, parse_iso
|
|
29
30
|
|
|
30
31
|
LOGGER = logging.getLogger(__name__)
|
|
31
32
|
|
|
@@ -33,6 +34,13 @@ _RECENT_NODE_LIMIT = 6
|
|
|
33
34
|
_SEARCH_HISTORY_LIMIT = 2000
|
|
34
35
|
_BRIEFING_HISTORY_LIMIT = 2000
|
|
35
36
|
|
|
37
|
+
# Graph-hygiene advisory (review 2026-07-25 Wave 2.5): once the graph passes
|
|
38
|
+
# this many nodes with no noise-curate pass in the stale window, the briefing
|
|
39
|
+
# suggests a *dry-run* /knowledge-graph/curate/noise. Advisory only — nothing
|
|
40
|
+
# is deleted until the user explicitly applies the job.
|
|
41
|
+
_HYGIENE_MIN_NODES = 200
|
|
42
|
+
_HYGIENE_STALE_DAYS = 7
|
|
43
|
+
|
|
36
44
|
|
|
37
45
|
def _clip(text: Any, limit: int = 160) -> str:
|
|
38
46
|
value = str(text or "").strip()
|
|
@@ -240,6 +248,73 @@ class CommandCenterService:
|
|
|
240
248
|
]
|
|
241
249
|
return {"available": True, "count": len(suggestions), "top": top}
|
|
242
250
|
|
|
251
|
+
def _hygiene_section(self) -> Dict[str, Any]:
|
|
252
|
+
"""Advisory graph-hygiene signal (review 2026-07-25 Wave 2.5).
|
|
253
|
+
|
|
254
|
+
Suggests a dry-run noise-curate pass when the graph has grown past
|
|
255
|
+
``_HYGIENE_MIN_NODES`` nodes and the last applied pass is missing or
|
|
256
|
+
older than ``_HYGIENE_STALE_DAYS`` days. Fail-open: any backend error
|
|
257
|
+
degrades to "no suggestion" — the briefing itself never breaks.
|
|
258
|
+
"""
|
|
259
|
+
section: Dict[str, Any] = {
|
|
260
|
+
"available": False,
|
|
261
|
+
"suggest_noise_curate": False,
|
|
262
|
+
"reason": "",
|
|
263
|
+
"last_noise_curate_at": None,
|
|
264
|
+
"node_count": 0,
|
|
265
|
+
}
|
|
266
|
+
if not self._enable_graph or not hasattr(self._kg, "stats"):
|
|
267
|
+
return section
|
|
268
|
+
try:
|
|
269
|
+
stats = self._kg.stats() or {}
|
|
270
|
+
node_count = sum(
|
|
271
|
+
int(count or 0) for count in (stats.get("nodes") or {}).values()
|
|
272
|
+
)
|
|
273
|
+
last = (
|
|
274
|
+
self._kg.last_noise_curate_at()
|
|
275
|
+
if hasattr(self._kg, "last_noise_curate_at")
|
|
276
|
+
else None
|
|
277
|
+
)
|
|
278
|
+
section.update(
|
|
279
|
+
{
|
|
280
|
+
"available": True,
|
|
281
|
+
"node_count": node_count,
|
|
282
|
+
"last_noise_curate_at": last,
|
|
283
|
+
}
|
|
284
|
+
)
|
|
285
|
+
if node_count < _HYGIENE_MIN_NODES:
|
|
286
|
+
return section
|
|
287
|
+
if last is not None and not self._older_than_days(
|
|
288
|
+
last, _HYGIENE_STALE_DAYS
|
|
289
|
+
):
|
|
290
|
+
return section
|
|
291
|
+
section["suggest_noise_curate"] = True
|
|
292
|
+
section["reason"] = (
|
|
293
|
+
f"{node_count} nodes and no noise curation in the last "
|
|
294
|
+
f"{_HYGIENE_STALE_DAYS} days"
|
|
295
|
+
if last
|
|
296
|
+
else f"{node_count} nodes and no noise curation recorded"
|
|
297
|
+
)
|
|
298
|
+
except Exception:
|
|
299
|
+
LOGGER.exception("command center hygiene read failed")
|
|
300
|
+
return {
|
|
301
|
+
"available": False,
|
|
302
|
+
"suggest_noise_curate": False,
|
|
303
|
+
"reason": "",
|
|
304
|
+
"last_noise_curate_at": None,
|
|
305
|
+
"node_count": 0,
|
|
306
|
+
}
|
|
307
|
+
return section
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _older_than_days(stamp: str, days: int) -> bool:
|
|
311
|
+
parsed = parse_iso(stamp)
|
|
312
|
+
if parsed is None:
|
|
313
|
+
# Unreadable stamp → treat as stale; suggesting a dry-run is safe.
|
|
314
|
+
return True
|
|
315
|
+
now = datetime.now(parsed.tzinfo) if parsed.tzinfo else local_now()
|
|
316
|
+
return (now - parsed) > timedelta(days=days)
|
|
317
|
+
|
|
243
318
|
# ── quick actions ────────────────────────────────────────────────────
|
|
244
319
|
|
|
245
320
|
def _quick_actions(self, sections: Dict[str, Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
@@ -295,6 +370,19 @@ class CommandCenterService:
|
|
|
295
370
|
"target": "/brain/graph",
|
|
296
371
|
}
|
|
297
372
|
)
|
|
373
|
+
hygiene = sections.get("hygiene") or {}
|
|
374
|
+
if hygiene.get("suggest_noise_curate"):
|
|
375
|
+
actions.append(
|
|
376
|
+
{
|
|
377
|
+
"id": "curate-noise",
|
|
378
|
+
"kind": "hygiene",
|
|
379
|
+
"count": int(hygiene.get("node_count") or 0),
|
|
380
|
+
"target": "/brain/graph",
|
|
381
|
+
# Dry-run by default on the API side: this endpoint only
|
|
382
|
+
# *reports* removals until dry_run=false is sent explicitly.
|
|
383
|
+
"endpoint": "/knowledge-graph/curate/noise",
|
|
384
|
+
}
|
|
385
|
+
)
|
|
298
386
|
if not actions:
|
|
299
387
|
actions.append(
|
|
300
388
|
{"id": "ask-brain", "kind": "chat", "count": 0, "target": "/brain"}
|
|
@@ -319,6 +407,7 @@ class CommandCenterService:
|
|
|
319
407
|
"suggestions": self._suggestion_section(
|
|
320
408
|
user_email=user_email, workspace_id=workspace_id
|
|
321
409
|
),
|
|
410
|
+
"hygiene": self._hygiene_section(),
|
|
322
411
|
}
|
|
323
412
|
return {
|
|
324
413
|
"generated_at": _now(),
|
|
@@ -303,6 +303,10 @@ class FolderWatchService:
|
|
|
303
303
|
for key in ("status", "new", "changed", "removed", "ingested", "duplicate", "failed")
|
|
304
304
|
if key in result
|
|
305
305
|
}
|
|
306
|
+
# Watch trust UI (review Wave 1.3): keep a small sample of the
|
|
307
|
+
# latest scan's failures so the home health card can show *why*
|
|
308
|
+
# a watch is unhealthy, not just a count.
|
|
309
|
+
watch["last_errors"] = list(result.get("errors") or [])[:3]
|
|
306
310
|
if snapshot is not None:
|
|
307
311
|
watch["snapshot"] = snapshot
|
|
308
312
|
self._save_config()
|
|
@@ -353,6 +357,7 @@ class FolderWatchService:
|
|
|
353
357
|
"created_at": watch.get("created_at"),
|
|
354
358
|
"last_scan_at": watch.get("last_scan_at"),
|
|
355
359
|
"last_result": watch.get("last_result"),
|
|
360
|
+
"last_errors": list(watch.get("last_errors") or []),
|
|
356
361
|
"tracked_files": len(watch.get("snapshot") or {}),
|
|
357
362
|
}
|
|
358
363
|
|
|
@@ -10,7 +10,10 @@ Tracks the product's core value funnel with honest, local-only counters:
|
|
|
10
10
|
* ``needs_review_runs`` — agent runs that ended in ``NEEDS_REVIEW``;
|
|
11
11
|
* ``ingest_completions`` — successful ingestion pipeline completions;
|
|
12
12
|
* ``recall_successes`` — chat turns whose context was grounded in at
|
|
13
|
-
least one Brain node
|
|
13
|
+
least one Brain node;
|
|
14
|
+
* ``approval_pauses`` — agent/workflow runs paused for human approval;
|
|
15
|
+
* ``approval_resumes`` — paused runs a human explicitly resumed
|
|
16
|
+
(approved), the ``approval_resume_rate`` numerator.
|
|
14
17
|
|
|
15
18
|
TTFV ("time to first value") derives from two first-occurrence timestamps:
|
|
16
19
|
the first successful ingest and the first grounded recall/answer.
|
|
@@ -44,6 +47,8 @@ COUNTER_NAMES = (
|
|
|
44
47
|
"needs_review_runs",
|
|
45
48
|
"ingest_completions",
|
|
46
49
|
"recall_successes",
|
|
50
|
+
"approval_pauses",
|
|
51
|
+
"approval_resumes",
|
|
47
52
|
)
|
|
48
53
|
|
|
49
54
|
_FIRST_NAMES = ("first_ingest_at", "first_value_at")
|
|
@@ -193,6 +198,9 @@ class FunnelMetricsService:
|
|
|
193
198
|
"needs_review_rate": _rate(
|
|
194
199
|
counters["needs_review_runs"], counters["agent_runs"]
|
|
195
200
|
),
|
|
201
|
+
"approval_resume_rate": _rate(
|
|
202
|
+
counters["approval_resumes"], counters["approval_pauses"]
|
|
203
|
+
),
|
|
196
204
|
},
|
|
197
205
|
"ttfv_seconds": self.ttfv_seconds(),
|
|
198
206
|
"generated_at": _utc_now_iso(),
|
|
@@ -7,9 +7,11 @@ keyword search into UI-ready contracts without tying routers to store internals.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
from dataclasses import dataclass
|
|
10
|
-
from
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any, Dict, List, Mapping, Optional
|
|
11
12
|
|
|
12
|
-
from lattice_brain.graph.
|
|
13
|
+
from lattice_brain.graph._kg_fsutil import _parse_iso, _recency_score
|
|
14
|
+
from lattice_brain.graph.retrieval_policy import resolve_policy
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
DEFAULT_HYBRID_WEIGHTS = {
|
|
@@ -404,35 +406,44 @@ class SearchService:
|
|
|
404
406
|
allowed_workspaces=None,
|
|
405
407
|
include_legacy_global: bool = False,
|
|
406
408
|
) -> Dict[str, Any]:
|
|
407
|
-
#
|
|
408
|
-
# explicit weights,
|
|
409
|
-
#
|
|
410
|
-
#
|
|
411
|
-
#
|
|
409
|
+
# Single retrieval policy (review Wave 0.2): when the caller does not
|
|
410
|
+
# pin explicit weights, resolve the query class + per-class channel
|
|
411
|
+
# weights + deterministic rewrite from lattice_brain.graph.
|
|
412
|
+
# retrieval_policy (the same policy the graph-layer hybrid consults).
|
|
413
|
+
# Explicit weights still win (and disable rewrite/decay), and the
|
|
414
|
+
# "fact" class equals DEFAULT_HYBRID_WEIGHTS, so pinned-weight callers
|
|
415
|
+
# and fact-class queries behave exactly as before.
|
|
412
416
|
query_class: Optional[str] = None
|
|
417
|
+
search_query = query
|
|
418
|
+
rewrite_rules: List[str] = []
|
|
419
|
+
recency_half_life_days: Optional[float] = None
|
|
413
420
|
if weights is None:
|
|
414
|
-
|
|
415
|
-
query_class =
|
|
416
|
-
weights = dict(
|
|
421
|
+
policy = resolve_policy(query)
|
|
422
|
+
query_class = policy["query_class"]
|
|
423
|
+
weights = dict(policy["weights"])
|
|
424
|
+
rewrite_rules = list(policy["rewrite_rules"])
|
|
425
|
+
recency_half_life_days = policy["recency_half_life_days"]
|
|
426
|
+
if policy["search_query"] and policy["search_query"] != policy["original_query"]:
|
|
427
|
+
search_query = policy["search_query"]
|
|
417
428
|
else:
|
|
418
429
|
weights = {**DEFAULT_HYBRID_WEIGHTS, **dict(weights)}
|
|
419
430
|
# Scope each channel at the source so out-of-scope rows never enter the
|
|
420
431
|
# fusion set (defense-in-depth — the fused result is re-scoped below too).
|
|
421
432
|
channels = {
|
|
422
433
|
"keyword": self.keyword_search(
|
|
423
|
-
|
|
434
|
+
search_query,
|
|
424
435
|
limit=keyword_limit,
|
|
425
436
|
allowed_workspaces=allowed_workspaces,
|
|
426
437
|
include_legacy_global=include_legacy_global,
|
|
427
438
|
),
|
|
428
439
|
"vector": self.vector_search(
|
|
429
|
-
|
|
440
|
+
search_query,
|
|
430
441
|
limit=vector_limit,
|
|
431
442
|
allowed_workspaces=allowed_workspaces,
|
|
432
443
|
include_legacy_global=include_legacy_global,
|
|
433
444
|
),
|
|
434
445
|
"graph": self.graph_search(
|
|
435
|
-
|
|
446
|
+
search_query,
|
|
436
447
|
limit=graph_limit,
|
|
437
448
|
allowed_workspaces=allowed_workspaces,
|
|
438
449
|
include_legacy_global=include_legacy_global,
|
|
@@ -465,6 +476,24 @@ class SearchService:
|
|
|
465
476
|
current.setdefault("graph_context", [])
|
|
466
477
|
current["graph_context"].extend(result.get("graph_context") or [])
|
|
467
478
|
|
|
479
|
+
# Recency-class age decay (retrieval_policy): dampen each fused score
|
|
480
|
+
# into the [0.5, 1.0] band so old-but-relevant items sink without ever
|
|
481
|
+
# being zeroed. Missing/unparseable updated_at keeps multiplier 1.0 —
|
|
482
|
+
# unknown age is not evidence of staleness. Other classes skip this
|
|
483
|
+
# block byte-identically.
|
|
484
|
+
if recency_half_life_days is not None:
|
|
485
|
+
decay_now = datetime.now()
|
|
486
|
+
for item in fused.values():
|
|
487
|
+
stamp = item.get("updated_at")
|
|
488
|
+
if _parse_iso(stamp):
|
|
489
|
+
multiplier = 0.5 + 0.5 * _recency_score(
|
|
490
|
+
stamp, now=decay_now, half_life_days=recency_half_life_days
|
|
491
|
+
)
|
|
492
|
+
else:
|
|
493
|
+
multiplier = 1.0
|
|
494
|
+
item["source_scores"]["age_decay"] = round(multiplier, 6)
|
|
495
|
+
item["score"] = float(item["score"]) * multiplier
|
|
496
|
+
|
|
468
497
|
matches = self._scope(
|
|
469
498
|
sorted(fused.values(), key=lambda item: item["score"], reverse=True),
|
|
470
499
|
allowed_workspaces,
|
|
@@ -484,6 +513,7 @@ class SearchService:
|
|
|
484
513
|
"mode": "hybrid",
|
|
485
514
|
"query_class": query_class,
|
|
486
515
|
"weights": weights,
|
|
516
|
+
"policy": {"search_query": search_query, "rewrite_rules": rewrite_rules},
|
|
487
517
|
"channels": {
|
|
488
518
|
name: {
|
|
489
519
|
key: value
|
|
@@ -220,6 +220,60 @@ class ToolDispatchService:
|
|
|
220
220
|
)
|
|
221
221
|
return {"path": path, "ok": r.returncode == 0, "stderr": r.stderr[:200]}
|
|
222
222
|
|
|
223
|
+
# ── snapshot rollback ports (review L7) ──────────────────────────────
|
|
224
|
+
# Pre-write capture + restore for workspaces where git rollback does not
|
|
225
|
+
# apply (untracked new files, no git). Strictly confined to AGENT_ROOT.
|
|
226
|
+
|
|
227
|
+
_SNAPSHOT_MAX_BYTES = 512 * 1024
|
|
228
|
+
|
|
229
|
+
def _workspace_path(self, path: str) -> Optional[Path]:
|
|
230
|
+
raw = Path(path)
|
|
231
|
+
candidate = raw if raw.is_absolute() else AGENT_ROOT / raw
|
|
232
|
+
try:
|
|
233
|
+
resolved = candidate.resolve()
|
|
234
|
+
root = AGENT_ROOT.resolve()
|
|
235
|
+
except OSError:
|
|
236
|
+
return None
|
|
237
|
+
if resolved != root and root not in resolved.parents:
|
|
238
|
+
return None
|
|
239
|
+
return resolved
|
|
240
|
+
|
|
241
|
+
def snapshot_file(self, path: str) -> Dict[str, Any]:
|
|
242
|
+
"""Pre-write state of a workspace file: existence + bounded content."""
|
|
243
|
+
target = self._workspace_path(path)
|
|
244
|
+
if target is None:
|
|
245
|
+
return {"existed": False, "content": None, "too_large": False,
|
|
246
|
+
"error": "path escapes the agent workspace"}
|
|
247
|
+
if not target.exists() or not target.is_file():
|
|
248
|
+
return {"existed": False, "content": None, "too_large": False}
|
|
249
|
+
try:
|
|
250
|
+
if target.stat().st_size > self._SNAPSHOT_MAX_BYTES:
|
|
251
|
+
return {"existed": True, "content": None, "too_large": True}
|
|
252
|
+
return {
|
|
253
|
+
"existed": True,
|
|
254
|
+
"content": target.read_text(encoding="utf-8", errors="replace"),
|
|
255
|
+
"too_large": False,
|
|
256
|
+
}
|
|
257
|
+
except OSError as exc:
|
|
258
|
+
return {"existed": True, "content": None, "too_large": True,
|
|
259
|
+
"error": str(exc)}
|
|
260
|
+
|
|
261
|
+
def restore_snapshot(self, path: str, content: Optional[str]) -> Dict[str, Any]:
|
|
262
|
+
"""Restore a pre-write snapshot: rewrite prior content, or delete a
|
|
263
|
+
file the run created (``content=None``)."""
|
|
264
|
+
target = self._workspace_path(path)
|
|
265
|
+
if target is None:
|
|
266
|
+
return {"path": path, "ok": False, "error": "path escapes the agent workspace"}
|
|
267
|
+
try:
|
|
268
|
+
if content is None:
|
|
269
|
+
target.unlink(missing_ok=True)
|
|
270
|
+
return {"path": path, "ok": True, "action": "deleted"}
|
|
271
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
272
|
+
target.write_text(content, encoding="utf-8")
|
|
273
|
+
return {"path": path, "ok": True, "action": "restored"}
|
|
274
|
+
except OSError as exc:
|
|
275
|
+
return {"path": path, "ok": False, "error": str(exc)}
|
|
276
|
+
|
|
223
277
|
|
|
224
278
|
DEFAULT_TOOL_DISPATCH_SERVICE = ToolDispatchService()
|
|
225
279
|
|
|
@@ -375,6 +429,8 @@ def build_agent_runtime(
|
|
|
375
429
|
memory_updater_prompt=MEMORY_UPDATER_PROMPT,
|
|
376
430
|
agent_root=AGENT_ROOT,
|
|
377
431
|
rollback_file=dispatch_service.rollback_file,
|
|
432
|
+
snapshot_file=dispatch_service.snapshot_file,
|
|
433
|
+
restore_snapshot=dispatch_service.restore_snapshot,
|
|
378
434
|
hooks=hooks,
|
|
379
435
|
brain_memory=brain_memory,
|
|
380
436
|
)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ltcai",
|
|
3
|
-
"version": "9.9.
|
|
3
|
+
"version": "9.9.5",
|
|
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": {
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"test:integration": "node scripts/run_integration_tests.mjs",
|
|
42
42
|
"test:browser-extension": "node --check browser-extension/popup.js && node --check browser-extension/capture-page.js && node --test tests/browser-extension.test.cjs",
|
|
43
43
|
"test:visual": "playwright test",
|
|
44
|
+
"test:e2e:sidecar": "node scripts/run_sidecar_e2e.mjs",
|
|
44
45
|
"vercel:build": "node scripts/build_vercel_static.mjs",
|
|
45
46
|
"desktop:tauri": "tauri dev",
|
|
46
47
|
"desktop:tauri:build": "tauri build",
|