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.
- leapflow/__init__.py +6 -0
- leapflow/__main__.py +13 -0
- leapflow/analysis/__init__.py +17 -0
- leapflow/analysis/abstractor.py +304 -0
- leapflow/analysis/causal.py +91 -0
- leapflow/analysis/consensus.py +111 -0
- leapflow/analysis/denoise.py +248 -0
- leapflow/analysis/episode_dedup.py +50 -0
- leapflow/analysis/intent_inferrer.py +367 -0
- leapflow/analysis/patterns.py +260 -0
- leapflow/analysis/pipeline.py +764 -0
- leapflow/analysis/segmenter.py +331 -0
- leapflow/analysis/synthesis.py +1348 -0
- leapflow/causal/__init__.py +35 -0
- leapflow/causal/adapter.py +202 -0
- leapflow/causal/channel.py +310 -0
- leapflow/causal/components.py +557 -0
- leapflow/causal/inference.py +627 -0
- leapflow/causal/pipeline.py +522 -0
- leapflow/causal/types.py +327 -0
- leapflow/cli/__init__.py +5 -0
- leapflow/cli/banner.py +229 -0
- leapflow/cli/cli.py +216 -0
- leapflow/cli/commands/__init__.py +1 -0
- leapflow/cli/commands/chat.py +18 -0
- leapflow/cli/commands/host.py +393 -0
- leapflow/cli/commands/interactive.py +375 -0
- leapflow/cli/commands/learn.py +551 -0
- leapflow/cli/commands/relearn.py +63 -0
- leapflow/cli/commands/run.py +262 -0
- leapflow/cli/commands/skills.py +290 -0
- leapflow/cli/context.py +924 -0
- leapflow/cli/helpers.py +76 -0
- leapflow/cli/tui.py +101 -0
- leapflow/config.py +608 -0
- leapflow/domain/__init__.py +48 -0
- leapflow/domain/events.py +87 -0
- leapflow/domain/perception.py +75 -0
- leapflow/domain/platform.py +98 -0
- leapflow/domain/skill_types.py +114 -0
- leapflow/domain/trajectory.py +234 -0
- leapflow/domain/ui_vocabulary.py +81 -0
- leapflow/engine/__init__.py +36 -0
- leapflow/engine/audit.py +73 -0
- leapflow/engine/confirmation.py +220 -0
- leapflow/engine/engine.py +953 -0
- leapflow/engine/graph_planner.py +363 -0
- leapflow/engine/intent_classifier.py +152 -0
- leapflow/engine/planner.py +80 -0
- leapflow/engine/resilience.py +5 -0
- leapflow/engine/scheduler.py +389 -0
- leapflow/engine/session.py +802 -0
- leapflow/engine/shortcuts.py +120 -0
- leapflow/engine/situational_assessor.py +189 -0
- leapflow/engine/task_graph.py +608 -0
- leapflow/engine/terminal_io.py +5 -0
- leapflow/host/__init__.py +39 -0
- leapflow/host/dev.py +340 -0
- leapflow/host/launchd.py +197 -0
- leapflow/host/manager.py +547 -0
- leapflow/host/permissions.py +160 -0
- leapflow/learning/__init__.py +17 -0
- leapflow/learning/active_learning.py +1244 -0
- leapflow/learning/codegen.py +773 -0
- leapflow/learning/distiller.py +463 -0
- leapflow/learning/doc_generator.py +657 -0
- leapflow/learning/document.py +443 -0
- leapflow/learning/feedback.py +339 -0
- leapflow/learning/similarity.py +303 -0
- leapflow/learning/stream_progress.py +5 -0
- leapflow/llm/__init__.py +21 -0
- leapflow/llm/base.py +52 -0
- leapflow/llm/message_builder.py +53 -0
- leapflow/llm/openai_provider.py +449 -0
- leapflow/memory/__init__.py +8 -0
- leapflow/memory/decay.py +33 -0
- leapflow/memory/immediate.py +175 -0
- leapflow/memory/long_term.py +316 -0
- leapflow/memory/working_memory.py +94 -0
- leapflow/perception/__init__.py +37 -0
- leapflow/perception/config.py +112 -0
- leapflow/perception/cv/__init__.py +1 -0
- leapflow/perception/cv/optical_flow.py +173 -0
- leapflow/perception/cv/phash.py +183 -0
- leapflow/perception/cv/scene_cut.py +102 -0
- leapflow/perception/cv/text_diff.py +156 -0
- leapflow/perception/cv/ui_detect.py +81 -0
- leapflow/perception/encoding/__init__.py +1 -0
- leapflow/perception/encoding/delta.py +303 -0
- leapflow/perception/encoding/encoder.py +118 -0
- leapflow/perception/encoding/tiler.py +184 -0
- leapflow/perception/extraction/__init__.py +1 -0
- leapflow/perception/extraction/extractor.py +394 -0
- leapflow/perception/extraction/feature_extractor.py +117 -0
- leapflow/perception/extraction/pipeline.py +369 -0
- leapflow/perception/extraction/preprocessor.py +123 -0
- leapflow/perception/extraction/refiner.py +152 -0
- leapflow/perception/extraction/router.py +80 -0
- leapflow/perception/sampling/__init__.py +1 -0
- leapflow/perception/session.py +464 -0
- leapflow/perception/signals.py +53 -0
- leapflow/perception/state_snapshot.py +241 -0
- leapflow/perception/storage/__init__.py +1 -0
- leapflow/perception/storage/deduplicator.py +121 -0
- leapflow/perception/storage/frame_store.py +145 -0
- leapflow/perception/storage/semantic_cache.py +129 -0
- leapflow/perception/types.py +350 -0
- leapflow/perception/video/__init__.py +25 -0
- leapflow/perception/video/analyzer.py +413 -0
- leapflow/perception/video/prompts.py +243 -0
- leapflow/perception/video/recorder.py +118 -0
- leapflow/perception/video/segmenter.py +143 -0
- leapflow/perception/video/timeline.py +250 -0
- leapflow/platform/__init__.py +37 -0
- leapflow/platform/adapters/__init__.py +1 -0
- leapflow/platform/adapters/darwin.py +259 -0
- leapflow/platform/adapters/mock.py +189 -0
- leapflow/platform/capabilities.py +128 -0
- leapflow/platform/client.py +366 -0
- leapflow/platform/event_bus.py +194 -0
- leapflow/platform/facade.py +100 -0
- leapflow/platform/mock.py +156 -0
- leapflow/platform/normalizer.py +434 -0
- leapflow/platform/protocol.py +145 -0
- leapflow/platform/relevance.py +74 -0
- leapflow/platform/reorder_buffer.py +76 -0
- leapflow/prompts/__init__.py +5 -0
- leapflow/prompts/templates.py +63 -0
- leapflow/recording/__init__.py +27 -0
- leapflow/recording/attention.py +937 -0
- leapflow/recording/attention_tuner.py +148 -0
- leapflow/recording/field_policy_loader.py +426 -0
- leapflow/recording/health.py +146 -0
- leapflow/recording/perceptual_field.py +463 -0
- leapflow/recording/recorder.py +789 -0
- leapflow/signal_fusion/__init__.py +51 -0
- leapflow/signal_fusion/action_agent.py +135 -0
- leapflow/signal_fusion/cross_app.py +172 -0
- leapflow/signal_fusion/episode_agent.py +313 -0
- leapflow/signal_fusion/integrator.py +250 -0
- leapflow/signal_fusion/pipeline.py +230 -0
- leapflow/signal_fusion/protocol.py +63 -0
- leapflow/signal_fusion/quality.py +89 -0
- leapflow/signal_fusion/segment_agent.py +258 -0
- leapflow/signal_fusion/types.py +305 -0
- leapflow/signal_fusion/wait_classifier.py +103 -0
- leapflow/skills/__init__.py +17 -0
- leapflow/skills/action_policy.py +218 -0
- leapflow/skills/activator.py +273 -0
- leapflow/skills/bridge_factory.py +200 -0
- leapflow/skills/builtin/__init__.py +5 -0
- leapflow/skills/builtin/app_launcher.py +41 -0
- leapflow/skills/builtin/clipboard_manager.py +41 -0
- leapflow/skills/builtin/file_organizer.py +124 -0
- leapflow/skills/conditions.py +219 -0
- leapflow/skills/registry.py +388 -0
- leapflow/skills/sandbox.py +221 -0
- leapflow/skills/semantic_adapter.py +501 -0
- leapflow/skills/tool_executor.py +910 -0
- leapflow/skills/ui_selector.py +201 -0
- leapflow/skills/ui_summarizer.py +159 -0
- leapflow/storage/__init__.py +13 -0
- leapflow/storage/bundle_writer.py +142 -0
- leapflow/storage/session_store.py +162 -0
- leapflow/storage/skill_docs.py +247 -0
- leapflow/storage/skill_library.py +729 -0
- leapflow/storage/trajectory_store.py +474 -0
- leapflow/utils/__init__.py +27 -0
- leapflow/utils/diagnostics.py +156 -0
- leapflow/utils/progress.py +167 -0
- leapflow/utils/resilience.py +71 -0
- leapflow/utils/stream_progress.py +73 -0
- leapflow/utils/terminal_io.py +24 -0
- leapflow/version.py +3 -0
- leapflow/world_model/__init__.py +29 -0
- leapflow/world_model/_json_utils.py +18 -0
- leapflow/world_model/budget.py +165 -0
- leapflow/world_model/curiosity.py +267 -0
- leapflow/world_model/embedding.py +117 -0
- leapflow/world_model/experience_store.py +348 -0
- leapflow/world_model/prediction.py +417 -0
- leapflow/world_model/replay.py +377 -0
- leapflow/world_model/trajectory_grader.py +213 -0
- leapflow-0.0.0.dist-info/METADATA +35 -0
- leapflow-0.0.0.dist-info/RECORD +189 -0
- leapflow-0.0.0.dist-info/WHEEL +5 -0
- leapflow-0.0.0.dist-info/entry_points.txt +2 -0
- leapflow-0.0.0.dist-info/licenses/LICENSE +201 -0
- leapflow-0.0.0.dist-info/top_level.txt +1 -0
leapflow/__init__.py
ADDED
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
|