docslicer 0.2.2__tar.gz → 0.2.4__tar.gz
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.
- {docslicer-0.2.2/src/docslicer.egg-info → docslicer-0.2.4}/PKG-INFO +19 -3
- {docslicer-0.2.2 → docslicer-0.2.4}/README.md +17 -1
- {docslicer-0.2.2 → docslicer-0.2.4}/pyproject.toml +2 -2
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/__init__.py +1 -1
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_result.py +14 -4
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/df_aggregation/registry_aggregator.py +1 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/df_export/reorder_columns.py +1 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/step_04_line_builder.py +132 -5
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/mcp/_store.py +97 -12
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/mcp/server.py +120 -19
- {docslicer-0.2.2 → docslicer-0.2.4/src/docslicer.egg-info}/PKG-INFO +19 -3
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer.egg-info/SOURCES.txt +0 -1
- {docslicer-0.2.2 → docslicer-0.2.4}/tests/test_charts.py +1 -1
- {docslicer-0.2.2 → docslicer-0.2.4}/tests/test_exports.py +4 -4
- docslicer-0.2.2/LICENSE-COMMERCIAL.md +0 -29
- {docslicer-0.2.2 → docslicer-0.2.4}/LICENSE +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/setup.cfg +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_config.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_orchestrator.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/__init__.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/color_utils.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/cpu.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/df_aggregation/__init__.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/df_aggregation/text_merge.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/df_export/__init__.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/df_export/export_debug.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/io/__init__.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/io/yaml_compilers/__init__.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/io/yaml_compilers/exhibit_patterns.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/io/yaml_compilers/hierarchy_type_patterns.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/io/yaml_compilers/page_label_patterns.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/io/yaml_loader.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/layout/__init__.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/layout/gutter_detector.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/layout/layouts.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/layout/line_merger.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/layout/line_number_detector.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/layout/reading_order.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/layout/shape_processor.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/oxm_package.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/parallel.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/password.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/safe_call.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/table/__init__.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/table/table_header.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/table/table_normalize.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/table/table_schema.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/text_utils.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/timing.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/cli.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/config/common_author_names.csv +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/config/exhibit_patterns.yaml +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/config/hierarchy_type_patterns.yaml +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/config/page_label_patterns.yaml +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/docx/__init__.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/docx/docx_orchestrator.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/docx/native_metadata.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/docx/step_01_package_reader.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/docx/step_02_run_extractor.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/docx/step_03_chart_point_builder.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/docx/step_04_table_cell_builder.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/docx/step_05_paragraph_builder.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/docx/step_06_line_builder.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/docx/step_07_style_prefiller.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/__init__.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/extract_boxes.js +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/html_orchestrator.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/native_metadata.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/step_01_box_extractor.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/step_01_static_box_extractor.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/step_02_box_cleaner.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/step_03_page_label_detector.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/step_05_table_extractor.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/step_06_style_prefiller.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/mcp/__init__.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/metadata/__init__.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/metadata/consolidate.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/metadata/generator.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/metadata/ocr_detector.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/metadata/page_analysis.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/metadata/schema.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/metadata/text_fallback.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/ocr/__init__.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/ocr/_availability.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/ocr/ocr_orchestrator.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/ocr/step_01_word_extractor.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/ocr/step_02_word_colorizer.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/ocr/step_03_shape_extractor.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/ocr/step_04_text_cleaner.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/ocr/step_05_font_size_estimator.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/__init__.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/_utils/__init__.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/_utils/coordinates.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/_utils/form_fields.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/_utils/form_label_link.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/_utils/line_classification.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/_utils/page_rotation.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/_utils/script_thresholds.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/_utils/struct_context.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/_utils/struct_tree.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/native_metadata.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/pdf_orchestrator.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_01_word_extractor.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_02_image_extractor.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_03_shape_extractor.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_04_link_extractor.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_05_struct_group.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_06_style_prefiller.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_07_stream_group.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_08_reading_order.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_09_word_relationships.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_10_cell_builder.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_11_page_label_detector.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_12_cell_grouper.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_13_line_builder.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_14_table_builder.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/__init__.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/native_metadata.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/pptx_orchestrator.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/step_01_package_reader.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/step_02_run_extractor.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/step_03_chart_point_builder.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/step_04_table_cell_builder.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/step_05_paragraph_builder.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/step_06_reading_order.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/step_07_line_builder.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/step_08_style_prefiller.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/scraping/__init__.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/scraping/config.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/scraping/cookie_consent.js +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/scraping/dispatcher.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/scraping/fetchers/__init__.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/scraping/fetchers/http_fetcher.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/scraping/fetchers/sec_fetcher.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/scraping/models.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/scraping/stealth_init.js +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/shared/__init__.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/shared/shared_orchestrator.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/shared/step_01_navigation_detector.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/shared/step_02_toc_detector.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/shared/step_03_exhibit_detector.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/shared/step_04_section_classifier.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/shared/step_05_heading_detector.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/shared/step_06_hierarchy_builder.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/shared/step_07_block_merger.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/shared/step_08_chunk_builder.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer.egg-info/dependency_links.txt +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer.egg-info/entry_points.txt +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer.egg-info/requires.txt +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer.egg-info/top_level.txt +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/tests/test_api.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/tests/test_document_parser.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/tests/test_errors.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/tests/test_loose_box_reconstruction.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/tests/test_packaging.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/tests/test_smoke.py +0 -0
- {docslicer-0.2.2 → docslicer-0.2.4}/tests/test_table_cell_newlines.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: docslicer
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.2.4
|
|
4
|
+
Summary: Answer questions from documents too large to fit in context, reading only the sections you need
|
|
5
5
|
Author-email: "Market Framer Inc." <jelle@docslicer.ai>
|
|
6
6
|
License-Expression: AGPL-3.0-only
|
|
7
7
|
Project-URL: Homepage, https://docslicer.ai
|
|
@@ -665,7 +665,8 @@ document and the next `parse` re-parses it automatically.
|
|
|
665
665
|
|
|
666
666
|
| Variable | Effect |
|
|
667
667
|
| --- | --- |
|
|
668
|
-
| `DOCSLICER_MCP_ROOT` | Restrict file sources **and** written output to this directory tree |
|
|
668
|
+
| `DOCSLICER_MCP_ROOT` | Restrict file sources **and** written output to this directory tree. Several may be given, separated by `:` (`;` on Windows) |
|
|
669
|
+
| `DOCSLICER_MCP_ALLOW_CLAUDE_DIR` | Set to `0` to drop the Claude desktop app's own directory from the allowed roots (default `1`) |
|
|
669
670
|
| `DOCSLICER_MCP_ALLOW_URLS` | Set to `0` to reject `http(s)` sources |
|
|
670
671
|
| `DOCSLICER_MCP_CACHE` | Where parsed results are persisted (default `~/.cache/docslicer-mcp`) |
|
|
671
672
|
| `DOCSLICER_MCP_CACHE_MAX_MB` | Cache size ceiling, oldest pruned first (default `2048`; `0` disables) |
|
|
@@ -674,6 +675,21 @@ Set `DOCSLICER_MCP_ROOT` when exposing the server to anything but yourself —
|
|
|
674
675
|
without it, any readable path on the machine is parseable, and `to_markdown`
|
|
675
676
|
can write anywhere the server process can.
|
|
676
677
|
|
|
678
|
+
**Documents dropped into a chat.** Attaching a file to a Claude conversation
|
|
679
|
+
does not hand the server the path you know it by: the app first copies it into
|
|
680
|
+
a per-session workspace under its own data directory (`~/Library/Application
|
|
681
|
+
Support/Claude` on macOS, `%APPDATA%\Claude` on Windows), which is nowhere near
|
|
682
|
+
the folder you would have picked as your root. That directory is therefore
|
|
683
|
+
allowed alongside `DOCSLICER_MCP_ROOT`, so both routes work — the folder you
|
|
684
|
+
chose, and whatever you drop into the chat. It is only ever *added* to a root
|
|
685
|
+
you set; leaving `DOCSLICER_MCP_ROOT` unset still means no sandbox at all, not
|
|
686
|
+
a sandbox of that one directory. Set `DOCSLICER_MCP_ALLOW_CLAUDE_DIR=0` to opt
|
|
687
|
+
out and accept only your own roots.
|
|
688
|
+
|
|
689
|
+
`to_markdown` writes beside the source document, except for a document dropped
|
|
690
|
+
into a chat: that copy lives in a session folder you cannot navigate to, so the
|
|
691
|
+
markdown goes to your first `DOCSLICER_MCP_ROOT` instead.
|
|
692
|
+
|
|
677
693
|
---
|
|
678
694
|
|
|
679
695
|
## Command line
|
|
@@ -605,7 +605,8 @@ document and the next `parse` re-parses it automatically.
|
|
|
605
605
|
|
|
606
606
|
| Variable | Effect |
|
|
607
607
|
| --- | --- |
|
|
608
|
-
| `DOCSLICER_MCP_ROOT` | Restrict file sources **and** written output to this directory tree |
|
|
608
|
+
| `DOCSLICER_MCP_ROOT` | Restrict file sources **and** written output to this directory tree. Several may be given, separated by `:` (`;` on Windows) |
|
|
609
|
+
| `DOCSLICER_MCP_ALLOW_CLAUDE_DIR` | Set to `0` to drop the Claude desktop app's own directory from the allowed roots (default `1`) |
|
|
609
610
|
| `DOCSLICER_MCP_ALLOW_URLS` | Set to `0` to reject `http(s)` sources |
|
|
610
611
|
| `DOCSLICER_MCP_CACHE` | Where parsed results are persisted (default `~/.cache/docslicer-mcp`) |
|
|
611
612
|
| `DOCSLICER_MCP_CACHE_MAX_MB` | Cache size ceiling, oldest pruned first (default `2048`; `0` disables) |
|
|
@@ -614,6 +615,21 @@ Set `DOCSLICER_MCP_ROOT` when exposing the server to anything but yourself —
|
|
|
614
615
|
without it, any readable path on the machine is parseable, and `to_markdown`
|
|
615
616
|
can write anywhere the server process can.
|
|
616
617
|
|
|
618
|
+
**Documents dropped into a chat.** Attaching a file to a Claude conversation
|
|
619
|
+
does not hand the server the path you know it by: the app first copies it into
|
|
620
|
+
a per-session workspace under its own data directory (`~/Library/Application
|
|
621
|
+
Support/Claude` on macOS, `%APPDATA%\Claude` on Windows), which is nowhere near
|
|
622
|
+
the folder you would have picked as your root. That directory is therefore
|
|
623
|
+
allowed alongside `DOCSLICER_MCP_ROOT`, so both routes work — the folder you
|
|
624
|
+
chose, and whatever you drop into the chat. It is only ever *added* to a root
|
|
625
|
+
you set; leaving `DOCSLICER_MCP_ROOT` unset still means no sandbox at all, not
|
|
626
|
+
a sandbox of that one directory. Set `DOCSLICER_MCP_ALLOW_CLAUDE_DIR=0` to opt
|
|
627
|
+
out and accept only your own roots.
|
|
628
|
+
|
|
629
|
+
`to_markdown` writes beside the source document, except for a document dropped
|
|
630
|
+
into a chat: that copy lives in a session folder you cannot navigate to, so the
|
|
631
|
+
markdown goes to your first `DOCSLICER_MCP_ROOT` instead.
|
|
632
|
+
|
|
617
633
|
---
|
|
618
634
|
|
|
619
635
|
## Command line
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "docslicer"
|
|
7
|
-
version = "0.2.
|
|
8
|
-
description = "
|
|
7
|
+
version = "0.2.4"
|
|
8
|
+
description = "Answer questions from documents too large to fit in context, reading only the sections you need"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "AGPL-3.0-only"
|
|
11
11
|
license-files = ["LICENSE"]
|
|
@@ -20,7 +20,7 @@ try:
|
|
|
20
20
|
from importlib.metadata import version as _pkg_version
|
|
21
21
|
__version__ = _pkg_version("docslicer")
|
|
22
22
|
except Exception:
|
|
23
|
-
__version__ = "0.2.
|
|
23
|
+
__version__ = "0.2.4"
|
|
24
24
|
|
|
25
25
|
from ._config import ParseConfig
|
|
26
26
|
from ._result import ParseResult, Chunk, Block, Table, TableCell, Chart, ChartPoint, BBox, HierarchyNode, HierarchyTree
|
|
@@ -1110,7 +1110,8 @@ class ParseResult:
|
|
|
1110
1110
|
if self.charts:
|
|
1111
1111
|
_to_parquet(self.charts_df(), path / "charts.parquet")
|
|
1112
1112
|
(path / "metadata.json").write_text(
|
|
1113
|
-
json.dumps(self.metadata.to_dict(), indent=2, ensure_ascii=False)
|
|
1113
|
+
json.dumps(self.metadata.to_dict(), indent=2, ensure_ascii=False),
|
|
1114
|
+
encoding="utf-8",
|
|
1114
1115
|
)
|
|
1115
1116
|
return
|
|
1116
1117
|
|
|
@@ -1129,20 +1130,29 @@ class ParseResult:
|
|
|
1129
1130
|
if stem == "metadata":
|
|
1130
1131
|
if suffix != ".json":
|
|
1131
1132
|
raise ValueError("metadata can only be saved as .json")
|
|
1132
|
-
path.write_text(
|
|
1133
|
+
path.write_text(
|
|
1134
|
+
json.dumps(self.metadata.to_dict(), indent=2, ensure_ascii=False),
|
|
1135
|
+
encoding="utf-8",
|
|
1136
|
+
)
|
|
1133
1137
|
return
|
|
1134
1138
|
|
|
1135
1139
|
# Unknown stem (e.g. "result") → full document export
|
|
1136
1140
|
if stem not in level_map:
|
|
1137
1141
|
if suffix != ".json":
|
|
1138
1142
|
raise ValueError(f"Unknown stem {stem!r}: use chunks/blocks/tables/charts/metadata, or a .json path for a full export")
|
|
1139
|
-
path.write_text(
|
|
1143
|
+
path.write_text(
|
|
1144
|
+
json.dumps(self.to_dict(), indent=2, ensure_ascii=False),
|
|
1145
|
+
encoding="utf-8",
|
|
1146
|
+
)
|
|
1140
1147
|
return
|
|
1141
1148
|
|
|
1142
1149
|
rows_fn = level_map[stem]
|
|
1143
1150
|
|
|
1144
1151
|
if suffix == ".json":
|
|
1145
|
-
path.write_text(
|
|
1152
|
+
path.write_text(
|
|
1153
|
+
json.dumps(rows_fn(), indent=2, ensure_ascii=False),
|
|
1154
|
+
encoding="utf-8",
|
|
1155
|
+
)
|
|
1146
1156
|
elif suffix == ".jsonl":
|
|
1147
1157
|
with path.open("w", encoding="utf-8") as f:
|
|
1148
1158
|
for row in rows_fn():
|
{docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/df_aggregation/registry_aggregator.py
RENAMED
|
@@ -145,6 +145,7 @@ COLUMN_REGISTRY: Dict[str, Agg] = {
|
|
|
145
145
|
|
|
146
146
|
# --- layout / reading order ----------------------------------------------
|
|
147
147
|
"layout_id": Agg.FIRST,
|
|
148
|
+
"layout_cell_group": Agg.DROP, # line-level only: groups the <td>s of a dissolved layout table
|
|
148
149
|
"layout_type": Agg.FIRST,
|
|
149
150
|
"layout_score": Agg.DROP,
|
|
150
151
|
"block_type": Agg.FIRST,
|
|
@@ -20,17 +20,32 @@ from docslicer._utils.df_aggregation.text_merge import (
|
|
|
20
20
|
# Build Line Text
|
|
21
21
|
# =================================
|
|
22
22
|
|
|
23
|
-
def _build_line_text(df: pd.DataFrame) -> pd.Series:
|
|
23
|
+
def _build_line_text(df: pd.DataFrame, stacked: np.ndarray | None = None) -> pd.Series:
|
|
24
24
|
"""
|
|
25
25
|
Build the text for each line: boxes sorted by x_left, non-table lines joined
|
|
26
26
|
with spaces, table lines joined with pipes so downstream shared stages see a
|
|
27
27
|
row-shaped representation. Inline markup (script/strikethrough) is already
|
|
28
28
|
baked into box text by the box cleaner.
|
|
29
29
|
|
|
30
|
+
Args:
|
|
31
|
+
df: box-level frame carrying line_id.
|
|
32
|
+
stacked: optional boolean mask of boxes that stack vertically inside
|
|
33
|
+
their line (the layout-table cells split by
|
|
34
|
+
:func:`_split_layout_table_cell_lines`). Those boxes order by y first,
|
|
35
|
+
because a centred paragraph's x_left depends on its rendered width
|
|
36
|
+
and would otherwise come out widest-first. None (the normal case)
|
|
37
|
+
keeps the plain x_left sort.
|
|
38
|
+
|
|
30
39
|
Returns:
|
|
31
40
|
Series of joined text indexed by line_id.
|
|
32
41
|
"""
|
|
33
|
-
|
|
42
|
+
if stacked is None:
|
|
43
|
+
ordered = df.sort_values(["line_id", "x_left"], kind="stable")
|
|
44
|
+
else:
|
|
45
|
+
# 0.0 everywhere else, so unstacked lines sort on x_left exactly as before.
|
|
46
|
+
df = df.assign(_stack_y=np.where(stacked, df["y_top"].to_numpy(dtype=float), 0.0))
|
|
47
|
+
ordered = df.sort_values(["line_id", "_stack_y", "x_left"], kind="stable")
|
|
48
|
+
ordered = ordered.drop(columns="_stack_y")
|
|
34
49
|
texts = ordered["text"].astype("string").fillna("")
|
|
35
50
|
# <pre> code-line boxes keep their leading whitespace — indentation is
|
|
36
51
|
# semantically meaningful in code. Everything else is stripped as before.
|
|
@@ -60,6 +75,100 @@ def _build_line_text(df: pd.DataFrame) -> pd.Series:
|
|
|
60
75
|
return line_text
|
|
61
76
|
|
|
62
77
|
|
|
78
|
+
# =================================
|
|
79
|
+
# Layout-table cells
|
|
80
|
+
# =================================
|
|
81
|
+
|
|
82
|
+
def _split_layout_table_cell_lines(df: pd.DataFrame) -> tuple[pd.DataFrame, np.ndarray | None]:
|
|
83
|
+
"""
|
|
84
|
+
Give every ``<td>`` of a single-row *layout* table its own line_id.
|
|
85
|
+
|
|
86
|
+
A single-row table whose cells stack several paragraphs on top of each other
|
|
87
|
+
is a layout device, not data — the multi-column signature block where each
|
|
88
|
+
cell holds one law firm. ``assign_line_id`` merges a whole ``<tr>`` into one
|
|
89
|
+
line, which is right for a data row but here glues four firms into one line
|
|
90
|
+
and makes it impossible to tell which people belong to which firm. Splitting
|
|
91
|
+
per cell yields one line per column, in document (left-to-right) order.
|
|
92
|
+
|
|
93
|
+
``_remove_single_row_tables`` dissolves these tables a few steps later, so no
|
|
94
|
+
downstream table stage ever sees the split rows.
|
|
95
|
+
|
|
96
|
+
The detection is a linear neighbour scan over the table boxes only: cells are
|
|
97
|
+
contiguous within a line, so a line has a multi-box cell iff two adjacent
|
|
98
|
+
boxes share (line_id, table_cell_index), and spans several cells iff two
|
|
99
|
+
adjacent boxes share line_id but not table_cell_index. Both must hold before
|
|
100
|
+
anything heavier than that scan runs, which on ordinary documents is never.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
(df, stacked_mask) — df with the split line_ids applied, and a boolean
|
|
104
|
+
mask of the boxes that were split (None when nothing qualified, i.e. the
|
|
105
|
+
frame is untouched).
|
|
106
|
+
"""
|
|
107
|
+
needed = ("line_id", "table_id", "table_row_id", "table_cell_index")
|
|
108
|
+
if any(c not in df.columns for c in needed):
|
|
109
|
+
return df, None
|
|
110
|
+
|
|
111
|
+
is_table = df["table_id"].notna().to_numpy()
|
|
112
|
+
if not is_table.any():
|
|
113
|
+
return df, None
|
|
114
|
+
|
|
115
|
+
pos = np.flatnonzero(is_table)
|
|
116
|
+
line = df["line_id"].to_numpy()[pos]
|
|
117
|
+
cell = pd.to_numeric(df["table_cell_index"], errors="coerce").to_numpy(dtype=float)[pos]
|
|
118
|
+
|
|
119
|
+
same_line = line[1:] == line[:-1]
|
|
120
|
+
same_cell = cell[1:] == cell[:-1]
|
|
121
|
+
multi_box_cell = same_line & same_cell # a cell holding >1 box
|
|
122
|
+
multi_cell_line = same_line & ~same_cell # a line spanning >1 cell
|
|
123
|
+
if not (multi_box_cell.any() and multi_cell_line.any()):
|
|
124
|
+
return df, None
|
|
125
|
+
|
|
126
|
+
# Lines that are both — the only candidates worth a closer look.
|
|
127
|
+
candidates = np.intersect1d(line[1:][multi_box_cell], line[1:][multi_cell_line])
|
|
128
|
+
if candidates.size == 0:
|
|
129
|
+
return df, None
|
|
130
|
+
|
|
131
|
+
# Keep only candidates whose table is single-row: a wrapped cell in a real
|
|
132
|
+
# multi-row table looks identical to the scan above but must stay one line
|
|
133
|
+
# per row (step_05 relies on that).
|
|
134
|
+
line_all = df["line_id"].to_numpy()
|
|
135
|
+
on_candidate_line = is_table & np.isin(line_all, candidates)
|
|
136
|
+
cand_tables = df.loc[on_candidate_line, "table_id"].unique()
|
|
137
|
+
in_cand_tables = df["table_id"].isin(cand_tables)
|
|
138
|
+
rows_per_table = df.loc[in_cand_tables].groupby("table_id")["table_row_id"].nunique()
|
|
139
|
+
single_row = set(rows_per_table[rows_per_table == 1].index)
|
|
140
|
+
if not single_row:
|
|
141
|
+
return df, None
|
|
142
|
+
|
|
143
|
+
stacked = on_candidate_line & df["table_id"].isin(single_row).to_numpy()
|
|
144
|
+
if not stacked.any():
|
|
145
|
+
return df, None
|
|
146
|
+
|
|
147
|
+
# Renumber: a new line starts wherever line_id changes, plus at every cell
|
|
148
|
+
# boundary inside a split line. Boxes are in document order, so the cumsum
|
|
149
|
+
# keeps line_ids ascending exactly as assign_line_id left them.
|
|
150
|
+
cell_all = pd.to_numeric(df["table_cell_index"], errors="coerce").to_numpy(dtype=float)
|
|
151
|
+
starts = np.empty(len(df), dtype=bool)
|
|
152
|
+
starts[0] = True
|
|
153
|
+
starts[1:] = line_all[1:] != line_all[:-1]
|
|
154
|
+
cell_break = np.zeros(len(df), dtype=bool)
|
|
155
|
+
cell_break[1:] = stacked[1:] & stacked[:-1] & (cell_all[1:] != cell_all[:-1])
|
|
156
|
+
|
|
157
|
+
out = df.copy()
|
|
158
|
+
out["line_id"] = np.cumsum(starts | cell_break)
|
|
159
|
+
|
|
160
|
+
# top_bucket is derived from line_id by the merger; refresh it for the boxes
|
|
161
|
+
# whose line changed (it is dropped at aggregation, but the box-level frame
|
|
162
|
+
# is exported for debugging).
|
|
163
|
+
if "top_bucket" in out.columns:
|
|
164
|
+
split = out.loc[stacked]
|
|
165
|
+
out.loc[stacked, "top_bucket"] = (
|
|
166
|
+
split["line_id"].map(split.groupby("line_id")["y_top"].min().round().astype("Int64"))
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return out, stacked
|
|
170
|
+
|
|
171
|
+
|
|
63
172
|
# =================================
|
|
64
173
|
# Table vs Text
|
|
65
174
|
# =================================
|
|
@@ -268,6 +377,7 @@ def _add_layout_id(df: pd.DataFrame) -> pd.DataFrame:
|
|
|
268
377
|
group key (new id when entering/leaving/switching groups). A line's
|
|
269
378
|
group key is, in order of precedence:
|
|
270
379
|
- its table_id
|
|
380
|
+
- its layout_cell_group (sibling <td>s of a dissolved layout table)
|
|
271
381
|
- its deepest ul/ol or h1-h6 struct ancestor (see
|
|
272
382
|
:func:`_struct_layout_groups`), so list items and multi-line
|
|
273
383
|
headings stay together
|
|
@@ -300,6 +410,9 @@ def _add_layout_id(df: pd.DataFrame) -> pd.DataFrame:
|
|
|
300
410
|
|
|
301
411
|
# Rule 3: group key — table_id wins over the struct list/heading group
|
|
302
412
|
group_key = _struct_layout_groups(df)
|
|
413
|
+
if "layout_cell_group" in df.columns:
|
|
414
|
+
cell_group = df["layout_cell_group"].astype("string")
|
|
415
|
+
group_key = group_key.mask(cell_group.notna(), "cells_" + cell_group)
|
|
303
416
|
if "table_id" in df.columns:
|
|
304
417
|
has_table = df["table_id"].notna()
|
|
305
418
|
group_key = group_key.mask(has_table, "table_" + df["table_id"].astype(str))
|
|
@@ -340,7 +453,8 @@ def merge_boxes_to_lines(
|
|
|
340
453
|
) -> pd.DataFrame:
|
|
341
454
|
"""
|
|
342
455
|
Merge boxes into lines:
|
|
343
|
-
1. Assign line_id using line_merger
|
|
456
|
+
1. Assign line_id using line_merger, then split the cells of single-row
|
|
457
|
+
layout tables into their own lines
|
|
344
458
|
2. Merge text within each line (sorted by x_left) via text_merge
|
|
345
459
|
3. Aggregate the remaining columns via the registry aggregator
|
|
346
460
|
4. Optionally remove single-row tables and reindex
|
|
@@ -391,12 +505,16 @@ def merge_boxes_to_lines(
|
|
|
391
505
|
boxes_df["y_bottom"] = synthetic_y
|
|
392
506
|
|
|
393
507
|
boxes_with_lines = assign_line_id(boxes_df, y_alignment="top")
|
|
394
|
-
|
|
508
|
+
|
|
509
|
+
# Step 1b: Split the cells of single-row layout tables into their own lines
|
|
510
|
+
# (no-op, after one linear scan, on everything else).
|
|
511
|
+
boxes_with_lines, stacked = _split_layout_table_cell_lines(boxes_with_lines)
|
|
512
|
+
|
|
395
513
|
# Step 2: Create text for each line (sorted by x_left, joined with spaces,
|
|
396
514
|
# pipe-joined on table lines). Text is merged from the x-sorted view while
|
|
397
515
|
# aggregation runs on the frame in document order, so "first"/dominant
|
|
398
516
|
# columns keep picking the same source boxes as before.
|
|
399
|
-
line_text = _build_line_text(boxes_with_lines)
|
|
517
|
+
line_text = _build_line_text(boxes_with_lines, stacked=stacked)
|
|
400
518
|
|
|
401
519
|
# Step 3: Aggregate everything else via the central column registry
|
|
402
520
|
lines_df = aggregate_to(
|
|
@@ -406,6 +524,15 @@ def merge_boxes_to_lines(
|
|
|
406
524
|
)
|
|
407
525
|
lines_df["text"] = lines_df["line_id"].map(line_text)
|
|
408
526
|
|
|
527
|
+
# Sibling cells of one layout table share a layout group, so the columns stay
|
|
528
|
+
# in one layout after _remove_single_row_tables clears their table_id.
|
|
529
|
+
if stacked is not None:
|
|
530
|
+
split = boxes_with_lines.loc[stacked]
|
|
531
|
+
group = (
|
|
532
|
+
split["table_id"].astype(str) + ":" + split["table_row_id"].astype(str)
|
|
533
|
+
).groupby(split["line_id"]).first()
|
|
534
|
+
lines_df["layout_cell_group"] = lines_df["line_id"].map(group)
|
|
535
|
+
|
|
409
536
|
# Step 4: Remove single-row tables if requested
|
|
410
537
|
if remove_single_row_tables:
|
|
411
538
|
lines_df = _remove_single_row_tables(lines_df)
|
|
@@ -16,6 +16,7 @@ import hashlib
|
|
|
16
16
|
import json
|
|
17
17
|
import os
|
|
18
18
|
import re
|
|
19
|
+
import sys
|
|
19
20
|
import time
|
|
20
21
|
from collections import OrderedDict
|
|
21
22
|
from dataclasses import dataclass
|
|
@@ -47,10 +48,94 @@ def cache_limit_bytes() -> int:
|
|
|
47
48
|
return max(0, megabytes) * 1024 * 1024
|
|
48
49
|
|
|
49
50
|
|
|
50
|
-
def
|
|
51
|
-
"""
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
def configured_roots() -> tuple[Path, ...]:
|
|
52
|
+
"""The sandbox roots the operator named in DOCSLICER_MCP_ROOT.
|
|
53
|
+
|
|
54
|
+
More than one may be given, separated by the platform's path separator
|
|
55
|
+
(``:`` on macOS and Linux, ``;`` on Windows) — the same convention as PATH,
|
|
56
|
+
so a host that can only hand the server one string can still hand it
|
|
57
|
+
several directories. Empty when the variable is unset, which means no
|
|
58
|
+
sandbox at all.
|
|
59
|
+
"""
|
|
60
|
+
env = os.environ.get("DOCSLICER_MCP_ROOT") or ""
|
|
61
|
+
parts = [p.strip() for p in env.split(os.pathsep)]
|
|
62
|
+
return tuple(Path(p).expanduser().resolve() for p in parts if p)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def claude_dir_allowed() -> bool:
|
|
66
|
+
"""Whether the Claude desktop app's data directory joins the sandbox. Disable with
|
|
67
|
+
DOCSLICER_MCP_ALLOW_CLAUDE_DIR=0."""
|
|
68
|
+
return os.environ.get("DOCSLICER_MCP_ALLOW_CLAUDE_DIR", "1").lower() not in (
|
|
69
|
+
"0",
|
|
70
|
+
"false",
|
|
71
|
+
"no",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def claude_dir() -> Path | None:
|
|
76
|
+
"""Where the Claude desktop app keeps its own files, or None if it is not there.
|
|
77
|
+
|
|
78
|
+
A document dropped into a Claude chat is not read from wherever the user
|
|
79
|
+
keeps it; the app copies it into a per-session workspace under this
|
|
80
|
+
directory and gives the model that path. So a root pointing at the user's
|
|
81
|
+
Documents or Desktop — the folder they would think to pick — matches none
|
|
82
|
+
of the files they actually hand the server, and every drop is refused as
|
|
83
|
+
outside the sandbox. This is the second root that makes the first usable.
|
|
84
|
+
"""
|
|
85
|
+
if sys.platform == "darwin":
|
|
86
|
+
base = Path.home() / "Library" / "Application Support" / "Claude"
|
|
87
|
+
elif sys.platform == "win32":
|
|
88
|
+
appdata = os.environ.get("APPDATA")
|
|
89
|
+
if not appdata:
|
|
90
|
+
return None
|
|
91
|
+
base = Path(appdata) / "Claude"
|
|
92
|
+
else:
|
|
93
|
+
config = os.environ.get("XDG_CONFIG_HOME")
|
|
94
|
+
base = (Path(config) if config else Path.home() / ".config") / "Claude"
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
resolved = base.expanduser().resolve(strict=True)
|
|
98
|
+
except OSError:
|
|
99
|
+
return None
|
|
100
|
+
return resolved if resolved.is_dir() else None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def allowed_roots() -> tuple[Path, ...]:
|
|
104
|
+
"""Every directory tree this server may read from and write to.
|
|
105
|
+
|
|
106
|
+
Empty means unrestricted. That is what an unset DOCSLICER_MCP_ROOT has
|
|
107
|
+
always meant, and the Claude directory is added only alongside a root the
|
|
108
|
+
operator set — a server told to sandbox nothing must not find itself
|
|
109
|
+
sandboxed to one directory because the app happens to be installed.
|
|
110
|
+
"""
|
|
111
|
+
roots = configured_roots()
|
|
112
|
+
if not roots or not claude_dir_allowed():
|
|
113
|
+
return roots
|
|
114
|
+
|
|
115
|
+
extra = claude_dir()
|
|
116
|
+
if extra is None or any(_within(extra, root) for root in roots):
|
|
117
|
+
return roots
|
|
118
|
+
return roots + (extra,)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def primary_root() -> Path | None:
|
|
122
|
+
"""The root a path with nowhere else to go belongs in — the first one configured.
|
|
123
|
+
|
|
124
|
+
Only the operator's own roots qualify: the Claude directory is a place
|
|
125
|
+
files arrive from, not somewhere to file a document the caller asked for.
|
|
126
|
+
"""
|
|
127
|
+
roots = configured_roots()
|
|
128
|
+
return roots[0] if roots else None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _within(path: Path, root: Path) -> bool:
|
|
132
|
+
"""Whether a resolved path is the root or sits beneath it."""
|
|
133
|
+
return path == root or root in path.parents
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _root_list(roots: tuple[Path, ...]) -> str:
|
|
137
|
+
"""The allowed roots as an error message names them."""
|
|
138
|
+
return ", ".join(str(r) for r in roots)
|
|
54
139
|
|
|
55
140
|
|
|
56
141
|
def urls_allowed() -> bool:
|
|
@@ -98,11 +183,11 @@ def resolve_source(source: str) -> str:
|
|
|
98
183
|
except FileNotFoundError:
|
|
99
184
|
raise FileNotFoundError(f"No such file: {source}") from None
|
|
100
185
|
|
|
101
|
-
|
|
102
|
-
if
|
|
186
|
+
roots = allowed_roots()
|
|
187
|
+
if roots and not any(_within(resolved, root) for root in roots):
|
|
103
188
|
raise SourceNotAllowed(
|
|
104
|
-
f"Access to {resolved} is outside the allowed
|
|
105
|
-
"Copy the file inside
|
|
189
|
+
f"Access to {resolved} is outside the allowed roots: {_root_list(roots)}. "
|
|
190
|
+
"Copy the file inside one of those directories or adjust DOCSLICER_MCP_ROOT."
|
|
106
191
|
)
|
|
107
192
|
return str(resolved)
|
|
108
193
|
|
|
@@ -128,11 +213,11 @@ def resolve_output(path: str, base: Path | None = None) -> Path:
|
|
|
128
213
|
anchor = base or Path.cwd()
|
|
129
214
|
resolved = Path(target if target.is_absolute() else anchor / target).resolve()
|
|
130
215
|
|
|
131
|
-
|
|
132
|
-
if
|
|
216
|
+
roots = allowed_roots()
|
|
217
|
+
if roots and not any(root in resolved.parents for root in roots):
|
|
133
218
|
raise SourceNotAllowed(
|
|
134
|
-
f"Cannot write to {resolved}: it is outside the allowed
|
|
135
|
-
"Choose an output_path inside
|
|
219
|
+
f"Cannot write to {resolved}: it is outside the allowed roots: {_root_list(roots)}. "
|
|
220
|
+
"Choose an output_path inside one of those directories or adjust DOCSLICER_MCP_ROOT."
|
|
136
221
|
)
|
|
137
222
|
return resolved
|
|
138
223
|
|