meltygui 0.1.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.
- meltygui/__init__.py +107 -0
- meltygui/accounts/__init__.py +0 -0
- meltygui/accounts/internet_accounts.py +1355 -0
- meltygui/chat/__init__.py +91 -0
- meltygui/chat/activity.py +75 -0
- meltygui/chat/backends.py +36 -0
- meltygui/chat/chat_interface.py +732 -0
- meltygui/chat/chat_proxy.py +352 -0
- meltygui/chat/codex_proxy.py +592 -0
- meltygui/chat/codex_settings.py +100 -0
- meltygui/chat/codex_transport.py +60 -0
- meltygui/chat/command_parser.py +204 -0
- meltygui/chat/images.py +227 -0
- meltygui/chat/messages.py +417 -0
- meltygui/chat/metadata.py +139 -0
- meltygui/chat/writer_locks.py +64 -0
- meltygui/code/__init__.py +0 -0
- meltygui/code/basic_converters.py +533 -0
- meltygui/code/chain_converters.py +2111 -0
- meltygui/code/code_checks.py +2209 -0
- meltygui/code/core_syntax.py +1430 -0
- meltygui/code/file_converters.py +1933 -0
- meltygui/code/fileref.py +702 -0
- meltygui/code/hotswap_guard.py +144 -0
- meltygui/code/libcst_conversion.py +9724 -0
- meltygui/code/live_instrument.py +392 -0
- meltygui/code/live_view.py +2490 -0
- meltygui/code/melty_scan.py +2684 -0
- meltygui/code/new_codecs.py +1255 -0
- meltygui/code/new_converters.py +3017 -0
- meltygui/code/project_code.py +278 -0
- meltygui/code/source_context.py +63 -0
- meltygui/code/symbol_roster.py +1588 -0
- meltygui/code/syntax_check.py +34 -0
- meltygui/code/syntax_check_worker.py +114 -0
- meltygui/completion/__init__.py +0 -0
- meltygui/completion/fim.py +1232 -0
- meltygui/completion/fim_context.py +481 -0
- meltygui/completion/providers/__init__.py +0 -0
- meltygui/completion/providers/anthropic_oauth.py +446 -0
- meltygui/completion/providers/anthropic_requests.py +69 -0
- meltygui/completion/providers/claude.py +203 -0
- meltygui/completion/providers/claude_usage.py +499 -0
- meltygui/completion/providers/codex_accounts.py +180 -0
- meltygui/completion/providers/copilot.py +617 -0
- meltygui/completion/providers/oauth_popup.py +220 -0
- meltygui/completion/providers/ollama.py +320 -0
- meltygui/completion/providers/profiles.py +23 -0
- meltygui/core/README.md +88 -0
- meltygui/core/__init__.py +1 -0
- meltygui/core/automation/__init__.py +1 -0
- meltygui/core/automation/action_core.py +153 -0
- meltygui/core/automation/collection_action.py +45 -0
- meltygui/core/automation/mcp_eval.py +167 -0
- meltygui/core/automation/mcp_hotswap.py +107 -0
- meltygui/core/automation/mcp_query.py +552 -0
- meltygui/core/automation/mcp_server.py +657 -0
- meltygui/core/automation/orchestration_core.py +2636 -0
- meltygui/core/automation/query_core.py +124 -0
- meltygui/core/automation/search_core.py +26 -0
- meltygui/core/automation/selector_core.py +340 -0
- meltygui/core/automation/value_core.py +1752 -0
- meltygui/core/cache/__init__.py +1 -0
- meltygui/core/cache/cache_diagnostics.py +0 -0
- meltygui/core/cache/invalidation_decoration.py +153 -0
- meltygui/core/cache/invalidation_tracker.py +43 -0
- meltygui/core/cache/tile_cache.py +5968 -0
- meltygui/core/conversion/__init__.py +1 -0
- meltygui/core/conversion/bubbling.py +599 -0
- meltygui/core/conversion/cache_tree.py +188 -0
- meltygui/core/conversion/chain.py +113 -0
- meltygui/core/conversion/converter_register.py +145 -0
- meltygui/core/conversion/data_decoration.py +67 -0
- meltygui/core/conversion/dict_conversion.py +1815 -0
- meltygui/core/conversion/dict_conversion_util.py +177 -0
- meltygui/core/conversion/dynamic_obj.py +89 -0
- meltygui/core/conversion/graph_compare.py +183 -0
- meltygui/core/conversion/load_save_v2.py +1032 -0
- meltygui/core/conversion/missing_saved_class.py +39 -0
- meltygui/core/conversion/path_finder.py +606 -0
- meltygui/core/conversion/render_host.py +1083 -0
- meltygui/core/core_render.py +6537 -0
- meltygui/core/definition_hotswap.py +231 -0
- meltygui/core/diagnostics/__init__.py +1 -0
- meltygui/core/diagnostics/attribute_churn.py +38 -0
- meltygui/core/diagnostics/fps_counter.py +39 -0
- meltygui/core/diagnostics/gpu_frame_timer.py +103 -0
- meltygui/core/diagnostics/inspection_core.py +169 -0
- meltygui/core/diagnostics/monitor_core.py +105 -0
- meltygui/core/diagnostics/notifications.py +706 -0
- meltygui/core/diagnostics/perf_trace.py +281 -0
- meltygui/core/diagnostics/profile_decoration.py +88 -0
- meltygui/core/diagnostics/resize_trace.py +62 -0
- meltygui/core/diagnostics/screenshot_core.py +247 -0
- meltygui/core/diagnostics/session_status.py +98 -0
- meltygui/core/diagnostics/trace_core.py +445 -0
- meltygui/core/files/__init__.py +1 -0
- meltygui/core/files/file_core.py +208 -0
- meltygui/core/files/file_explorer_core.py +104 -0
- meltygui/core/files/file_tree_core.py +198 -0
- meltygui/core/files/file_watch_core.py +43 -0
- meltygui/core/files/import_graph_core.py +43 -0
- meltygui/core/files/metadata_core.py +51 -0
- meltygui/core/graphics/__init__.py +1 -0
- meltygui/core/graphics/cuda_context_core.py +166 -0
- meltygui/core/graphics/cuda_interop_core.py +136 -0
- meltygui/core/graphics/cuda_kernel_core.py +91 -0
- meltygui/core/graphics/framebuffer_recorder.py +337 -0
- meltygui/core/graphics/gl_state.py +658 -0
- meltygui/core/graphics/lut_core.py +52 -0
- meltygui/core/graphics/overlay_renderer.py +984 -0
- meltygui/core/graphics/scene_target.py +180 -0
- meltygui/core/graphics/screenshot.py +439 -0
- meltygui/core/graphics/shader_func.py +478 -0
- meltygui/core/graphics/tensor_core.py +45 -0
- meltygui/core/graphics/text_texture.py +329 -0
- meltygui/core/graphics/wayland_color.py +635 -0
- meltygui/core/input/__init__.py +1 -0
- meltygui/core/input/collision.py +165 -0
- meltygui/core/input/drag_drop_core.py +1525 -0
- meltygui/core/input/hypr_left_drag.py +323 -0
- meltygui/core/input/input_core.py +245 -0
- meltygui/core/input/input_handler.py +1101 -0
- meltygui/core/input/mouse_cursor.py +355 -0
- meltygui/core/input/pynput_backend.py +1054 -0
- meltygui/core/input/space_mouse.py +338 -0
- meltygui/core/input/touchpad_backend.py +393 -0
- meltygui/core/input/view_selection.py +177 -0
- meltygui/core/layout/__init__.py +1 -0
- meltygui/core/layout/column_core.py +2153 -0
- meltygui/core/layout/cursor_core.py +161 -0
- meltygui/core/layout/dropdown_core.py +278 -0
- meltygui/core/layout/edge_constraints.py +155 -0
- meltygui/core/layout/grid_core.py +117 -0
- meltygui/core/layout/header_core.py +23 -0
- meltygui/core/layout/header_runtime.py +67 -0
- meltygui/core/layout/layout_core.py +87 -0
- meltygui/core/layout/tile_manager_core.py +591 -0
- meltygui/core/melty.py +6726 -0
- meltygui/core/module_map.json +896 -0
- meltygui/core/module_names.py +19 -0
- meltygui/core/rendering/__init__.py +1 -0
- meltygui/core/rendering/core_decoration.py +431 -0
- meltygui/core/rendering/core_render_helpers.py +328 -0
- meltygui/core/rendering/func_metadata.py +398 -0
- meltygui/core/rendering/mode.py +818 -0
- meltygui/core/rendering/mode_defaults.py +41 -0
- meltygui/core/rendering/modes.py +136 -0
- meltygui/core/rendering/parameter_core.py +1665 -0
- meltygui/core/rendering/render_dispatch.py +1891 -0
- meltygui/core/rendering/render_funcs.py +273 -0
- meltygui/core/rendering/shaped.py +312 -0
- meltygui/core/rendering/window_decoration.py +25 -0
- meltygui/core/runtime/__init__.py +1 -0
- meltygui/core/runtime/app.py +735 -0
- meltygui/core/runtime/app_session.py +140 -0
- meltygui/core/runtime/background.py +564 -0
- meltygui/core/runtime/extensions.py +53 -0
- meltygui/core/runtime/gc_manager.py +1163 -0
- meltygui/core/runtime/lifecycle.py +21 -0
- meltygui/core/runtime/paths.py +27 -0
- meltygui/core/runtime/settings.py +14 -0
- meltygui/core/runtime/singleton.py +16 -0
- meltygui/core/runtime/thread_safe_bool.py +24 -0
- meltygui/core/runtime/thread_signal.py +30 -0
- meltygui/core/runtime/toggles.py +3115 -0
- meltygui/core/services/__init__.py +1 -0
- meltygui/core/services/account_core.py +10 -0
- meltygui/core/services/chat_core.py +19 -0
- meltygui/core/services/claude_terminal_core.py +346 -0
- meltygui/core/services/terminal_core.py +458 -0
- meltygui/core/services/terminal_runtime.py +78 -0
- meltygui/core/styling/__init__.py +1 -0
- meltygui/core/styling/color_core.py +46 -0
- meltygui/core/styling/fonts.py +639 -0
- meltygui/core/styling/global_style.py +338 -0
- meltygui/core/styling/style.py +198 -0
- meltygui/core/styling/style_core.py +522 -0
- meltygui/core/styling/warm_start.py +149 -0
- meltygui/core/windowing/__init__.py +1 -0
- meltygui/core/windowing/backends/PYIMGUI_LICENSE +28 -0
- meltygui/core/windowing/backends/__init__.py +1 -0
- meltygui/core/windowing/backends/imgui_renderer.py +138 -0
- meltygui/core/windowing/backends/native_wayland.py +850 -0
- meltygui/core/windowing/backends/protocols/xdg-decoration-unstable-v1.xml +156 -0
- meltygui/core/windowing/backends/protocols/xdg-shell.xml +1420 -0
- meltygui/core/windowing/backends/wayland_protocol.py +101 -0
- meltygui/core/windowing/dock_core.py +182 -0
- meltygui/core/windowing/frame_geometry.py +42 -0
- meltygui/core/windowing/geometry_feed.py +864 -0
- meltygui/core/windowing/glfw_utils.py +1343 -0
- meltygui/core/windowing/os_frame.py +1552 -0
- meltygui/core/windowing/surface.py +643 -0
- meltygui/core/windowing/titlebar.py +1560 -0
- meltygui/core/windowing/titlebar_buttons.py +281 -0
- meltygui/core/windowing/wayland_move.py +932 -0
- meltygui/core/windowing/window_api.py +62 -0
- meltygui/core/windowing/window_constants.py +339 -0
- meltygui/core/windowing/window_visibility.py +162 -0
- meltygui/debug/__init__.py +0 -0
- meltygui/debug/app_view_utils.py +9 -0
- meltygui/editor/__init__.py +0 -0
- meltygui/editor/bash_syntax.py +30 -0
- meltygui/editor/code_line_fast.py +152 -0
- meltygui/editor/diff.py +139 -0
- meltygui/editor/external_changes.py +159 -0
- meltygui/editor/file_header.py +41 -0
- meltygui/editor/live_usage.py +169 -0
- meltygui/editor/live_view_views.py +1803 -0
- meltygui/editor/pending_save.py +1351 -0
- meltygui/editor/roster_tints.py +547 -0
- meltygui/editor/source_preview.py +16 -0
- meltygui/editor/source_tools.py +10 -0
- meltygui/editor/source_ui.py +70 -0
- meltygui/editor/spell_check.py +77 -0
- meltygui/editor/text_editor.py +9076 -0
- meltygui/editor/usage_picker.py +538 -0
- meltygui/events/__init__.py +0 -0
- meltygui/events/example.py +118 -0
- meltygui/examples/__init__.py +0 -0
- meltygui/examples/columns_demo.py +33 -0
- meltygui/examples/columns_window_demo.py +60 -0
- meltygui/examples/context_menu_demo.py +24 -0
- meltygui/examples/context_menu_window_demo.py +49 -0
- meltygui/examples/gui_playground.py +127 -0
- meltygui/examples/live_view_playground.py +256 -0
- meltygui/examples/lora.py +43 -0
- meltygui/examples/lora_data.py +59 -0
- meltygui/examples/lora_policies.py +67 -0
- meltygui/examples/mode_demo.py +49 -0
- meltygui/examples/modifies_demo.py +119 -0
- meltygui/examples/scalar_policies.py +60 -0
- meltygui/examples/style_layouts.py +149 -0
- meltygui/examples/tile_manager_demo.py +74 -0
- meltygui/examples/tint_demo.py +160 -0
- meltygui/examples/tint_functions.py +56 -0
- meltygui/examples/trace_demo.py +88 -0
- meltygui/examples/two_windows.py +36 -0
- meltygui/files/__init__.py +0 -0
- meltygui/files/fast_file_explorer.py +439 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/extension.js +237 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/lsd-window-geometry@latent-descent.iml +9 -0
- meltygui/gnome_extension/lsd-window-geometry@latent-descent/metadata.json +7 -0
- meltygui/graphics/__init__.py +6 -0
- meltygui/graphics/base.py +85 -0
- meltygui/graphics/examples.py +507 -0
- meltygui/graphics/executor.py +520 -0
- meltygui/graphics/filter.py +667 -0
- meltygui/graphics/filter.pyi +856 -0
- meltygui/graphics/generate_stubs.py +22 -0
- meltygui/graphics/registry.py +203 -0
- meltygui/graphics/shader_compiler.py +155 -0
- meltygui/graphics/shaders.py +1302 -0
- meltygui/graphics/stub_generator.py +250 -0
- meltygui/graphics/texture_manager.py +170 -0
- meltygui/graphics/texture_min_max.py +295 -0
- meltygui/hdr_color.py +757 -0
- meltygui/image_load.py +308 -0
- meltygui/model/__init__.py +1 -0
- meltygui/model/account_model.py +142 -0
- meltygui/model/camera_model.py +159 -0
- meltygui/model/chat_model.py +86 -0
- meltygui/model/code_model.py +62 -0
- meltygui/model/code_proxy_model.py +876 -0
- meltygui/model/collection_model.py +41 -0
- meltygui/model/color_model.py +127 -0
- meltygui/model/cuda_tensor_model.py +36 -0
- meltygui/model/cuda_texture_model.py +149 -0
- meltygui/model/dropdown_model.py +137 -0
- meltygui/model/file_metadata_model.py +73 -0
- meltygui/model/file_model.py +289 -0
- meltygui/model/format_model.py +390 -0
- meltygui/model/graph_model.py +98 -0
- meltygui/model/icon_model.py +1024 -0
- meltygui/model/import_graph_model.py +468 -0
- meltygui/model/layout_model.py +15 -0
- meltygui/model/lut_model.py +266 -0
- meltygui/model/search_model.py +173 -0
- meltygui/model/tensor_model.py +402 -0
- meltygui/model/terminal_model.py +329 -0
- meltygui/model/texture_model.py +146 -0
- meltygui/model/tile_model.py +24 -0
- meltygui/model/trace_model.py +198 -0
- meltygui/model/trace_report_model.py +146 -0
- meltygui/models/__init__.py +0 -0
- meltygui/models/file_meta.py +613 -0
- meltygui/models/function_console.py +184 -0
- meltygui/models/orchestration.py +48 -0
- meltygui/pbr.py +1576 -0
- meltygui/png_unfilter.c +49 -0
- meltygui/resources/JetBrainsMono-Regular.ttf +0 -0
- meltygui/resources/THIRD_PARTY_NOTICES.md +21 -0
- meltygui/resources/dejavu/DejaVuSans-Bold.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans-ExtraLight.ttf +0 -0
- meltygui/resources/dejavu/DejaVuSans.ttf +0 -0
- meltygui/resources/dejavu/LICENSE.txt +78 -0
- meltygui/resources/fontawesome-LICENSE.txt +121 -0
- meltygui/resources/fontawesome-webfont.ttf +0 -0
- meltygui/resources/hdri/studio_small_09_1k.hdr +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Bold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-ExtraLight.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Light.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Medium.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-SemiBold.ttf +0 -0
- meltygui/resources/jetbrains-weights/JetBrainsMono-Thin.ttf +0 -0
- meltygui/resources/jetbrains-weights/OFL.txt +93 -0
- meltygui/resources/jetbrains-weights/README.md +2 -0
- meltygui/state/__init__.py +0 -0
- meltygui/state/account_state.py +22 -0
- meltygui/state/animation_state.py +97 -0
- meltygui/state/annotation_state.py +22 -0
- meltygui/state/chat_state.py +36 -0
- meltygui/state/code_state.py +10 -0
- meltygui/state/core_enums.py +59 -0
- meltygui/state/core_markers.py +115 -0
- meltygui/state/core_undo.py +1075 -0
- meltygui/state/file_state.py +75 -0
- meltygui/state/graph_state.py +26 -0
- meltygui/state/inspection_state.py +47 -0
- meltygui/state/menu_state.py +10 -0
- meltygui/state/model_enums.py +11 -0
- meltygui/state/new_core_model.py +2394 -0
- meltygui/state/orchestration_state.py +14 -0
- meltygui/state/query_state.py +24 -0
- meltygui/state/tensor_state.py +10 -0
- meltygui/state/terminal_state.py +23 -0
- meltygui/state/trace_state.py +32 -0
- meltygui/state/voxel_state.py +12 -0
- meltygui/text_index.py +816 -0
- meltygui/utils/__init__.py +0 -0
- meltygui/utils/jump_to_code.py +344 -0
- meltygui/utils/pkl_inspect.py +90 -0
- meltygui/utils/render_utils.py +1419 -0
- meltygui/view/__init__.py +1 -0
- meltygui/view/account_view.py +524 -0
- meltygui/view/action_view.py +73 -0
- meltygui/view/chat_decoration_view.py +112 -0
- meltygui/view/chat_view.py +1703 -0
- meltygui/view/code_view.py +2677 -0
- meltygui/view/collection_view.py +1185 -0
- meltygui/view/color_view.py +824 -0
- meltygui/view/control_view.py +559 -0
- meltygui/view/decoration_view.py +439 -0
- meltygui/view/diagnostic_view.py +177 -0
- meltygui/view/dropdown_view.py +1082 -0
- meltygui/view/file_view.py +1599 -0
- meltygui/view/graph_cuda_view.py +105 -0
- meltygui/view/graph_view.py +882 -0
- meltygui/view/header_view.py +848 -0
- meltygui/view/input_view.py +238 -0
- meltygui/view/inspection_view.py +1783 -0
- meltygui/view/layout_view.py +596 -0
- meltygui/view/lut_view.py +45 -0
- meltygui/view/menu_view.py +212 -0
- meltygui/view/orchestration_view.py +658 -0
- meltygui/view/query_view.py +118 -0
- meltygui/view/search_view.py +342 -0
- meltygui/view/tab_view.py +258 -0
- meltygui/view/tensor_view.py +430 -0
- meltygui/view/terminal_view.py +355 -0
- meltygui/view/text_view.py +8031 -0
- meltygui/view/texture_view.py +480 -0
- meltygui/view/tile_view.py +38 -0
- meltygui/view/trace_view.py +923 -0
- meltygui/view/voxel_cuda_view.py +824 -0
- meltygui/view/voxel_view.py +1860 -0
- meltygui/view/window_view.py +111 -0
- meltygui-0.1.0.dist-info/METADATA +143 -0
- meltygui-0.1.0.dist-info/RECORD +372 -0
- meltygui-0.1.0.dist-info/WHEEL +4 -0
- meltygui-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,1232 @@
|
|
|
1
|
+
"""FIM (fill-in-the-middle) code completion: the contract, the registries and
|
|
2
|
+
the per-editor broker. `draw_text` talks ONLY to `FimState`; providers talk
|
|
3
|
+
ONLY to `FimRequest`/`FimResult`; nothing in the editor knows which backend
|
|
4
|
+
answered.
|
|
5
|
+
|
|
6
|
+
draw_text ──► FimState (per draw_state, injected like GLState)
|
|
7
|
+
│ debounce · key dedup · cancel · chunked buffer · prefetch
|
|
8
|
+
▼
|
|
9
|
+
@fim_provider function (stateless, one per backend)
|
|
10
|
+
│ session kwargs split off its FimSession.__init__
|
|
11
|
+
▼
|
|
12
|
+
FimSession (per connection config: LS process, HTTP client, login)
|
|
13
|
+
pooled on Melty, REFCOUNTED by editors, idle-closed
|
|
14
|
+
|
|
15
|
+
Three lifetimes, deliberately separate:
|
|
16
|
+
* FimSession — one per (provider, connection config). Two editors on the
|
|
17
|
+
same config share one; "copilot-work" and "copilot-home"
|
|
18
|
+
are two. Never a singleton.
|
|
19
|
+
* FimState — one per editor draw_state: which profile, the in-flight
|
|
20
|
+
request, the chunked ghost buffer, the pinned context.
|
|
21
|
+
* FimRequest — one per keystroke burst.
|
|
22
|
+
|
|
23
|
+
Chunked acceptance: the provider always fetches a LONG completion
|
|
24
|
+
(`Toggles.Fim.max_tokens`, streamed); the editor only ever SHOWS one chunk of
|
|
25
|
+
it (`Toggles.Fim.chunk_lines` newline-terminated segments). Tab splices the
|
|
26
|
+
visible chunk and the next chunk becomes the ghost instantly — no request.
|
|
27
|
+
When the buffer runs low and the stream has ended, a continuation request
|
|
28
|
+
is prefetched from the virtual caret so arbitrarily long completions stay
|
|
29
|
+
snappy, and a small chunk gives the user a steering decision every few
|
|
30
|
+
lines instead of after a 40-line dump.
|
|
31
|
+
|
|
32
|
+
Coordinates: the broker's virtual document lives in BUFFER coordinates (the
|
|
33
|
+
editor span, what `draw_text` edits); a request is built in FILE coordinates
|
|
34
|
+
(`EditorView.file_head` + buffer + `file_tail`, PENDING truth) so providers
|
|
35
|
+
see the whole file.
|
|
36
|
+
|
|
37
|
+
Registries live on `Melty` (`_fim_providers`, `_fim_profiles`,
|
|
38
|
+
`_fim_context_sources`, `_fim_sessions`) so hotswap's registry reconcile
|
|
39
|
+
(`_FUNC_REGISTRY_NAMES` in file_converters) keeps them pointing at the live
|
|
40
|
+
functions after a recompile re-runs the decorators.
|
|
41
|
+
"""
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import inspect
|
|
45
|
+
import re
|
|
46
|
+
import threading
|
|
47
|
+
import time
|
|
48
|
+
import traceback
|
|
49
|
+
import weakref
|
|
50
|
+
from dataclasses import dataclass, field
|
|
51
|
+
from typing import Any, Callable
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
55
|
+
# Registries (on Melty - see module docstring). Accessed lazily so this
|
|
56
|
+
# module imports headless (tests) without dragging imgui in.
|
|
57
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
def _melty():
|
|
60
|
+
from meltygui.core.melty import Melty
|
|
61
|
+
return Melty
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _reg(name):
|
|
65
|
+
m = _melty()
|
|
66
|
+
reg = getattr(m, name, None)
|
|
67
|
+
if reg is None:
|
|
68
|
+
reg = {}
|
|
69
|
+
setattr(m, name, reg)
|
|
70
|
+
return reg
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def providers() -> dict:
|
|
74
|
+
return _reg("_fim_providers")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def profiles() -> dict:
|
|
78
|
+
return _reg("_fim_profiles")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def context_sources() -> dict:
|
|
82
|
+
return _reg("_fim_context_sources")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _sessions() -> dict:
|
|
86
|
+
"""The live session pool — kept on `sys` (not Melty) so it SURVIVES an
|
|
87
|
+
in-process restart (the server purges every `src.*` module and
|
|
88
|
+
re-imports; `sys` does not), keeping Copilot language-server processes
|
|
89
|
+
and HTTP clients alive so a restart never re-spawns or re-logs-in.
|
|
90
|
+
|
|
91
|
+
The pool's `session_key` is (module string, qualname string, kwargs),
|
|
92
|
+
all stable across a re-exec, so the re-imported session classes resolve
|
|
93
|
+
to the same cached instances. A cached instance pins its BIRTH module
|
|
94
|
+
generation through its class (and the Copilot reader thread) — bounded
|
|
95
|
+
to one generation because sessions are created once and reused, which
|
|
96
|
+
is the unavoidable cost of holding a live subprocess across restarts.
|
|
97
|
+
Real process exit: the Copilot LS self-terminates on parent-PID death
|
|
98
|
+
(`processId` in initialize), and HTTP clients hold nothing that leaks,
|
|
99
|
+
so no atexit closer is registered (that would pin gen-1 Melty)."""
|
|
100
|
+
import sys
|
|
101
|
+
pool = getattr(sys, "_lsd_fim_sessions", None)
|
|
102
|
+
if pool is None:
|
|
103
|
+
pool = sys._lsd_fim_sessions = {}
|
|
104
|
+
return pool
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _session_alive(sess) -> bool:
|
|
108
|
+
"""A pooled session is reusable unless its `alive()` says otherwise (a
|
|
109
|
+
Copilot LS that exited, an HTTP client closed)."""
|
|
110
|
+
try:
|
|
111
|
+
chk = getattr(sess, "alive", None)
|
|
112
|
+
return bool(chk()) if callable(chk) else True
|
|
113
|
+
except Exception:
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
118
|
+
# Contract
|
|
119
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
TIERS = ("stable", "run", "volatile")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class ContextItem:
|
|
126
|
+
"""One piece of context the model sees. `tier` decides WHERE in the
|
|
127
|
+
prompt it goes (stable → run → volatile, cache breakpoints after the
|
|
128
|
+
first two); `score` decides membership only — never order, a wobbling
|
|
129
|
+
order would re-byte the cached prefix."""
|
|
130
|
+
kind: str # definition | signature | enclosing | caller | outline | runtime_types | live_values | call_stack | error
|
|
131
|
+
key: tuple # identity for dedupe/diff
|
|
132
|
+
text: str # final rendered text
|
|
133
|
+
tier: str = "stable" # stable | run | volatile
|
|
134
|
+
score: float = 1.0
|
|
135
|
+
path: str | None = None
|
|
136
|
+
line: int | None = None # 1-based file line of the item's start
|
|
137
|
+
end: int | None = None
|
|
138
|
+
version: Any = None # an invalidation token (pending gen / run gen)
|
|
139
|
+
data: Any = None # kind-specific: definition → its signature line (degrade target);
|
|
140
|
+
# live_values → {0-based buffer line: summary}
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def tokens(self) -> int:
|
|
144
|
+
return max(1, len(self.text) // 4)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class FimContext:
|
|
149
|
+
"""The fitted, deterministically ordered context for one editor."""
|
|
150
|
+
items: list = field(default_factory=list)
|
|
151
|
+
key: tuple = ()
|
|
152
|
+
|
|
153
|
+
def tier(self, name) -> list:
|
|
154
|
+
return [it for it in self.items if it.tier == name]
|
|
155
|
+
|
|
156
|
+
def tokens(self) -> int:
|
|
157
|
+
return sum(it.tokens for it in self.items)
|
|
158
|
+
|
|
159
|
+
def render(self, tiers=("stable",), header="# {path}:{line}-{end}") -> str:
|
|
160
|
+
"""Plain-text rendering of the given tiers, in stored order, each
|
|
161
|
+
item under a provenance header when it has one."""
|
|
162
|
+
out = []
|
|
163
|
+
for it in self.items:
|
|
164
|
+
if it.tier not in tiers or it.kind == "live_values":
|
|
165
|
+
continue
|
|
166
|
+
if it.path and it.line:
|
|
167
|
+
out.append(header.format(path=it.path, line=it.line,
|
|
168
|
+
end=it.end if it.end else it.line))
|
|
169
|
+
out.append(it.text.rstrip("\n"))
|
|
170
|
+
out.append("")
|
|
171
|
+
return "\n".join(out).rstrip("\n")
|
|
172
|
+
|
|
173
|
+
def line_annotations(self) -> dict:
|
|
174
|
+
"""{0-based buffer line: summary} merged from every `live_values`
|
|
175
|
+
item — providers splice these into the prefix as trailing comments."""
|
|
176
|
+
ann = {}
|
|
177
|
+
for it in self.items:
|
|
178
|
+
if it.kind == "live_values" and isinstance(it.data, dict):
|
|
179
|
+
ann.update(it.data)
|
|
180
|
+
return ann
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass
|
|
184
|
+
class EditorView:
|
|
185
|
+
"""What the editor knows about itself — the input to context sources
|
|
186
|
+
and the file-coordinate frame for requests. Built per editor body run,
|
|
187
|
+
so everything O(file) is lazy: `file_head`/`file_tail` (the PENDING
|
|
188
|
+
file text before/after the buffer) are computed on first access, i.e.
|
|
189
|
+
at submit time, and `fn` (the live function object / store owner) is
|
|
190
|
+
resolved when context is assembled."""
|
|
191
|
+
path: str | None
|
|
192
|
+
text: str # editor text (the span)
|
|
193
|
+
cursor: int # caret offset into `text`
|
|
194
|
+
address: Any = None # the span's Address (path/start/end)
|
|
195
|
+
fn: Any = None
|
|
196
|
+
language: str = "python"
|
|
197
|
+
version: Any = None # pending version of `path`
|
|
198
|
+
_head: str | None = None
|
|
199
|
+
_tail: str | None = None
|
|
200
|
+
|
|
201
|
+
def _split(self):
|
|
202
|
+
from meltygui.completion.fim_context import file_head_tail
|
|
203
|
+
self._head, self._tail = file_head_tail(self.text, self.address)
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def file_head(self) -> str:
|
|
207
|
+
if self._head is None:
|
|
208
|
+
self._split()
|
|
209
|
+
return self._head
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def file_tail(self) -> str:
|
|
213
|
+
if self._tail is None:
|
|
214
|
+
self._split()
|
|
215
|
+
return self._tail
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def span_start(self) -> int:
|
|
219
|
+
"""0-based file line the buffer starts at."""
|
|
220
|
+
start = getattr(self.address, "start", None) if self.address is not None else None
|
|
221
|
+
return int(start) if isinstance(start, int) and start >= 0 else 0
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def caret_line(self) -> int:
|
|
225
|
+
return self.text.count("\n", 0, self.cursor)
|
|
226
|
+
|
|
227
|
+
def resolve_fn(self):
|
|
228
|
+
if self.fn is None and self.path is not None:
|
|
229
|
+
from meltygui.completion.fim_context import span_function
|
|
230
|
+
self.fn = span_function(self.path, self.span_start)
|
|
231
|
+
return self.fn
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass
|
|
235
|
+
class FimRequest:
|
|
236
|
+
"""One completion request in FILE coordinates: `text` is the whole file
|
|
237
|
+
(pending truth) so a provider can window it however it likes;
|
|
238
|
+
`prefix`/`suffix` are the convenience split at `cursor`."""
|
|
239
|
+
path: str | None
|
|
240
|
+
text: str
|
|
241
|
+
cursor: int
|
|
242
|
+
language: str
|
|
243
|
+
version: Any
|
|
244
|
+
context: FimContext
|
|
245
|
+
max_tokens: int
|
|
246
|
+
cancelled: threading.Event
|
|
247
|
+
emit: Callable[[str], None] # emit the FULL text produced so far (streaming)
|
|
248
|
+
span_start: int = 0 # buffer line 0 == file line span_start (for requests)
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def prefix(self) -> str:
|
|
252
|
+
return self.text[:self.cursor]
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def suffix(self) -> str:
|
|
256
|
+
return self.text[self.cursor:]
|
|
257
|
+
|
|
258
|
+
def annotated_prefix(self, prefix=None, marker=" # ← ") -> str:
|
|
259
|
+
"""`prefix` (default: the request's) with the context's live-value
|
|
260
|
+
summaries appended to their lines as trailing comments — what the
|
|
261
|
+
live view shows a human, shown to the model. The caret's own
|
|
262
|
+
(partial) line is never annotated."""
|
|
263
|
+
if prefix is None:
|
|
264
|
+
prefix = self.prefix
|
|
265
|
+
ann = self.context.line_annotations() if self.context is not None else {}
|
|
266
|
+
if not ann:
|
|
267
|
+
return prefix
|
|
268
|
+
lines = prefix.split("\n")
|
|
269
|
+
for bl, summary in ann.items():
|
|
270
|
+
fl = bl + self.span_start
|
|
271
|
+
if 0 <= fl < len(lines) - 1 and lines[fl].strip():
|
|
272
|
+
lines[fl] = lines[fl].rstrip() + marker + summary
|
|
273
|
+
return "\n".join(lines)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@dataclass
|
|
277
|
+
class FimResult:
|
|
278
|
+
text: str # insert at cursor
|
|
279
|
+
replace_to: int = 0 # consume this many suffix chars (for text edits)
|
|
280
|
+
alternatives: tuple = () # other candidates' full text
|
|
281
|
+
token: Any = None # arbitrary, handed back to the provider's _on_accept
|
|
282
|
+
provider: str = ""
|
|
283
|
+
truncated: bool = False # the model hit the token limit (more to come) or
|
|
284
|
+
# finished on a stop token. Only a truncated
|
|
285
|
+
# completion is auto-continued when the buffer
|
|
286
|
+
# drains - its natural stop is the end.
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
290
|
+
# Sessions
|
|
291
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
class FimSession:
|
|
294
|
+
"""Base class for a live connection (LS subprocess, HTTP client, login).
|
|
295
|
+
Subclass `__init__(self, **config)` — its parameter NAMES are what
|
|
296
|
+
`fim_profile(...)` kwargs get routed here (everything else goes to the
|
|
297
|
+
provider function per request). Must be safe for concurrent `complete`
|
|
298
|
+
calls from several editors' worker threads."""
|
|
299
|
+
_refs = 0
|
|
300
|
+
_idle_since = 0.0
|
|
301
|
+
_key = None
|
|
302
|
+
_profile = ""
|
|
303
|
+
|
|
304
|
+
def status(self):
|
|
305
|
+
"""("ready",) | ("needs_login", user_code, url) | ("error", msg)"""
|
|
306
|
+
return ("ready",)
|
|
307
|
+
|
|
308
|
+
def alive(self):
|
|
309
|
+
"""False when the session can no longer serve requests (a crashed
|
|
310
|
+
subprocess) and must be recreated on the next acquire. HTTP-client
|
|
311
|
+
sessions stay alive; process-backed ones override this."""
|
|
312
|
+
return True
|
|
313
|
+
|
|
314
|
+
def close(self):
|
|
315
|
+
pass
|
|
316
|
+
|
|
317
|
+
def restart(self):
|
|
318
|
+
self.close()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _session_param_names(session_cls) -> set:
|
|
322
|
+
if session_cls is None:
|
|
323
|
+
return set()
|
|
324
|
+
try:
|
|
325
|
+
sig = inspect.signature(session_cls.__init__)
|
|
326
|
+
except (TypeError, ValueError):
|
|
327
|
+
return set()
|
|
328
|
+
return {n for n, p in sig.parameters.items()
|
|
329
|
+
if n != "self" and p.kind in (p.POSITIONAL_OR_KEYWORD, p.KEYWORD_ONLY)}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def split_kwargs(provider_fn, kwargs: dict):
|
|
333
|
+
"""(session_kwargs, request_kwargs) for a profile's kwargs."""
|
|
334
|
+
names = _session_param_names(getattr(provider_fn, "_fim_session_cls", None))
|
|
335
|
+
s = {k: v for k, v in kwargs.items() if k in names}
|
|
336
|
+
r = {k: v for k, v in kwargs.items() if k not in names}
|
|
337
|
+
return s, r
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def session_key(session_cls, session_kwargs: dict) -> tuple:
|
|
341
|
+
return (session_cls.__module__, session_cls.__qualname__,
|
|
342
|
+
tuple(sorted(session_kwargs.items())))
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
_pool_lock = threading.Lock()
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def acquire_session(profile_name: str):
|
|
349
|
+
"""The pooled session for `profile_name` (+1 ref), or None for a
|
|
350
|
+
provider without sessions. Raises on construction failure. Called from
|
|
351
|
+
worker threads (constructing a session may import an SDK or spawn a
|
|
352
|
+
process — never on the render thread); the pool is locked."""
|
|
353
|
+
with _pool_lock:
|
|
354
|
+
return _acquire_session_locked(profile_name)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _acquire_session_locked(profile_name: str):
|
|
358
|
+
prof = profiles().get(profile_name)
|
|
359
|
+
if prof is None:
|
|
360
|
+
raise KeyError(f"unknown FIM profile {profile_name!r} "
|
|
361
|
+
f"(known: {sorted(profiles())})")
|
|
362
|
+
fn = providers().get(prof.provider)
|
|
363
|
+
if fn is None:
|
|
364
|
+
raise KeyError(f"profile {profile_name!r}: unknown provider {prof.provider!r}")
|
|
365
|
+
cls = getattr(fn, "_fim_session_cls", None)
|
|
366
|
+
if cls is None:
|
|
367
|
+
return None
|
|
368
|
+
skw, _ = split_kwargs(fn, dict(prof.kwargs))
|
|
369
|
+
key = session_key(cls, skw)
|
|
370
|
+
pool = _sessions()
|
|
371
|
+
sess = pool.get(key)
|
|
372
|
+
if sess is not None and not _session_alive(sess):
|
|
373
|
+
pool.pop(key, None)
|
|
374
|
+
try:
|
|
375
|
+
sess.close()
|
|
376
|
+
except Exception:
|
|
377
|
+
pass
|
|
378
|
+
sess = None
|
|
379
|
+
if sess is None:
|
|
380
|
+
sess = cls(**skw)
|
|
381
|
+
sess._key = key
|
|
382
|
+
sess._profile = profile_name
|
|
383
|
+
sess._refs = 0
|
|
384
|
+
pool[key] = sess
|
|
385
|
+
sess._refs += 1
|
|
386
|
+
return sess
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def session_for(session_cls, session_kwargs: dict, create=True):
|
|
390
|
+
"""The pooled session for an explicit (class, kwargs) — the SAME key a
|
|
391
|
+
profile with those session kwargs resolves to, so e.g. the Internet
|
|
392
|
+
Accounts window and the copilot provider share one LS process. No
|
|
393
|
+
refcount is taken (the idle sweep reclaims it). `create=False` only
|
|
394
|
+
peeks."""
|
|
395
|
+
key = session_key(session_cls, session_kwargs)
|
|
396
|
+
with _pool_lock:
|
|
397
|
+
pool = _sessions()
|
|
398
|
+
sess = pool.get(key)
|
|
399
|
+
if sess is not None and not _session_alive(sess):
|
|
400
|
+
pool.pop(key, None)
|
|
401
|
+
try:
|
|
402
|
+
sess.close()
|
|
403
|
+
except Exception:
|
|
404
|
+
pass
|
|
405
|
+
sess = None
|
|
406
|
+
if sess is None and create:
|
|
407
|
+
sess = session_cls(**session_kwargs)
|
|
408
|
+
sess._key = key
|
|
409
|
+
sess._profile = ""
|
|
410
|
+
sess._refs = 0
|
|
411
|
+
sess._idle_since = time.monotonic()
|
|
412
|
+
pool[key] = sess
|
|
413
|
+
return sess
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def drop_sessions(pred):
|
|
417
|
+
"""Close every pooled session `pred(sess)` selects and make the editors
|
|
418
|
+
holding it re-acquire (a credential changed). Returns the count."""
|
|
419
|
+
pool = _sessions()
|
|
420
|
+
dropped = []
|
|
421
|
+
with _pool_lock:
|
|
422
|
+
for key, sess in list(pool.items()):
|
|
423
|
+
try:
|
|
424
|
+
hit = pred(sess)
|
|
425
|
+
except Exception:
|
|
426
|
+
hit = False
|
|
427
|
+
if hit:
|
|
428
|
+
pool.pop(key, None)
|
|
429
|
+
dropped.append(sess)
|
|
430
|
+
for sess in dropped:
|
|
431
|
+
for st in list(_live_states):
|
|
432
|
+
if st.session is sess:
|
|
433
|
+
st.session = None
|
|
434
|
+
st._session_resolved = False
|
|
435
|
+
st._session_error = None
|
|
436
|
+
try:
|
|
437
|
+
sess.close()
|
|
438
|
+
except Exception as e:
|
|
439
|
+
print(f"[fim] session close failed: {e}")
|
|
440
|
+
return len(dropped)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def release_session(sess):
|
|
444
|
+
if sess is None:
|
|
445
|
+
return
|
|
446
|
+
sess._refs = max(0, sess._refs - 1)
|
|
447
|
+
if sess._refs == 0:
|
|
448
|
+
sess._idle_since = time.monotonic()
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def sweep_sessions(now=None, idle_s=None):
|
|
452
|
+
"""Close zero-ref sessions idle longer than `idle_s`
|
|
453
|
+
(`Toggles.Fim.session_idle_s`). Returns the number closed."""
|
|
454
|
+
if idle_s is None:
|
|
455
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
456
|
+
idle_s = Toggles.Fim.session_idle_s
|
|
457
|
+
now = time.monotonic() if now is None else now
|
|
458
|
+
pool = _sessions()
|
|
459
|
+
n = 0
|
|
460
|
+
for key, sess in list(pool.items()):
|
|
461
|
+
if sess._refs <= 0 and now - sess._idle_since >= idle_s:
|
|
462
|
+
pool.pop(key, None)
|
|
463
|
+
try:
|
|
464
|
+
sess.close()
|
|
465
|
+
except Exception as e:
|
|
466
|
+
print(f"[fim] session close failed: {e}")
|
|
467
|
+
n += 1
|
|
468
|
+
return n
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def restart_session(profile_name: str):
|
|
472
|
+
"""Drop the pooled session for a profile (editors re-acquire lazily) —
|
|
473
|
+
the escape hatch after hotswapping a session class's __init__."""
|
|
474
|
+
pool = _sessions()
|
|
475
|
+
for key, sess in list(pool.items()):
|
|
476
|
+
if sess._profile == profile_name:
|
|
477
|
+
pool.pop(key, None)
|
|
478
|
+
try:
|
|
479
|
+
sess.close()
|
|
480
|
+
except Exception as e:
|
|
481
|
+
print(f"[fim] session close failed: {e}")
|
|
482
|
+
for st in list(_live_states):
|
|
483
|
+
if st.profile_name == profile_name:
|
|
484
|
+
st.session = None
|
|
485
|
+
st._session_resolved = False
|
|
486
|
+
st._session_error = None
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def detach_sessions_for_restart():
|
|
490
|
+
"""Keep every pooled session alive across an in-process restart: zero
|
|
491
|
+
its refcount (the current generation's FimStates are about to be
|
|
492
|
+
dropped with their module) and stamp it idle-now, so the next
|
|
493
|
+
generation re-acquires it within the idle window rather than
|
|
494
|
+
re-spawning. Closes nothing."""
|
|
495
|
+
now = time.monotonic()
|
|
496
|
+
for sess in list(_sessions().values()):
|
|
497
|
+
sess._refs = 0
|
|
498
|
+
sess._idle_since = now
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def shutdown_all_sessions():
|
|
502
|
+
"""Close and drop EVERY pooled session (real teardown). Not used on the
|
|
503
|
+
restart path — see FimState.shutdown_all — only where a genuine full
|
|
504
|
+
close is wanted."""
|
|
505
|
+
pool = _sessions()
|
|
506
|
+
for key, sess in list(pool.items()):
|
|
507
|
+
pool.pop(key, None)
|
|
508
|
+
try:
|
|
509
|
+
sess.close()
|
|
510
|
+
except Exception:
|
|
511
|
+
pass
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
515
|
+
# Decorators
|
|
516
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
517
|
+
|
|
518
|
+
@dataclass(frozen=True)
|
|
519
|
+
class FimProfile:
|
|
520
|
+
name: str
|
|
521
|
+
provider: str # provider NAME (hotswap-stable; resolved at runtime)
|
|
522
|
+
kwargs: tuple # sorted (k, v) pairs
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def fim_provider(name: str, session=None):
|
|
526
|
+
"""Register a provider function `fn(req: FimRequest, session, **params)
|
|
527
|
+
-> FimResult`. `session` is the FimSession subclass whose instances the
|
|
528
|
+
broker hands in (None for stateless providers). Params on the function's
|
|
529
|
+
signature are the per-request knobs; a profile overrides them by name.
|
|
530
|
+
Optional hooks by attribute: `fn._on_shown(req, result)`,
|
|
531
|
+
`fn._on_accept(result, accepted_chars)`.
|
|
532
|
+
Also registers a default profile under the provider's own name."""
|
|
533
|
+
def deco(fn):
|
|
534
|
+
fn._fim_name = name
|
|
535
|
+
fn._fim_session_cls = session
|
|
536
|
+
providers()[name] = fn
|
|
537
|
+
profiles().setdefault(name, FimProfile(name, name, ()))
|
|
538
|
+
return fn
|
|
539
|
+
return deco
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def fim_profile(name: str, provider, **kwargs):
|
|
543
|
+
"""A named configuration of a provider: `fim_profile("copilot-work",
|
|
544
|
+
copilot_fim, config_dir=...)`. Kwargs naming the session class's __init__
|
|
545
|
+
params select/construct the session; the rest override the provider
|
|
546
|
+
function's per-request params."""
|
|
547
|
+
pname = provider if isinstance(provider, str) else getattr(provider, "_fim_name", None)
|
|
548
|
+
if not pname:
|
|
549
|
+
raise ValueError(f"fim_profile({name!r}): provider must be a name or a @fim_provider")
|
|
550
|
+
prof = FimProfile(name, pname, tuple(sorted(kwargs.items())))
|
|
551
|
+
profiles()[name] = prof
|
|
552
|
+
return prof
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def fim_context_source(kind: str, tier: str = "stable", order: int = 100):
|
|
556
|
+
"""Register `fn(view: EditorView) -> Iterable[ContextItem]`. `order` is
|
|
557
|
+
the deterministic position of this source's items within its tier."""
|
|
558
|
+
def deco(fn):
|
|
559
|
+
fn._fim_kind = kind
|
|
560
|
+
fn._fim_tier = tier
|
|
561
|
+
fn._fim_order = order
|
|
562
|
+
context_sources()[kind] = fn
|
|
563
|
+
return fn
|
|
564
|
+
return deco
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def profile_names() -> list:
|
|
568
|
+
return sorted(profiles())
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
572
|
+
# Chunking
|
|
573
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
574
|
+
|
|
575
|
+
def _segments(buffer: str) -> list:
|
|
576
|
+
return buffer.splitlines(keepends=True)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
_CURSOR_TAIL_RE = re.compile(r"\n[ \t]+$")
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def split_chunk(buffer: str, chunk_lines: int, complete_only: bool = False) -> str:
|
|
583
|
+
"""The first chunk of `buffer`: any leading blank segments (the model
|
|
584
|
+
moving to a new line) plus up to `chunk_lines` content segments — and
|
|
585
|
+
NEVER a trailing newline: the chunk's line end stays in the buffer, so
|
|
586
|
+
accepting leaves the caret at the end of what was inserted and the
|
|
587
|
+
NEXT chunk begins with "\\n" + the model's own indentation (the model
|
|
588
|
+
decides where the caret goes). With `complete_only` (stream still
|
|
589
|
+
running) an unterminated last segment is held back. A whitespace-only
|
|
590
|
+
remainder is a chunk only when it is "\\n" + indentation (a final
|
|
591
|
+
caret placement) and the stream is done."""
|
|
592
|
+
parts = _segments(buffer)
|
|
593
|
+
if not parts:
|
|
594
|
+
return ""
|
|
595
|
+
n = len(parts)
|
|
596
|
+
i = 0
|
|
597
|
+
while i < n and parts[i].strip() == "":
|
|
598
|
+
i += 1
|
|
599
|
+
if i == n:
|
|
600
|
+
if not complete_only and _CURSOR_TAIL_RE.search(buffer):
|
|
601
|
+
return buffer
|
|
602
|
+
return ""
|
|
603
|
+
taken = 0
|
|
604
|
+
while i < n and taken < max(1, chunk_lines):
|
|
605
|
+
i += 1
|
|
606
|
+
taken += 1
|
|
607
|
+
if complete_only and i == n and not parts[-1].endswith("\n"):
|
|
608
|
+
i -= 1
|
|
609
|
+
chunk = "".join(parts[:i])
|
|
610
|
+
if chunk.endswith("\n"):
|
|
611
|
+
chunk = chunk[:-1]
|
|
612
|
+
return chunk if chunk.strip() else ""
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def strip_cursor_tail(buffer: str) -> str:
|
|
616
|
+
"""Everything in `buffer` worth inserting at once (accept-all): a bare
|
|
617
|
+
trailing newline run is dropped, "\\n" + indentation is kept."""
|
|
618
|
+
if _CURSOR_TAIL_RE.search(buffer):
|
|
619
|
+
return buffer
|
|
620
|
+
return buffer.rstrip("\n")
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def content_lines(buffer: str) -> int:
|
|
624
|
+
return sum(1 for p in _segments(buffer) if p.strip())
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def at_line_end(text: str, cursor: int) -> bool:
|
|
628
|
+
"""True when nothing but whitespace follows the caret on its own line —
|
|
629
|
+
the FIM trigger condition (don't generate in the MIDDLE of a line).
|
|
630
|
+
Trailing spaces and the rest of the file below are ignored."""
|
|
631
|
+
rest = text[cursor:]
|
|
632
|
+
nl = rest.find("\n")
|
|
633
|
+
line_rest = rest if nl < 0 else rest[:nl]
|
|
634
|
+
return line_rest.strip() == ""
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
_WORD_RE = re.compile(r"[^\S\n]*(?:\n[^\S\n]*|[A-Za-z0-9_]+|[^\sA-Za-z0-9_]+)")
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def first_word(chunk: str) -> str:
|
|
641
|
+
m = _WORD_RE.match(chunk)
|
|
642
|
+
return m.group(0) if m else chunk[:1]
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
646
|
+
# Per-editor state (injected: `fim_state: FimState = None` on draw_text)
|
|
647
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
648
|
+
|
|
649
|
+
@dataclass
|
|
650
|
+
class Ghost:
|
|
651
|
+
text: str # the visible chunk ("" while the first chunk streams in)
|
|
652
|
+
more_lines: int = 0 # content lines buffered beyond the chunk
|
|
653
|
+
pending: bool = False # a request is in flight
|
|
654
|
+
profile: str = ""
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
_live_states = weakref.WeakSet()
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _wake(ds):
|
|
661
|
+
"""Wake the render loop once from a worker thread (tile invalidate +
|
|
662
|
+
glfw event) — the loop is parked in wait_events, and request_render
|
|
663
|
+
no-ops off the GL thread. Mirrors text_editor._wake_on_future."""
|
|
664
|
+
try:
|
|
665
|
+
tile = getattr(ds, "_tile_id", None) if ds is not None else None
|
|
666
|
+
if tile is not None:
|
|
667
|
+
_melty().cache.invalidate(tile)
|
|
668
|
+
except Exception:
|
|
669
|
+
pass
|
|
670
|
+
try:
|
|
671
|
+
import meltygui.core.windowing.glfw_utils as glfw_utils
|
|
672
|
+
glfw_utils._needs_render.set()
|
|
673
|
+
except Exception:
|
|
674
|
+
pass
|
|
675
|
+
try:
|
|
676
|
+
if getattr(_melty(), "vis", None) is not None: # a window exists (not headless)
|
|
677
|
+
import meltygui.core.windowing.window_api as glfw
|
|
678
|
+
glfw.post_empty_event()
|
|
679
|
+
except Exception:
|
|
680
|
+
pass
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
class FimState:
|
|
684
|
+
"""Broker + ghost buffer for ONE editor. Constructed by render_func's
|
|
685
|
+
injected-state path (no-arg), bound to its draw_state via `_owner_ds`.
|
|
686
|
+
|
|
687
|
+
Virtual document model (buffer coordinates): `_vtext` = prefix-at-
|
|
688
|
+
request + everything the provider produced; `_vcursor` = how far into
|
|
689
|
+
it the real buffer has caught up (accept / prefix-consume move it
|
|
690
|
+
forward). The ghost buffer is `_vtext[_vcursor:]`. `_vsuffix` is the
|
|
691
|
+
suffix the completion was made for — any change to it invalidates the
|
|
692
|
+
generation."""
|
|
693
|
+
_owner_ds = None
|
|
694
|
+
|
|
695
|
+
def __init__(self):
|
|
696
|
+
self.profile_name = ""
|
|
697
|
+
self.session = None
|
|
698
|
+
self.error = None
|
|
699
|
+
self._lock = threading.RLock()
|
|
700
|
+
self._gen = 0 # increments on every invalidation
|
|
701
|
+
self._vtext = ""
|
|
702
|
+
self._vcursor = 0
|
|
703
|
+
self._vsuffix = None # None = no active generation
|
|
704
|
+
self._streaming = False
|
|
705
|
+
self._inflight = None # FimRequest in flight
|
|
706
|
+
self._inflight_origin = 0 # virtual offset the text started at
|
|
707
|
+
self._thread = None
|
|
708
|
+
self._timer = None
|
|
709
|
+
self._key = None # last scheduled request key
|
|
710
|
+
self._dismissed_key = None
|
|
711
|
+
self._exhausted = False # continuation returned nothing
|
|
712
|
+
self._last_wake = 0.0
|
|
713
|
+
self._wake_lines = 0
|
|
714
|
+
self._last_sweep = 0.0
|
|
715
|
+
self._session_profile = None # profile the session slot was resolved for
|
|
716
|
+
self._session_error = None
|
|
717
|
+
self._session_err_time = 0.0
|
|
718
|
+
self.alternatives = ()
|
|
719
|
+
self.context = None
|
|
720
|
+
self.context_key = None
|
|
721
|
+
self.context_time = 0.0
|
|
722
|
+
self.last_result = None
|
|
723
|
+
self.stats = {"requests": 0, "accepted_chunks": 0, "invalidations": 0}
|
|
724
|
+
_live_states.add(self)
|
|
725
|
+
|
|
726
|
+
def __reduce__(self):
|
|
727
|
+
return (FimState, ())
|
|
728
|
+
|
|
729
|
+
# ── profile / session ─────────────────────────────────────────────
|
|
730
|
+
def _ensure_profile(self, profile_name):
|
|
731
|
+
"""Render thread: switch profiles (drop the old session ref and any
|
|
732
|
+
generation made with it). The new session is acquired lazily on the
|
|
733
|
+
worker thread by `_session_for_work`."""
|
|
734
|
+
if profile_name == self._session_profile:
|
|
735
|
+
return
|
|
736
|
+
release_session(self.session)
|
|
737
|
+
self.session = None
|
|
738
|
+
self._session_resolved = False
|
|
739
|
+
self.profile_name = profile_name
|
|
740
|
+
self._session_profile = profile_name
|
|
741
|
+
self._session_error = None
|
|
742
|
+
self._session_err_time = 0.0
|
|
743
|
+
self._key = None # re-request at the current site
|
|
744
|
+
if self.active or self._timer is not None:
|
|
745
|
+
self.invalidate("profile")
|
|
746
|
+
|
|
747
|
+
_session_resolved = False
|
|
748
|
+
|
|
749
|
+
def _session_for_work(self):
|
|
750
|
+
"""Worker thread: the profile's pooled session (None for a
|
|
751
|
+
sessionless provider). A failed construction is re-raised for 5 s
|
|
752
|
+
without retrying the constructor."""
|
|
753
|
+
if self._session_resolved:
|
|
754
|
+
return self.session
|
|
755
|
+
if (self._session_error is not None
|
|
756
|
+
and time.monotonic() - self._session_err_time < 5.0):
|
|
757
|
+
raise RuntimeError(self._session_error)
|
|
758
|
+
try:
|
|
759
|
+
sess = acquire_session(self.profile_name)
|
|
760
|
+
except Exception as e:
|
|
761
|
+
self._session_error = str(e)
|
|
762
|
+
self._session_err_time = time.monotonic()
|
|
763
|
+
raise
|
|
764
|
+
self._session_error = None
|
|
765
|
+
self.session = sess
|
|
766
|
+
self._session_resolved = True
|
|
767
|
+
return sess
|
|
768
|
+
|
|
769
|
+
def _provider(self):
|
|
770
|
+
prof = profiles().get(self.profile_name)
|
|
771
|
+
if prof is None:
|
|
772
|
+
return None, {}
|
|
773
|
+
fn = providers().get(prof.provider)
|
|
774
|
+
if fn is None:
|
|
775
|
+
return None, {}
|
|
776
|
+
_, rkw = split_kwargs(fn, dict(prof.kwargs))
|
|
777
|
+
return fn, rkw
|
|
778
|
+
|
|
779
|
+
# ── buffer ─────────────────────────────────────────────────────────
|
|
780
|
+
@property
|
|
781
|
+
def buffer(self) -> str:
|
|
782
|
+
with self._lock:
|
|
783
|
+
return self._vtext[self._vcursor:] if self._vsuffix is not None else ""
|
|
784
|
+
|
|
785
|
+
@property
|
|
786
|
+
def active(self) -> bool:
|
|
787
|
+
return self._vsuffix is not None
|
|
788
|
+
|
|
789
|
+
def invalidate(self, reason=""):
|
|
790
|
+
"""Drop the generation: cancel in-flight work, clear the buffer."""
|
|
791
|
+
with self._lock:
|
|
792
|
+
self._gen += 1
|
|
793
|
+
if self._inflight is not None:
|
|
794
|
+
self._inflight.cancelled.set()
|
|
795
|
+
self._inflight = None
|
|
796
|
+
self._vtext = ""
|
|
797
|
+
self._vcursor = 0
|
|
798
|
+
self._vsuffix = None
|
|
799
|
+
self._streaming = False
|
|
800
|
+
self._exhausted = False
|
|
801
|
+
self.alternatives = ()
|
|
802
|
+
self.stats["invalidations"] += 1
|
|
803
|
+
t = self._timer
|
|
804
|
+
self._timer = None
|
|
805
|
+
self._armed = None
|
|
806
|
+
if t is not None:
|
|
807
|
+
t.cancel()
|
|
808
|
+
|
|
809
|
+
def dismiss(self):
|
|
810
|
+
"""Esc: drop the buffer and don't re-request at this site (the
|
|
811
|
+
caret's CURRENT site, which accepts may have moved past the last
|
|
812
|
+
scheduled one) until the caret moves on."""
|
|
813
|
+
self._dismissed_key = self._cur_key
|
|
814
|
+
self.invalidate("dismiss")
|
|
815
|
+
|
|
816
|
+
_cur_key = None
|
|
817
|
+
|
|
818
|
+
def _reconcile(self, text, cursor):
|
|
819
|
+
"""Advance the virtual cursor when the real buffer typed/accepted
|
|
820
|
+
exactly what the ghost predicted; invalidate on any divergence."""
|
|
821
|
+
with self._lock:
|
|
822
|
+
if self._vsuffix is None:
|
|
823
|
+
return
|
|
824
|
+
prefix = text[:cursor]
|
|
825
|
+
if text[cursor:] != self._vsuffix:
|
|
826
|
+
self.invalidate("suffix")
|
|
827
|
+
return
|
|
828
|
+
n = len(prefix)
|
|
829
|
+
if n < self._vcursor:
|
|
830
|
+
self.invalidate("backspace")
|
|
831
|
+
return
|
|
832
|
+
if n > len(self._vtext):
|
|
833
|
+
# typed past anything produced so far (exhausted, or
|
|
834
|
+
# ahead of a stream) - a fresh request from here is cheap
|
|
835
|
+
self.invalidate("overrun")
|
|
836
|
+
return
|
|
837
|
+
if self._vtext[:n] != prefix:
|
|
838
|
+
self.invalidate("diverged")
|
|
839
|
+
return
|
|
840
|
+
self._vcursor = n
|
|
841
|
+
|
|
842
|
+
# ── scheduling ──────────────────────────────────────────────────
|
|
843
|
+
@staticmethod
|
|
844
|
+
def _request_key(text, cursor):
|
|
845
|
+
return (cursor, len(text), text[max(0, cursor - 48):cursor], text[cursor:cursor + 48])
|
|
846
|
+
|
|
847
|
+
def poll(self, text, cursor, *, view: EditorView, profile: str = "",
|
|
848
|
+
enabled=True, ds=None, now=None, typed=True) -> Ghost | None:
|
|
849
|
+
"""Called from the editor body after the key handlers. Reconciles
|
|
850
|
+
the buffer with the real text, schedules/continues requests, and
|
|
851
|
+
returns what to draw (or None). `typed` = the buffer changed this
|
|
852
|
+
frame: only typing arms a fresh request — a caret move never does,
|
|
853
|
+
and one during the debounce cancels the armed request."""
|
|
854
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
855
|
+
if ds is not None:
|
|
856
|
+
self._owner_ds = ds
|
|
857
|
+
if not enabled or not Toggles.Fim.enabled:
|
|
858
|
+
if self.active or self._timer is not None:
|
|
859
|
+
self.invalidate("disabled")
|
|
860
|
+
return None
|
|
861
|
+
now = time.monotonic() if now is None else now
|
|
862
|
+
self._ensure_profile(profile or Toggles.Fim.profile)
|
|
863
|
+
self._reconcile(text, cursor)
|
|
864
|
+
key = self._request_key(text, cursor)
|
|
865
|
+
self._cur_key = key
|
|
866
|
+
if key != self._dismissed_key:
|
|
867
|
+
self._dismissed_key = None
|
|
868
|
+
chunk_lines = max(1, Toggles.Fim.chunk_lines)
|
|
869
|
+
with self._lock:
|
|
870
|
+
chunk = self._chunk(chunk_lines)
|
|
871
|
+
ahead = content_lines(self.buffer[len(chunk):])
|
|
872
|
+
active = self.active
|
|
873
|
+
need_more = (active and not self._streaming and self._inflight is None
|
|
874
|
+
and not self._exhausted and Toggles.Fim.prefetch
|
|
875
|
+
and ahead < chunk_lines)
|
|
876
|
+
pending = self._inflight is not None
|
|
877
|
+
if not active and key != self._key and self._armed is not None:
|
|
878
|
+
# the site moved during the debounce: drop the armed request
|
|
879
|
+
self._armed = None
|
|
880
|
+
t = self._timer
|
|
881
|
+
self._timer = None
|
|
882
|
+
if t is not None:
|
|
883
|
+
t.cancel()
|
|
884
|
+
if not active:
|
|
885
|
+
if key != self._key:
|
|
886
|
+
self._key = key
|
|
887
|
+
# Only generate on typing, at a site the user hasn't seen,
|
|
888
|
+
# and (when only_at_line_end is on) with nothing but space
|
|
889
|
+
# after the cursor on this line - no mid-line completions.
|
|
890
|
+
if (typed and self._dismissed_key != key
|
|
891
|
+
and (not Toggles.Fim.only_at_line_end or at_line_end(text, cursor))):
|
|
892
|
+
self._schedule(text, cursor, view, Toggles.Fim.debounce_s, now)
|
|
893
|
+
elif self._fire_armed(text, cursor, view, now):
|
|
894
|
+
pending = True
|
|
895
|
+
elif need_more:
|
|
896
|
+
self._key = key
|
|
897
|
+
self._schedule(None, None, view, 0.0, now, continuation=True)
|
|
898
|
+
pending = True
|
|
899
|
+
if now - self._last_sweep > 30.0:
|
|
900
|
+
self._last_sweep = now
|
|
901
|
+
try:
|
|
902
|
+
sweep_sessions(now)
|
|
903
|
+
except Exception:
|
|
904
|
+
pass
|
|
905
|
+
if not chunk:
|
|
906
|
+
return Ghost("", 0, True, self.profile_name) if pending else None
|
|
907
|
+
return Ghost(chunk, ahead, pending, self.profile_name)
|
|
908
|
+
|
|
909
|
+
def _chunk(self, chunk_lines):
|
|
910
|
+
buf = self.buffer
|
|
911
|
+
if not buf:
|
|
912
|
+
return ""
|
|
913
|
+
# Hold back an unterminated final line ONLY while it is genuinely
|
|
914
|
+
# still streaming in - i.e. the in-flight request appends at (or
|
|
915
|
+
# before) the shown buffer's start (the initial request:
|
|
916
|
+
# _inflight_origin == _vcursor). A CONTINUATION appends beyond the
|
|
917
|
+
# already-shown buffer (_inflight_origin > _vcursor), so the visible
|
|
918
|
+
# first chunk is settled text and must never be held back: doing so
|
|
919
|
+
# blinked the ghost out for each continuation round trip (the
|
|
920
|
+
# appear → disappear → reappear flicker).
|
|
921
|
+
streaming_tail = self._streaming and self._inflight_origin <= self._vcursor
|
|
922
|
+
return split_chunk(buf, chunk_lines, complete_only=streaming_tail)
|
|
923
|
+
|
|
924
|
+
def _refresh_context(self, view, now):
|
|
925
|
+
if self.context is not None and not self._context_stale(view, now):
|
|
926
|
+
return
|
|
927
|
+
try:
|
|
928
|
+
self.context = assemble_context(view)
|
|
929
|
+
self.context_key = self.context.key
|
|
930
|
+
self.context_time = now
|
|
931
|
+
except Exception as e:
|
|
932
|
+
if self.context is None:
|
|
933
|
+
self.context = FimContext()
|
|
934
|
+
self.error = f"context: {e}"
|
|
935
|
+
|
|
936
|
+
def _schedule(self, text, cursor, view, delay, now, continuation=False):
|
|
937
|
+
"""Arm (or fire) a request. With a delay the request is only ARMED:
|
|
938
|
+
a timer wakes the render loop when the debounce elapses and the
|
|
939
|
+
next `poll` (render thread — the roster is not thread-safe) does
|
|
940
|
+
the context assembly + submit. So a caret-move burst costs nothing
|
|
941
|
+
but timer churn; the heavy work runs once, when the user pauses.
|
|
942
|
+
Continuations (delay 0) assemble + submit right here."""
|
|
943
|
+
with self._lock:
|
|
944
|
+
t = self._timer
|
|
945
|
+
self._timer = None
|
|
946
|
+
if continuation:
|
|
947
|
+
if self._vsuffix is None:
|
|
948
|
+
return
|
|
949
|
+
text = self._vtext + self._vsuffix
|
|
950
|
+
cursor = len(self._vtext)
|
|
951
|
+
gen = self._gen
|
|
952
|
+
self._armed = None
|
|
953
|
+
if t is not None:
|
|
954
|
+
t.cancel()
|
|
955
|
+
if delay <= 0:
|
|
956
|
+
self._submit(text, cursor, view, gen, continuation)
|
|
957
|
+
return
|
|
958
|
+
self._armed = (gen, now + delay)
|
|
959
|
+
ds = self._owner_ds
|
|
960
|
+
|
|
961
|
+
def fire():
|
|
962
|
+
self._timer = None
|
|
963
|
+
_wake(ds)
|
|
964
|
+
|
|
965
|
+
timer = threading.Timer(delay, fire)
|
|
966
|
+
timer.daemon = True
|
|
967
|
+
self._timer = timer
|
|
968
|
+
timer.start()
|
|
969
|
+
|
|
970
|
+
_armed = None # (gen, due) of a debounced fresh request awaiting its poll
|
|
971
|
+
|
|
972
|
+
def _fire_armed(self, text, cursor, view, now):
|
|
973
|
+
"""Submit the armed request if its debounce has elapsed (called
|
|
974
|
+
from poll with the CURRENT buffer — same key, so the same site)."""
|
|
975
|
+
armed = self._armed
|
|
976
|
+
if armed is None or self._timer is not None:
|
|
977
|
+
return False
|
|
978
|
+
gen, due = armed
|
|
979
|
+
if now < due:
|
|
980
|
+
return False
|
|
981
|
+
self._armed = None
|
|
982
|
+
if gen != self._gen:
|
|
983
|
+
return False
|
|
984
|
+
self._submit(text, cursor, view, gen, False)
|
|
985
|
+
return True
|
|
986
|
+
|
|
987
|
+
def _context_stale(self, view, now):
|
|
988
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
989
|
+
try:
|
|
990
|
+
key = context_key_for(view)
|
|
991
|
+
except Exception:
|
|
992
|
+
return False
|
|
993
|
+
if key != self.context_key:
|
|
994
|
+
return True
|
|
995
|
+
return now - self.context_time > Toggles.Fim.stable_refresh_s
|
|
996
|
+
|
|
997
|
+
def _submit(self, text, cursor, view, gen, continuation):
|
|
998
|
+
"""Start the worker for a request over the virtual buffer
|
|
999
|
+
(`text`/`cursor` in buffer coordinates)."""
|
|
1000
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
1001
|
+
fn, rkw = self._provider()
|
|
1002
|
+
if fn is None:
|
|
1003
|
+
self.error = f"no provider for profile {self.profile_name!r}"
|
|
1004
|
+
return
|
|
1005
|
+
self._refresh_context(view, time.monotonic())
|
|
1006
|
+
head = view.file_head if view is not None else ""
|
|
1007
|
+
tail = view.file_tail if view is not None else ""
|
|
1008
|
+
with self._lock:
|
|
1009
|
+
if gen != self._gen or self._inflight is not None:
|
|
1010
|
+
return
|
|
1011
|
+
cancelled = threading.Event()
|
|
1012
|
+
if not continuation:
|
|
1013
|
+
self._vtext = text[:cursor]
|
|
1014
|
+
self._vcursor = cursor
|
|
1015
|
+
self._vsuffix = text[cursor:]
|
|
1016
|
+
self._exhausted = False
|
|
1017
|
+
origin = cursor
|
|
1018
|
+
self._inflight_origin = origin
|
|
1019
|
+
self._streaming = True
|
|
1020
|
+
req = FimRequest(
|
|
1021
|
+
path=view.path if view is not None else None,
|
|
1022
|
+
text=head + text + tail, cursor=len(head) + cursor,
|
|
1023
|
+
language=view.language if view is not None else "python",
|
|
1024
|
+
version=view.version if view is not None else None,
|
|
1025
|
+
context=self.context or FimContext(),
|
|
1026
|
+
max_tokens=Toggles.Fim.max_tokens,
|
|
1027
|
+
cancelled=cancelled,
|
|
1028
|
+
emit=lambda t, g=gen, o=origin: self._on_partial(g, o, t),
|
|
1029
|
+
span_start=view.span_start if view is not None else 0)
|
|
1030
|
+
self._inflight = req
|
|
1031
|
+
self.stats["requests"] += 1
|
|
1032
|
+
ds = self._owner_ds
|
|
1033
|
+
|
|
1034
|
+
def work():
|
|
1035
|
+
result = None
|
|
1036
|
+
err = None
|
|
1037
|
+
try:
|
|
1038
|
+
session = self._session_for_work()
|
|
1039
|
+
result = fn(req, session, **rkw)
|
|
1040
|
+
except Exception as e:
|
|
1041
|
+
err = e
|
|
1042
|
+
if not cancelled.is_set() and Toggles.Fim.debug_print:
|
|
1043
|
+
traceback.print_exc()
|
|
1044
|
+
self._on_done(gen, origin, req, result, err)
|
|
1045
|
+
_wake(ds)
|
|
1046
|
+
|
|
1047
|
+
th = threading.Thread(target=work, name=f"fim-{self.profile_name}", daemon=True)
|
|
1048
|
+
self._thread = th
|
|
1049
|
+
th.start()
|
|
1050
|
+
|
|
1051
|
+
# ── worker thread callbacks ────────────────────────────────────────
|
|
1052
|
+
def _on_partial(self, gen, origin, full_text):
|
|
1053
|
+
with self._lock:
|
|
1054
|
+
if gen != self._gen or self._vsuffix is None:
|
|
1055
|
+
return
|
|
1056
|
+
self._vtext = self._vtext[:origin] + full_text
|
|
1057
|
+
self._vcursor = min(self._vcursor, len(self._vtext))
|
|
1058
|
+
lines = full_text.count("\n")
|
|
1059
|
+
now = time.monotonic()
|
|
1060
|
+
if lines != self._wake_lines or now - self._last_wake > 0.15:
|
|
1061
|
+
self._wake_lines = lines
|
|
1062
|
+
self._last_wake = now
|
|
1063
|
+
_wake(self._owner_ds)
|
|
1064
|
+
|
|
1065
|
+
def _on_done(self, gen, origin, req, result, err):
|
|
1066
|
+
with self._lock:
|
|
1067
|
+
if self._inflight is req:
|
|
1068
|
+
self._inflight = None
|
|
1069
|
+
if gen != self._gen or self._vsuffix is None:
|
|
1070
|
+
return
|
|
1071
|
+
self._streaming = False
|
|
1072
|
+
if err is not None:
|
|
1073
|
+
self.error = f"{self.profile_name}: {err}"
|
|
1074
|
+
self._exhausted = True
|
|
1075
|
+
return
|
|
1076
|
+
self.error = None
|
|
1077
|
+
text = result.text if result is not None else ""
|
|
1078
|
+
self.last_result = result
|
|
1079
|
+
self.alternatives = tuple(result.alternatives) if result is not None else ()
|
|
1080
|
+
self._vtext = self._vtext[:origin] + text
|
|
1081
|
+
self._vcursor = min(self._vcursor, len(self._vtext))
|
|
1082
|
+
# A continuation is fetched ONLY when the model was cut off by the
|
|
1083
|
+
# token limit (truncated). A natural stop-text end - or an empty
|
|
1084
|
+
# reply - is the end: don't queue another request behind it.
|
|
1085
|
+
if not text or not getattr(result, "truncated", False):
|
|
1086
|
+
self._exhausted = True
|
|
1087
|
+
fn, _ = self._provider()
|
|
1088
|
+
hook = getattr(fn, "_on_shown", None)
|
|
1089
|
+
if hook is not None and result is not None:
|
|
1090
|
+
try:
|
|
1091
|
+
hook(req, result)
|
|
1092
|
+
except Exception:
|
|
1093
|
+
pass
|
|
1094
|
+
|
|
1095
|
+
# ── acceptance ─────────────────────────────────────────────────────
|
|
1096
|
+
def accept(self, mode="chunk") -> str:
|
|
1097
|
+
"""The text the editor should splice at the caret ("" if nothing).
|
|
1098
|
+
Advances the buffer; the editor then moves its caret by len()."""
|
|
1099
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
1100
|
+
with self._lock:
|
|
1101
|
+
chunk = self._chunk(max(1, Toggles.Fim.chunk_lines))
|
|
1102
|
+
if not chunk:
|
|
1103
|
+
return ""
|
|
1104
|
+
if mode == "word":
|
|
1105
|
+
chunk = first_word(chunk)
|
|
1106
|
+
elif mode == "line":
|
|
1107
|
+
nl = chunk.find("\n")
|
|
1108
|
+
chunk = chunk if nl < 0 else chunk[:nl + 1]
|
|
1109
|
+
elif mode == "all" and not self._streaming:
|
|
1110
|
+
chunk = strip_cursor_tail(self.buffer) or chunk
|
|
1111
|
+
self._vcursor += len(chunk)
|
|
1112
|
+
self.stats["accepted_chunks"] += 1
|
|
1113
|
+
accepted = self._vcursor - self._inflight_origin
|
|
1114
|
+
fn, _ = self._provider()
|
|
1115
|
+
hook = getattr(fn, "_on_accept", None)
|
|
1116
|
+
if hook is not None and self.last_result is not None:
|
|
1117
|
+
try:
|
|
1118
|
+
hook(self.last_result, accepted)
|
|
1119
|
+
except Exception:
|
|
1120
|
+
pass
|
|
1121
|
+
return chunk
|
|
1122
|
+
|
|
1123
|
+
# ── lifecycle ────────────────────────────────────────────────────
|
|
1124
|
+
def release(self):
|
|
1125
|
+
self.invalidate("release")
|
|
1126
|
+
release_session(self.session)
|
|
1127
|
+
self.session = None
|
|
1128
|
+
self._session_resolved = False
|
|
1129
|
+
self._session_profile = None
|
|
1130
|
+
self._session_error = None
|
|
1131
|
+
self.profile_name = ""
|
|
1132
|
+
self.context = None
|
|
1133
|
+
|
|
1134
|
+
@classmethod
|
|
1135
|
+
def on_window_deleted(cls, window_ds):
|
|
1136
|
+
"""GLState.on_window_deleted's twin: release every state owned by a
|
|
1137
|
+
draw_state under the deleted window."""
|
|
1138
|
+
if window_ds is None:
|
|
1139
|
+
return
|
|
1140
|
+
for state in list(_live_states):
|
|
1141
|
+
node = state._owner_ds
|
|
1142
|
+
hops = 0
|
|
1143
|
+
while node is not None and hops < 64:
|
|
1144
|
+
if node is window_ds:
|
|
1145
|
+
state.release()
|
|
1146
|
+
break
|
|
1147
|
+
nxt = getattr(node, "parent_window", None)
|
|
1148
|
+
if nxt is None or nxt is node:
|
|
1149
|
+
break
|
|
1150
|
+
node = nxt
|
|
1151
|
+
hops += 1
|
|
1152
|
+
|
|
1153
|
+
@classmethod
|
|
1154
|
+
def shutdown_all(cls):
|
|
1155
|
+
"""Called from Melty.cleanup — which runs on an in-process RESTART,
|
|
1156
|
+
not just real exit. So it does NOT close the pooled sessions (they
|
|
1157
|
+
live on `sys` and are reused next generation): it only cancels the
|
|
1158
|
+
current generation's in-flight requests and detaches the sessions so
|
|
1159
|
+
the next generation re-acquires them instead of re-spawning /
|
|
1160
|
+
re-logging-in. Sessions are closed only by the idle sweep, an
|
|
1161
|
+
explicit restart_session / credential change (drop_sessions), or the
|
|
1162
|
+
Copilot LS self-terminating on process exit."""
|
|
1163
|
+
for state in list(_live_states):
|
|
1164
|
+
try:
|
|
1165
|
+
state.invalidate("shutdown") # cancel in-flight, keep the session
|
|
1166
|
+
except Exception:
|
|
1167
|
+
pass
|
|
1168
|
+
detach_sessions_for_restart()
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
1172
|
+
# Context assembly (sources live in fim_context.py; this is the fitter)
|
|
1173
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
1174
|
+
|
|
1175
|
+
def context_key_for(view: EditorView) -> tuple:
|
|
1176
|
+
"""What stable-tier membership is keyed on: the file, its pending gen,
|
|
1177
|
+
and the enclosing def of the caret (its line in the buffer) — NOT the
|
|
1178
|
+
caret itself, so typing inside one function reuses the prefix."""
|
|
1179
|
+
from meltygui.completion.fim_context import enclosing_def_line
|
|
1180
|
+
return (view.path, view.version, enclosing_def_line(view.text, view.cursor))
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
_TIER_SHARE = {"volatile": 0.15, "run": 0.25} # stable takes the remainder
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def assemble_context(view: EditorView, budget_tokens=None) -> FimContext:
|
|
1187
|
+
"""Run every registered context source, dedupe by key, fit each tier to
|
|
1188
|
+
its budget share by score (degrading definition → signature before
|
|
1189
|
+
dropping), and order deterministically within a tier (source order,
|
|
1190
|
+
then key). Stored order is prompt order: stable, run, volatile."""
|
|
1191
|
+
from meltygui.core.runtime.toggles import Toggles
|
|
1192
|
+
import meltygui.completion.fim_context # noqa: F401 (registers the built-in sources)
|
|
1193
|
+
if budget_tokens is None:
|
|
1194
|
+
budget_tokens = Toggles.Fim.context_tokens
|
|
1195
|
+
view.resolve_fn()
|
|
1196
|
+
seen = {}
|
|
1197
|
+
order_of = {}
|
|
1198
|
+
for kind, src in context_sources().items():
|
|
1199
|
+
order_of[kind] = getattr(src, "_fim_order", 100)
|
|
1200
|
+
try:
|
|
1201
|
+
items = list(src(view) or ())
|
|
1202
|
+
except Exception as e:
|
|
1203
|
+
if Toggles.Fim.debug_print:
|
|
1204
|
+
print(f"[fim] context source {kind} failed: {e}")
|
|
1205
|
+
continue
|
|
1206
|
+
for it in items:
|
|
1207
|
+
prev = seen.get(it.key)
|
|
1208
|
+
if prev is None or it.score > prev.score:
|
|
1209
|
+
seen[it.key] = it
|
|
1210
|
+
items = list(seen.values())
|
|
1211
|
+
kept = []
|
|
1212
|
+
spent = 0
|
|
1213
|
+
for tier in ("volatile", "run", "stable"):
|
|
1214
|
+
cap = int(budget_tokens * _TIER_SHARE[tier]) if tier in _TIER_SHARE else max(0, budget_tokens - spent)
|
|
1215
|
+
ranked = sorted((it for it in items if it.tier == tier), key=lambda it: -it.score)
|
|
1216
|
+
used = 0
|
|
1217
|
+
for it in ranked:
|
|
1218
|
+
t = it.tokens
|
|
1219
|
+
if used + t <= cap:
|
|
1220
|
+
kept.append(it)
|
|
1221
|
+
used += t
|
|
1222
|
+
continue
|
|
1223
|
+
if it.kind == "definition" and isinstance(it.data, str) and it.data:
|
|
1224
|
+
sig = ContextItem("signature", it.key, it.data, it.tier, it.score,
|
|
1225
|
+
it.path, it.line, it.line, it.version)
|
|
1226
|
+
if used + sig.tokens <= cap:
|
|
1227
|
+
kept.append(sig)
|
|
1228
|
+
used += sig.tokens
|
|
1229
|
+
spent += used
|
|
1230
|
+
kept.sort(key=lambda it: (TIERS.index(it.tier) if it.tier in TIERS else 9,
|
|
1231
|
+
order_of.get(it.kind, 100), repr(it.key)))
|
|
1232
|
+
return FimContext(kept, context_key_for(view))
|