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.
Files changed (89) hide show
  1. package/README.md +39 -38
  2. package/docs/CHANGELOG.md +100 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/SURFACE_PARITY.md +53 -0
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/graph/_kg_common.py +335 -0
  13. package/lattice_brain/graph/discovery_index.py +8 -1
  14. package/lattice_brain/graph/ingest.py +43 -9
  15. package/lattice_brain/graph/projection.py +221 -37
  16. package/lattice_brain/graph/rerank.py +160 -0
  17. package/lattice_brain/graph/retrieval.py +90 -455
  18. package/lattice_brain/graph/retrieval_policy.py +174 -0
  19. package/lattice_brain/graph/retrieval_reads.py +459 -0
  20. package/lattice_brain/graph/retrieval_vector.py +122 -2
  21. package/lattice_brain/graph/store.py +2 -0
  22. package/lattice_brain/portability.py +41 -12
  23. package/lattice_brain/quality.py +26 -6
  24. package/lattice_brain/runtime/multi_agent.py +1 -1
  25. package/latticeai/__init__.py +1 -1
  26. package/latticeai/api/chat.py +6 -0
  27. package/latticeai/api/chat_agent_http.py +262 -52
  28. package/latticeai/api/chat_contracts.py +1 -1
  29. package/latticeai/api/chat_intents.py +37 -18
  30. package/latticeai/api/chat_stream.py +76 -2
  31. package/latticeai/api/knowledge_graph.py +35 -1
  32. package/latticeai/core/agent.py +420 -27
  33. package/latticeai/core/enterprise.py +5 -0
  34. package/latticeai/core/file_generation.py +130 -5
  35. package/latticeai/core/legacy_compatibility.py +1 -1
  36. package/latticeai/core/marketplace.py +1 -1
  37. package/latticeai/core/run_store.py +245 -0
  38. package/latticeai/core/workspace_os.py +1 -1
  39. package/latticeai/integrations/telegram_bot.py +82 -23
  40. package/latticeai/models/router.py +25 -12
  41. package/latticeai/services/architecture_readiness.py +1 -1
  42. package/latticeai/services/command_center.py +90 -1
  43. package/latticeai/services/folder_watch.py +5 -0
  44. package/latticeai/services/funnel_metrics.py +9 -1
  45. package/latticeai/services/product_readiness.py +1 -1
  46. package/latticeai/services/search_service.py +43 -13
  47. package/latticeai/services/tool_dispatch.py +56 -0
  48. package/package.json +2 -1
  49. package/scripts/bench_agent_smoke.py +410 -0
  50. package/scripts/check_current_release_docs.mjs +1 -1
  51. package/scripts/funnel_soft_gate.py +192 -0
  52. package/scripts/run_sidecar_e2e.mjs +159 -0
  53. package/src-tauri/Cargo.lock +1 -1
  54. package/src-tauri/Cargo.toml +1 -1
  55. package/src-tauri/tauri.conf.json +1 -1
  56. package/static/app/asset-manifest.json +29 -28
  57. package/static/app/assets/Act-DmxfbqBp.js +1 -0
  58. package/static/app/assets/{Brain-DEY9jLVt.js → Brain-DK_zKCda.js} +2 -2
  59. package/static/app/assets/BrainHome-CCE_0hni.js +2 -0
  60. package/static/app/assets/BrainSignals-r_J68_zh.js +1 -0
  61. package/static/app/assets/{Capture-CVZ09QXi.js → Capture-CvxwzpJZ.js} +1 -1
  62. package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-CVLiFIQC.js} +1 -1
  63. package/static/app/assets/{Library-Bp0n-HlW.js → Library-4As9VUvy.js} +1 -1
  64. package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-BVWk58MW.js} +1 -1
  65. package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-B-Tz0Um4.js} +1 -1
  66. package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-Va3fPQuj.js} +1 -1
  67. package/static/app/assets/{System-ByQcmJW-.js → System-DSR2zKWd.js} +1 -1
  68. package/static/app/assets/{bot-BNDyZLR7.js → bot-VmP0kkeA.js} +1 -1
  69. package/static/app/assets/circle-pause-T9POo4qy.js +1 -0
  70. package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-Bz5iTD0p.js} +1 -1
  71. package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CJhjRtNq.js} +1 -1
  72. package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-B2K_22VI.js} +1 -1
  73. package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CjJtYqHf.js} +1 -1
  74. package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
  75. package/static/app/assets/{index-CHu7cgj3.js → index-CEu0Wqjl.js} +3 -3
  76. package/static/app/assets/{input-DVDI0YR3.js → input-CSi6OBJ9.js} +1 -1
  77. package/static/app/assets/{navigation-BddhEWA0.js → navigation-BPQqEQwr.js} +1 -1
  78. package/static/app/assets/{network-pYQt5oBu.js → network-Ck1nqc-v.js} +1 -1
  79. package/static/app/assets/{primitives-D7gCdEvS.js → primitives-B2f9N7Dh.js} +1 -1
  80. package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-yA_Y8lOB.js} +1 -1
  81. package/static/app/assets/{textarea-sqQmoBKL.js → textarea-C4wM0D-Z.js} +1 -1
  82. package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-CsnIhsDJ.js} +1 -1
  83. package/static/app/assets/utils-BqoznzGP.js +7 -0
  84. package/static/app/index.html +4 -4
  85. package/static/sw.js +1 -1
  86. package/static/app/assets/Act-DmdruVKV.js +0 -1
  87. package/static/app/assets/BrainHome-CeNaxjP1.js +0 -2
  88. package/static/app/assets/BrainSignals-CStjIqYi.js +0 -1
  89. 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
- if context:
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
- if context:
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
- if context:
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
- if context:
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,
@@ -17,7 +17,7 @@ from typing import Any, Dict, List
17
17
  from latticeai.core.legacy_compatibility import legacy_shim_report
18
18
 
19
19
 
20
- ARCHITECTURE_VERSION_TARGET = "9.9.3"
20
+ ARCHITECTURE_VERSION_TARGET = "9.9.5"
21
21
 
22
22
  PREFERRED_REFACTORING_ORDER = [
23
23
  "agent-runtime",
@@ -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(),
@@ -18,7 +18,7 @@ from typing import Any, Dict, List
18
18
 
19
19
  from latticeai.services.architecture_readiness import architecture_readiness
20
20
 
21
- PRODUCT_VERSION_TARGET = "9.9.3"
21
+ PRODUCT_VERSION_TARGET = "9.9.5"
22
22
 
23
23
 
24
24
  @dataclass(frozen=True)
@@ -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 typing import Any, Dict, Mapping, Optional
10
+ from datetime import datetime
11
+ from typing import Any, Dict, List, Mapping, Optional
11
12
 
12
- from lattice_brain.graph.fusion import fusion_profile
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
- # Query-class fusion (backlog #5): when the caller does not pin
408
- # explicit weights, detect the query class (fact/code/person/recency)
409
- # and use its per-class channel weights. Explicit weights still win,
410
- # and the "fact" class equals DEFAULT_HYBRID_WEIGHTS, so pinned-weight
411
- # callers and fact-class queries behave exactly as before.
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
- profile = fusion_profile(query)
415
- query_class = profile["query_class"]
416
- weights = dict(profile["weights"])
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
- query,
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
- query,
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
- query,
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",
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",