leapflow 0.0.0__py3-none-any.whl

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 (189) hide show
  1. leapflow/__init__.py +6 -0
  2. leapflow/__main__.py +13 -0
  3. leapflow/analysis/__init__.py +17 -0
  4. leapflow/analysis/abstractor.py +304 -0
  5. leapflow/analysis/causal.py +91 -0
  6. leapflow/analysis/consensus.py +111 -0
  7. leapflow/analysis/denoise.py +248 -0
  8. leapflow/analysis/episode_dedup.py +50 -0
  9. leapflow/analysis/intent_inferrer.py +367 -0
  10. leapflow/analysis/patterns.py +260 -0
  11. leapflow/analysis/pipeline.py +764 -0
  12. leapflow/analysis/segmenter.py +331 -0
  13. leapflow/analysis/synthesis.py +1348 -0
  14. leapflow/causal/__init__.py +35 -0
  15. leapflow/causal/adapter.py +202 -0
  16. leapflow/causal/channel.py +310 -0
  17. leapflow/causal/components.py +557 -0
  18. leapflow/causal/inference.py +627 -0
  19. leapflow/causal/pipeline.py +522 -0
  20. leapflow/causal/types.py +327 -0
  21. leapflow/cli/__init__.py +5 -0
  22. leapflow/cli/banner.py +229 -0
  23. leapflow/cli/cli.py +216 -0
  24. leapflow/cli/commands/__init__.py +1 -0
  25. leapflow/cli/commands/chat.py +18 -0
  26. leapflow/cli/commands/host.py +393 -0
  27. leapflow/cli/commands/interactive.py +375 -0
  28. leapflow/cli/commands/learn.py +551 -0
  29. leapflow/cli/commands/relearn.py +63 -0
  30. leapflow/cli/commands/run.py +262 -0
  31. leapflow/cli/commands/skills.py +290 -0
  32. leapflow/cli/context.py +924 -0
  33. leapflow/cli/helpers.py +76 -0
  34. leapflow/cli/tui.py +101 -0
  35. leapflow/config.py +608 -0
  36. leapflow/domain/__init__.py +48 -0
  37. leapflow/domain/events.py +87 -0
  38. leapflow/domain/perception.py +75 -0
  39. leapflow/domain/platform.py +98 -0
  40. leapflow/domain/skill_types.py +114 -0
  41. leapflow/domain/trajectory.py +234 -0
  42. leapflow/domain/ui_vocabulary.py +81 -0
  43. leapflow/engine/__init__.py +36 -0
  44. leapflow/engine/audit.py +73 -0
  45. leapflow/engine/confirmation.py +220 -0
  46. leapflow/engine/engine.py +953 -0
  47. leapflow/engine/graph_planner.py +363 -0
  48. leapflow/engine/intent_classifier.py +152 -0
  49. leapflow/engine/planner.py +80 -0
  50. leapflow/engine/resilience.py +5 -0
  51. leapflow/engine/scheduler.py +389 -0
  52. leapflow/engine/session.py +802 -0
  53. leapflow/engine/shortcuts.py +120 -0
  54. leapflow/engine/situational_assessor.py +189 -0
  55. leapflow/engine/task_graph.py +608 -0
  56. leapflow/engine/terminal_io.py +5 -0
  57. leapflow/host/__init__.py +39 -0
  58. leapflow/host/dev.py +340 -0
  59. leapflow/host/launchd.py +197 -0
  60. leapflow/host/manager.py +547 -0
  61. leapflow/host/permissions.py +160 -0
  62. leapflow/learning/__init__.py +17 -0
  63. leapflow/learning/active_learning.py +1244 -0
  64. leapflow/learning/codegen.py +773 -0
  65. leapflow/learning/distiller.py +463 -0
  66. leapflow/learning/doc_generator.py +657 -0
  67. leapflow/learning/document.py +443 -0
  68. leapflow/learning/feedback.py +339 -0
  69. leapflow/learning/similarity.py +303 -0
  70. leapflow/learning/stream_progress.py +5 -0
  71. leapflow/llm/__init__.py +21 -0
  72. leapflow/llm/base.py +52 -0
  73. leapflow/llm/message_builder.py +53 -0
  74. leapflow/llm/openai_provider.py +449 -0
  75. leapflow/memory/__init__.py +8 -0
  76. leapflow/memory/decay.py +33 -0
  77. leapflow/memory/immediate.py +175 -0
  78. leapflow/memory/long_term.py +316 -0
  79. leapflow/memory/working_memory.py +94 -0
  80. leapflow/perception/__init__.py +37 -0
  81. leapflow/perception/config.py +112 -0
  82. leapflow/perception/cv/__init__.py +1 -0
  83. leapflow/perception/cv/optical_flow.py +173 -0
  84. leapflow/perception/cv/phash.py +183 -0
  85. leapflow/perception/cv/scene_cut.py +102 -0
  86. leapflow/perception/cv/text_diff.py +156 -0
  87. leapflow/perception/cv/ui_detect.py +81 -0
  88. leapflow/perception/encoding/__init__.py +1 -0
  89. leapflow/perception/encoding/delta.py +303 -0
  90. leapflow/perception/encoding/encoder.py +118 -0
  91. leapflow/perception/encoding/tiler.py +184 -0
  92. leapflow/perception/extraction/__init__.py +1 -0
  93. leapflow/perception/extraction/extractor.py +394 -0
  94. leapflow/perception/extraction/feature_extractor.py +117 -0
  95. leapflow/perception/extraction/pipeline.py +369 -0
  96. leapflow/perception/extraction/preprocessor.py +123 -0
  97. leapflow/perception/extraction/refiner.py +152 -0
  98. leapflow/perception/extraction/router.py +80 -0
  99. leapflow/perception/sampling/__init__.py +1 -0
  100. leapflow/perception/session.py +464 -0
  101. leapflow/perception/signals.py +53 -0
  102. leapflow/perception/state_snapshot.py +241 -0
  103. leapflow/perception/storage/__init__.py +1 -0
  104. leapflow/perception/storage/deduplicator.py +121 -0
  105. leapflow/perception/storage/frame_store.py +145 -0
  106. leapflow/perception/storage/semantic_cache.py +129 -0
  107. leapflow/perception/types.py +350 -0
  108. leapflow/perception/video/__init__.py +25 -0
  109. leapflow/perception/video/analyzer.py +413 -0
  110. leapflow/perception/video/prompts.py +243 -0
  111. leapflow/perception/video/recorder.py +118 -0
  112. leapflow/perception/video/segmenter.py +143 -0
  113. leapflow/perception/video/timeline.py +250 -0
  114. leapflow/platform/__init__.py +37 -0
  115. leapflow/platform/adapters/__init__.py +1 -0
  116. leapflow/platform/adapters/darwin.py +259 -0
  117. leapflow/platform/adapters/mock.py +189 -0
  118. leapflow/platform/capabilities.py +128 -0
  119. leapflow/platform/client.py +366 -0
  120. leapflow/platform/event_bus.py +194 -0
  121. leapflow/platform/facade.py +100 -0
  122. leapflow/platform/mock.py +156 -0
  123. leapflow/platform/normalizer.py +434 -0
  124. leapflow/platform/protocol.py +145 -0
  125. leapflow/platform/relevance.py +74 -0
  126. leapflow/platform/reorder_buffer.py +76 -0
  127. leapflow/prompts/__init__.py +5 -0
  128. leapflow/prompts/templates.py +63 -0
  129. leapflow/recording/__init__.py +27 -0
  130. leapflow/recording/attention.py +937 -0
  131. leapflow/recording/attention_tuner.py +148 -0
  132. leapflow/recording/field_policy_loader.py +426 -0
  133. leapflow/recording/health.py +146 -0
  134. leapflow/recording/perceptual_field.py +463 -0
  135. leapflow/recording/recorder.py +789 -0
  136. leapflow/signal_fusion/__init__.py +51 -0
  137. leapflow/signal_fusion/action_agent.py +135 -0
  138. leapflow/signal_fusion/cross_app.py +172 -0
  139. leapflow/signal_fusion/episode_agent.py +313 -0
  140. leapflow/signal_fusion/integrator.py +250 -0
  141. leapflow/signal_fusion/pipeline.py +230 -0
  142. leapflow/signal_fusion/protocol.py +63 -0
  143. leapflow/signal_fusion/quality.py +89 -0
  144. leapflow/signal_fusion/segment_agent.py +258 -0
  145. leapflow/signal_fusion/types.py +305 -0
  146. leapflow/signal_fusion/wait_classifier.py +103 -0
  147. leapflow/skills/__init__.py +17 -0
  148. leapflow/skills/action_policy.py +218 -0
  149. leapflow/skills/activator.py +273 -0
  150. leapflow/skills/bridge_factory.py +200 -0
  151. leapflow/skills/builtin/__init__.py +5 -0
  152. leapflow/skills/builtin/app_launcher.py +41 -0
  153. leapflow/skills/builtin/clipboard_manager.py +41 -0
  154. leapflow/skills/builtin/file_organizer.py +124 -0
  155. leapflow/skills/conditions.py +219 -0
  156. leapflow/skills/registry.py +388 -0
  157. leapflow/skills/sandbox.py +221 -0
  158. leapflow/skills/semantic_adapter.py +501 -0
  159. leapflow/skills/tool_executor.py +910 -0
  160. leapflow/skills/ui_selector.py +201 -0
  161. leapflow/skills/ui_summarizer.py +159 -0
  162. leapflow/storage/__init__.py +13 -0
  163. leapflow/storage/bundle_writer.py +142 -0
  164. leapflow/storage/session_store.py +162 -0
  165. leapflow/storage/skill_docs.py +247 -0
  166. leapflow/storage/skill_library.py +729 -0
  167. leapflow/storage/trajectory_store.py +474 -0
  168. leapflow/utils/__init__.py +27 -0
  169. leapflow/utils/diagnostics.py +156 -0
  170. leapflow/utils/progress.py +167 -0
  171. leapflow/utils/resilience.py +71 -0
  172. leapflow/utils/stream_progress.py +73 -0
  173. leapflow/utils/terminal_io.py +24 -0
  174. leapflow/version.py +3 -0
  175. leapflow/world_model/__init__.py +29 -0
  176. leapflow/world_model/_json_utils.py +18 -0
  177. leapflow/world_model/budget.py +165 -0
  178. leapflow/world_model/curiosity.py +267 -0
  179. leapflow/world_model/embedding.py +117 -0
  180. leapflow/world_model/experience_store.py +348 -0
  181. leapflow/world_model/prediction.py +417 -0
  182. leapflow/world_model/replay.py +377 -0
  183. leapflow/world_model/trajectory_grader.py +213 -0
  184. leapflow-0.0.0.dist-info/METADATA +35 -0
  185. leapflow-0.0.0.dist-info/RECORD +189 -0
  186. leapflow-0.0.0.dist-info/WHEEL +5 -0
  187. leapflow-0.0.0.dist-info/entry_points.txt +2 -0
  188. leapflow-0.0.0.dist-info/licenses/LICENSE +201 -0
  189. leapflow-0.0.0.dist-info/top_level.txt +1 -0
leapflow/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """LEAP Agent — Learning and Evolving from Actual Practice."""
2
+
3
+ from leapflow.config import load_config
4
+ from leapflow.version import __version__
5
+
6
+ __all__ = ["__version__", "load_config"]
leapflow/__main__.py ADDED
@@ -0,0 +1,13 @@
1
+ """CLI entrypoint for LEAP Agent.
2
+
3
+ Subcommands:
4
+ leap learn — Interactive learning mode (record → distill)
5
+ leap run — Execute a skill by trigger match or explicit name
6
+ leap skills — List / inspect learned skills
7
+ leap chat — Conversational mode (original single-turn or interactive)
8
+ """
9
+
10
+ from leapflow.cli import main
11
+
12
+ if __name__ == "__main__":
13
+ raise SystemExit(main())
@@ -0,0 +1,17 @@
1
+ """Offline analysis layer — synthesis, abstraction, segmentation, and distillation pipeline."""
2
+
3
+ from leapflow.analysis.abstractor import ActionAbstractor
4
+ from leapflow.analysis.synthesis import PlatformSynthesisPass
5
+
6
+ __all__ = [
7
+ "ActionAbstractor",
8
+ "ImitationPipeline",
9
+ "PlatformSynthesisPass",
10
+ ]
11
+
12
+
13
+ def __getattr__(name: str):
14
+ if name == "ImitationPipeline":
15
+ from leapflow.analysis.pipeline import ImitationPipeline
16
+ return ImitationPipeline
17
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,304 @@
1
+ """Multi-level action abstraction for trajectory analysis.
2
+
3
+ Abstraction levels:
4
+ L0 (Raw) → individual events from EventBus
5
+ L1 (Grouped) → consecutive same-type actions merged (e.g. keystrokes → type_text)
6
+ L2 (Pattern) → recognized action patterns (e.g. copy + switch + paste → transfer_data)
7
+ L3 (Intent) → LLM-inferred user intent (optional, requires LLMProvider)
8
+
9
+ Each level is a composable AbstractionPass (Pipeline pattern).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from abc import ABC, abstractmethod
16
+ from typing import Any, Dict, List, Optional, Sequence
17
+
18
+ from leapflow.analysis.patterns import PatternLibrary
19
+ from leapflow.domain.trajectory import (
20
+ ActionType,
21
+ RawAction,
22
+ SemanticAction,
23
+ TrajectoryStep,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ # ── Abstraction pass protocol ──
30
+
31
+
32
+ class AbstractionPass(ABC):
33
+ """A single transformation in the abstraction pipeline."""
34
+
35
+ @abstractmethod
36
+ def apply(
37
+ self,
38
+ actions: List[SemanticAction],
39
+ steps: Optional[List[TrajectoryStep]] = None,
40
+ ) -> List[SemanticAction]:
41
+ """Transform a list of semantic actions into a (usually shorter) list.
42
+
43
+ Args:
44
+ actions: Current semantic actions to transform.
45
+ steps: Optional raw trajectory steps for passes that need
46
+ access to state context (e.g., visual frames).
47
+ """
48
+
49
+
50
+ # ── L0→L1: Rule-based grouping ──
51
+
52
+
53
+ class GroupingPass(AbstractionPass):
54
+ """Merge consecutive low-level actions of the same type.
55
+
56
+ - Sequential type events → single "type_text" with merged text_content
57
+ - Sequential file modifications on the same target → single "batch_modify"
58
+ - Sequential clipboard changes → keep only the last one
59
+ """
60
+
61
+ def apply(
62
+ self,
63
+ actions: List[SemanticAction],
64
+ steps: Optional[List[TrajectoryStep]] = None,
65
+ ) -> List[SemanticAction]:
66
+ if len(actions) <= 1:
67
+ return actions
68
+
69
+ result: List[SemanticAction] = []
70
+ i = 0
71
+ while i < len(actions):
72
+ # Special case: merge consecutive type events with char data
73
+ if actions[i].action_name == "ui.type":
74
+ type_end = self._find_type_group_end(actions, i)
75
+ if type_end > i + 1:
76
+ result.append(self._merge_type_group(actions[i:type_end]))
77
+ i = type_end
78
+ continue
79
+
80
+ group_end = self._find_group_end(actions, i)
81
+ if group_end > i + 1:
82
+ result.append(self._merge_group(actions[i:group_end]))
83
+ i = group_end
84
+ else:
85
+ result.append(actions[i])
86
+ i += 1
87
+ return result
88
+
89
+ def _find_type_group_end(self, actions: List[SemanticAction], start: int) -> int:
90
+ """Find end of a consecutive ui.type run within the same app."""
91
+ app = actions[start].parameters.get("app_bundle_id", "")
92
+ end = start + 1
93
+ while end < len(actions) and actions[end].action_name == "ui.type":
94
+ if actions[end].parameters.get("app_bundle_id", "") != app:
95
+ break
96
+ end += 1
97
+ return end
98
+
99
+ @staticmethod
100
+ def _merge_type_group(group: List[SemanticAction]) -> SemanticAction:
101
+ """Merge consecutive type actions, concatenating char fields into text_content."""
102
+ first, last = group[0], group[-1]
103
+ chars = [a.parameters.get("char", "") for a in group]
104
+ text_content = "".join(chars)
105
+ params: Dict[str, Any] = {
106
+ "app_bundle_id": first.parameters.get("app_bundle_id", ""),
107
+ "keystroke_count": len(group),
108
+ }
109
+ if text_content:
110
+ params["text_content"] = text_content
111
+ return SemanticAction(
112
+ action_name="type_text",
113
+ description=f"Type '{text_content[:50]}'" if text_content else f"Type {len(group)} keys",
114
+ parameters=params,
115
+ raw_action_range=(first.raw_action_range[0], last.raw_action_range[1]),
116
+ confidence=1.0,
117
+ )
118
+
119
+ def _find_group_end(self, actions: List[SemanticAction], start: int) -> int:
120
+ """Find the end index of a mergeable group starting at `start`."""
121
+ base = actions[start]
122
+ end = start + 1
123
+ while end < len(actions):
124
+ curr = actions[end]
125
+ if not self._can_merge(base, curr):
126
+ break
127
+ end += 1
128
+ return end
129
+
130
+ @staticmethod
131
+ def _can_merge(a: SemanticAction, b: SemanticAction) -> bool:
132
+ if a.action_name != b.action_name:
133
+ return False
134
+ mergeable = {"file.modify", "file.create", "file.rename", "file.move", "clipboard.copy"}
135
+ return a.action_name in mergeable
136
+
137
+ @staticmethod
138
+ def _merge_group(group: List[SemanticAction]) -> SemanticAction:
139
+ first, last = group[0], group[-1]
140
+ count = len(group)
141
+ merged_params = dict(first.parameters)
142
+ merged_params["count"] = count
143
+ return SemanticAction(
144
+ action_name=f"batch_{first.action_name.split('.')[-1]}",
145
+ description=f"{first.action_name} x{count}",
146
+ parameters=merged_params,
147
+ raw_action_range=(first.raw_action_range[0], last.raw_action_range[1]),
148
+ confidence=min(a.confidence for a in group),
149
+ )
150
+
151
+
152
+ # ── L1→L2: Pattern matching ──
153
+
154
+
155
+ class PatternPass(AbstractionPass):
156
+ """Pattern-based action abstraction using an extensible PatternLibrary.
157
+
158
+ Replaces hardcoded patterns with a YAML-driven, wildcard-capable
159
+ pattern matching engine that supports runtime addition of new patterns.
160
+ """
161
+
162
+ def __init__(self, library: Optional[PatternLibrary] = None) -> None:
163
+ self._library = library or PatternLibrary.default()
164
+
165
+ def apply(
166
+ self,
167
+ actions: List[SemanticAction],
168
+ steps: Optional[List[TrajectoryStep]] = None,
169
+ ) -> List[SemanticAction]:
170
+ if len(actions) < 2:
171
+ return actions
172
+
173
+ matches = self._library.match(actions)
174
+ if not matches:
175
+ return list(actions)
176
+
177
+ # Build result: replace matched spans with semantic actions, keep rest
178
+ result: List[SemanticAction] = []
179
+ consumed = 0
180
+
181
+ for m in matches:
182
+ # Append unmatched actions before this match
183
+ result.extend(actions[consumed : m.start_idx])
184
+ # Create the abstracted semantic action
185
+ first = actions[m.start_idx]
186
+ last = actions[m.end_idx - 1]
187
+ params: Dict[str, Any] = {}
188
+ # Merge original params from matched window
189
+ for a in actions[m.start_idx : m.end_idx]:
190
+ params.update(a.parameters)
191
+ # Overlay extracted params from pattern rules
192
+ params.update(m.extracted_params)
193
+
194
+ result.append(
195
+ SemanticAction(
196
+ action_name=m.pattern.semantic_name,
197
+ description=m.pattern.description or m.pattern.semantic_name,
198
+ parameters=params,
199
+ raw_action_range=(
200
+ first.raw_action_range[0],
201
+ last.raw_action_range[1],
202
+ ),
203
+ confidence=m.match_confidence,
204
+ )
205
+ )
206
+ consumed = m.end_idx
207
+
208
+ # Append remaining unmatched actions
209
+ result.extend(actions[consumed:])
210
+ return result
211
+
212
+
213
+ # ── Orchestrator ──
214
+
215
+
216
+ class ActionAbstractor:
217
+ """Multi-level action abstraction pipeline.
218
+
219
+ By default runs L0→L1→L2 (rule-based only, no LLM cost).
220
+ """
221
+
222
+ def __init__(
223
+ self,
224
+ passes: Sequence[AbstractionPass] | None = None,
225
+ *,
226
+ platform_hint: str = "darwin",
227
+ ) -> None:
228
+ if passes is not None:
229
+ self._passes = list(passes)
230
+ else:
231
+ from leapflow.analysis.denoise import DenoisePass
232
+ from leapflow.analysis.synthesis import PlatformSynthesisPass
233
+ self._passes = [
234
+ DenoisePass(),
235
+ PlatformSynthesisPass.for_platform(platform_hint),
236
+ GroupingPass(),
237
+ PatternPass(),
238
+ ]
239
+
240
+ def abstract(self, steps: List[TrajectoryStep]) -> List[SemanticAction]:
241
+ """Run the abstraction pipeline on raw trajectory steps."""
242
+ actions = [self._step_to_semantic(i, step) for i, step in enumerate(steps)]
243
+ for p in self._passes:
244
+ actions = p.apply(actions, steps=steps)
245
+ return actions
246
+
247
+ @staticmethod
248
+ def _step_to_semantic(idx: int, step: TrajectoryStep) -> SemanticAction:
249
+ """Convert a single TrajectoryStep to an initial SemanticAction (L0)."""
250
+ a = step.action
251
+ params: Dict[str, Any] = {}
252
+
253
+ if a.target:
254
+ params["target"] = a.target
255
+ if a.target_label:
256
+ params["target_label"] = a.target_label
257
+ if a.app_bundle_id:
258
+ params["app_bundle_id"] = a.app_bundle_id
259
+ if a.action_type in (ActionType.FILE_CREATE, ActionType.FILE_MODIFY,
260
+ ActionType.FILE_DELETE, ActionType.FILE_RENAME):
261
+ params["path"] = a.target
262
+ if a.action_type == ActionType.CLIPBOARD_COPY:
263
+ params["text"] = a.params.get("text", "")
264
+ if a.action_type == ActionType.UI_TYPE:
265
+ char = a.params.get("char")
266
+ if char:
267
+ params["char"] = char
268
+ if a.action_type == ActionType.UI_DRAG:
269
+ for k in ("start_x", "start_y", "end_x", "end_y",
270
+ "end_role", "end_label", "cross_app", "start_app", "end_app"):
271
+ if k in a.params:
272
+ params[k] = a.params[k]
273
+
274
+ return SemanticAction(
275
+ action_name=a.action_type.value,
276
+ description=_describe_action(a),
277
+ parameters=params,
278
+ raw_action_range=(idx, idx + 1),
279
+ confidence=1.0,
280
+ )
281
+
282
+
283
+ def _describe_action(a: RawAction) -> str:
284
+ """Generate a human-readable description of a raw action."""
285
+ at = a.action_type
286
+ if at == ActionType.APP_SWITCH:
287
+ return f"Switch to {a.app_name or a.app_bundle_id}"
288
+ if at in (ActionType.FILE_CREATE, ActionType.FILE_MODIFY,
289
+ ActionType.FILE_DELETE, ActionType.FILE_RENAME):
290
+ verb = at.value.split(".")[-1].capitalize()
291
+ return f"{verb} {a.target}"
292
+ if at == ActionType.CLIPBOARD_COPY:
293
+ length = a.params.get("text_length", 0)
294
+ return f"Copy to clipboard ({length} chars)"
295
+ if at == ActionType.UI_CLICK:
296
+ return f"Click {a.target_label or a.target or 'element'}"
297
+ if at == ActionType.UI_TYPE:
298
+ char = a.params.get("char", "")
299
+ return f"Type '{char}'" if char else "Type key"
300
+ if at == ActionType.UI_DRAG:
301
+ label = a.target_label or "element"
302
+ end_label = a.params.get("end_label", "") or "target"
303
+ return f"Drag {label} to {end_label}"
304
+ return f"{at.value} {a.target or ''}"
@@ -0,0 +1,91 @@
1
+ """Causal chain extraction from semantic action sequences.
2
+
3
+ Identifies only the actions that contribute to the final observable state,
4
+ filtering out detours, distractions, and non-contributing operations.
5
+ Used by the distillation layer to produce minimal, goal-directed skill steps.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import FrozenSet, List, Set
12
+
13
+ from leapflow.domain.trajectory import SemanticAction
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _EFFECT_ACTIONS: FrozenSet[str] = frozenset({
18
+ "file.create", "file.modify", "file.delete", "file.rename", "file.move",
19
+ "clipboard.copy", "batch_modify", "batch_rename", "batch_delete",
20
+ "batch_move", "batch_move_to_folder", "move_to_new_folder",
21
+ })
22
+
23
+ _SUPPORT_ACTIONS: FrozenSet[str] = frozenset({
24
+ "app.switch", "open_file_dialog", "transfer_data",
25
+ "create_and_edit", "download_organize",
26
+ })
27
+
28
+
29
+ class CausalChainAnalyzer:
30
+ """Extract causally-necessary steps from a semantic action sequence.
31
+
32
+ Algorithm:
33
+ 1. Forward scan: identify effect actions (file ops, clipboard writes)
34
+ 2. For each effect action, back-trace to the nearest un-claimed
35
+ support action (app.switch, open_file_dialog, etc.)
36
+ 3. Return only the causal subset, preserving original order.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ *,
42
+ effect_actions: FrozenSet[str] = _EFFECT_ACTIONS,
43
+ support_actions: FrozenSet[str] = _SUPPORT_ACTIONS,
44
+ ) -> None:
45
+ self._effects = effect_actions
46
+ self._supports = support_actions
47
+
48
+ def extract_causal_chain(
49
+ self, actions: List[SemanticAction]
50
+ ) -> List[SemanticAction]:
51
+ if len(actions) <= 2:
52
+ return list(actions)
53
+
54
+ causal_indices = self._find_causal_indices(actions)
55
+ if not causal_indices:
56
+ return list(actions)
57
+
58
+ result = [a for i, a in enumerate(actions) if i in causal_indices]
59
+ if len(result) < len(actions):
60
+ logger.debug(
61
+ "causal_chain: %d → %d actions (removed %d non-causal)",
62
+ len(actions), len(result), len(actions) - len(result),
63
+ )
64
+ return result
65
+
66
+ def _find_causal_indices(self, actions: List[SemanticAction]) -> Set[int]:
67
+ causal: List[int] = []
68
+ claimed: Set[int] = set()
69
+
70
+ for i, action in enumerate(actions):
71
+ if action.action_name in self._effects:
72
+ causal.append(i)
73
+ claimed.add(i)
74
+ self._backtrace_support(actions, i, causal, claimed)
75
+
76
+ return set(causal)
77
+
78
+ def _backtrace_support(
79
+ self,
80
+ actions: List[SemanticAction],
81
+ effect_idx: int,
82
+ causal: List[int],
83
+ claimed: Set[int],
84
+ ) -> None:
85
+ for j in range(effect_idx - 1, -1, -1):
86
+ if j in claimed:
87
+ break
88
+ if actions[j].action_name in self._supports:
89
+ causal.append(j)
90
+ claimed.add(j)
91
+ break
@@ -0,0 +1,111 @@
1
+ """Cross-trajectory consensus distillation.
2
+
3
+ When a user naturally performs the same task multiple times, each recording
4
+ will contain different noise (typos, detours, hesitations). The Longest
5
+ Common Subsequence across all trajectories converges on the essential steps,
6
+ filtering noise that varies between demonstrations.
7
+
8
+ Demo 1: [A, B, X, C, D] (X = mistake)
9
+ Demo 2: [A, B, C, Y, D] (Y = distraction)
10
+ Demo 3: [A, Z, B, C, D] (Z = hesitation)
11
+ LCS(1,2,3) = [A, B, C, D] ← clean skill
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from typing import TYPE_CHECKING, Dict, List, Optional, Sequence
18
+
19
+ from leapflow.domain.skill_types import DistillationCandidate
20
+
21
+ if TYPE_CHECKING:
22
+ from leapflow.analysis.pipeline import ImitationPipeline
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class MultiTrajectoryDistiller:
28
+ """Extract consensus skills from multiple demonstrations of the same task."""
29
+
30
+ def __init__(
31
+ self,
32
+ pipeline: "ImitationPipeline",
33
+ *,
34
+ min_trajectories: int = 2,
35
+ ) -> None:
36
+ self._pipeline = pipeline
37
+ self._min_count = min_trajectories
38
+
39
+ async def distill_consensus(
40
+ self, trajectory_ids: List[str]
41
+ ) -> Optional[DistillationCandidate]:
42
+ if len(trajectory_ids) < self._min_count:
43
+ return None
44
+
45
+ all_episode_actions: List[List[str]] = []
46
+ for tid in trajectory_ids:
47
+ episodes = await self._pipeline.analyze(tid)
48
+ if not episodes:
49
+ continue
50
+ best = max(episodes, key=lambda e: len(e.semantic_actions))
51
+ action_names = [a.action_name for a in best.semantic_actions]
52
+ all_episode_actions.append(action_names)
53
+
54
+ if len(all_episode_actions) < self._min_count:
55
+ return None
56
+
57
+ consensus = all_episode_actions[0]
58
+ for other in all_episode_actions[1:]:
59
+ consensus = _lcs(consensus, other)
60
+
61
+ if len(consensus) < 2:
62
+ return None
63
+
64
+ total = len(all_episode_actions)
65
+ step_frequency: Dict[str, int] = {}
66
+ for actions in all_episode_actions:
67
+ for a in set(actions):
68
+ step_frequency[a] = step_frequency.get(a, 0) + 1
69
+
70
+ confidence = sum(
71
+ step_frequency.get(s, 0) / total for s in consensus
72
+ ) / len(consensus)
73
+
74
+ logger.info(
75
+ "consensus: %d trajectories → %d steps (confidence=%.2f)",
76
+ len(trajectory_ids), len(consensus), confidence,
77
+ )
78
+
79
+ return DistillationCandidate(
80
+ title=f"Consensus skill ({len(trajectory_ids)} demos)",
81
+ trigger_phrases=[],
82
+ steps=consensus,
83
+ confidence=min(confidence, 0.95),
84
+ )
85
+
86
+
87
+ def _lcs(a: Sequence[str], b: Sequence[str]) -> List[str]:
88
+ """Longest common subsequence of two string sequences."""
89
+ m, n = len(a), len(b)
90
+ if m == 0 or n == 0:
91
+ return []
92
+ dp = [[0] * (n + 1) for _ in range(m + 1)]
93
+ for i in range(1, m + 1):
94
+ for j in range(1, n + 1):
95
+ if a[i - 1] == b[j - 1]:
96
+ dp[i][j] = dp[i - 1][j - 1] + 1
97
+ else:
98
+ dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
99
+ result: List[str] = []
100
+ i, j = m, n
101
+ while i > 0 and j > 0:
102
+ if a[i - 1] == b[j - 1]:
103
+ result.append(a[i - 1])
104
+ i -= 1
105
+ j -= 1
106
+ elif dp[i - 1][j] >= dp[i][j - 1]:
107
+ i -= 1
108
+ else:
109
+ j -= 1
110
+ result.reverse()
111
+ return result