From dedbc3c3cdd72159944a11eb0c9a5c61d6d95313 Mon Sep 17 00:00:00 2001
From: wgqqqqq
Date: Wed, 11 Feb 2026 09:00:30 +0800
Subject: [PATCH 01/19] feat(cowork): introduce cowork mode and plugin MCP
management
---
Cargo.toml | 1 +
src/apps/desktop/Cargo.toml | 2 +
src/apps/desktop/src/api/mod.rs | 1 +
src/apps/desktop/src/api/plugin_api.rs | 526 ++
src/apps/desktop/src/lib.rs | 5 +
src/crates/core/Cargo.toml | 2 +-
.../core/builtin_skills/docx/LICENSE.txt | 30 +
src/crates/core/builtin_skills/docx/SKILL.md | 481 ++
.../builtin_skills/docx/scripts/__init__.py | 1 +
.../docx/scripts/accept_changes.py | 135 +
.../builtin_skills/docx/scripts/comment.py | 318 ++
.../docx/scripts/office/helpers/__init__.py | 0
.../docx/scripts/office/helpers/merge_runs.py | 199 +
.../office/helpers/simplify_redlines.py | 197 +
.../docx/scripts/office/pack.py | 159 +
.../schemas/ISO-IEC29500-4_2016/dml-chart.xsd | 1499 ++++++
.../ISO-IEC29500-4_2016/dml-chartDrawing.xsd | 146 +
.../ISO-IEC29500-4_2016/dml-diagram.xsd | 1085 ++++
.../ISO-IEC29500-4_2016/dml-lockedCanvas.xsd | 11 +
.../schemas/ISO-IEC29500-4_2016/dml-main.xsd | 3081 ++++++++++++
.../ISO-IEC29500-4_2016/dml-picture.xsd | 23 +
.../dml-spreadsheetDrawing.xsd | 185 +
.../dml-wordprocessingDrawing.xsd | 287 ++
.../schemas/ISO-IEC29500-4_2016/pml.xsd | 1676 +++++++
.../shared-additionalCharacteristics.xsd | 28 +
.../shared-bibliography.xsd | 144 +
.../shared-commonSimpleTypes.xsd | 174 +
.../shared-customXmlDataProperties.xsd | 25 +
.../shared-customXmlSchemaProperties.xsd | 18 +
.../shared-documentPropertiesCustom.xsd | 59 +
.../shared-documentPropertiesExtended.xsd | 56 +
.../shared-documentPropertiesVariantTypes.xsd | 195 +
.../ISO-IEC29500-4_2016/shared-math.xsd | 582 +++
.../shared-relationshipReference.xsd | 25 +
.../schemas/ISO-IEC29500-4_2016/sml.xsd | 4439 +++++++++++++++++
.../schemas/ISO-IEC29500-4_2016/vml-main.xsd | 570 +++
.../ISO-IEC29500-4_2016/vml-officeDrawing.xsd | 509 ++
.../vml-presentationDrawing.xsd | 12 +
.../vml-spreadsheetDrawing.xsd | 108 +
.../vml-wordprocessingDrawing.xsd | 96 +
.../schemas/ISO-IEC29500-4_2016/wml.xsd | 3646 ++++++++++++++
.../schemas/ISO-IEC29500-4_2016/xml.xsd | 116 +
.../ecma/fouth-edition/opc-contentTypes.xsd | 42 +
.../ecma/fouth-edition/opc-coreProperties.xsd | 50 +
.../schemas/ecma/fouth-edition/opc-digSig.xsd | 49 +
.../ecma/fouth-edition/opc-relationships.xsd | 33 +
.../docx/scripts/office/schemas/mce/mc.xsd | 75 +
.../office/schemas/microsoft/wml-2010.xsd | 560 +++
.../office/schemas/microsoft/wml-2012.xsd | 67 +
.../office/schemas/microsoft/wml-2018.xsd | 14 +
.../office/schemas/microsoft/wml-cex-2018.xsd | 20 +
.../office/schemas/microsoft/wml-cid-2016.xsd | 13 +
.../microsoft/wml-sdtdatahash-2020.xsd | 4 +
.../schemas/microsoft/wml-symex-2015.xsd | 8 +
.../docx/scripts/office/soffice.py | 183 +
.../docx/scripts/office/unpack.py | 132 +
.../docx/scripts/office/validate.py | 111 +
.../scripts/office/validators/__init__.py | 15 +
.../docx/scripts/office/validators/base.py | 847 ++++
.../docx/scripts/office/validators/docx.py | 446 ++
.../docx/scripts/office/validators/pptx.py | 275 +
.../scripts/office/validators/redlining.py | 247 +
.../docx/scripts/templates/comments.xml | 3 +
.../scripts/templates/commentsExtended.xml | 3 +
.../scripts/templates/commentsExtensible.xml | 3 +
.../docx/scripts/templates/commentsIds.xml | 3 +
.../docx/scripts/templates/people.xml | 3 +
.../core/builtin_skills/pdf/LICENSE.txt | 30 +
src/crates/core/builtin_skills/pdf/SKILL.md | 314 ++
src/crates/core/builtin_skills/pdf/forms.md | 294 ++
.../core/builtin_skills/pdf/reference.md | 612 +++
.../pdf/scripts/check_bounding_boxes.py | 65 +
.../pdf/scripts/check_fillable_fields.py | 11 +
.../pdf/scripts/convert_pdf_to_images.py | 33 +
.../pdf/scripts/create_validation_image.py | 37 +
.../pdf/scripts/extract_form_field_info.py | 122 +
.../pdf/scripts/extract_form_structure.py | 115 +
.../pdf/scripts/fill_fillable_fields.py | 98 +
.../scripts/fill_pdf_form_with_annotations.py | 107 +
.../core/builtin_skills/pptx/LICENSE.txt | 30 +
src/crates/core/builtin_skills/pptx/SKILL.md | 232 +
.../core/builtin_skills/pptx/editing.md | 205 +
.../core/builtin_skills/pptx/pptxgenjs.md | 420 ++
.../builtin_skills/pptx/scripts/__init__.py | 0
.../builtin_skills/pptx/scripts/add_slide.py | 195 +
.../core/builtin_skills/pptx/scripts/clean.py | 286 ++
.../pptx/scripts/office/helpers/__init__.py | 0
.../pptx/scripts/office/helpers/merge_runs.py | 199 +
.../office/helpers/simplify_redlines.py | 197 +
.../pptx/scripts/office/pack.py | 159 +
.../schemas/ISO-IEC29500-4_2016/dml-chart.xsd | 1499 ++++++
.../ISO-IEC29500-4_2016/dml-chartDrawing.xsd | 146 +
.../ISO-IEC29500-4_2016/dml-diagram.xsd | 1085 ++++
.../ISO-IEC29500-4_2016/dml-lockedCanvas.xsd | 11 +
.../schemas/ISO-IEC29500-4_2016/dml-main.xsd | 3081 ++++++++++++
.../ISO-IEC29500-4_2016/dml-picture.xsd | 23 +
.../dml-spreadsheetDrawing.xsd | 185 +
.../dml-wordprocessingDrawing.xsd | 287 ++
.../schemas/ISO-IEC29500-4_2016/pml.xsd | 1676 +++++++
.../shared-additionalCharacteristics.xsd | 28 +
.../shared-bibliography.xsd | 144 +
.../shared-commonSimpleTypes.xsd | 174 +
.../shared-customXmlDataProperties.xsd | 25 +
.../shared-customXmlSchemaProperties.xsd | 18 +
.../shared-documentPropertiesCustom.xsd | 59 +
.../shared-documentPropertiesExtended.xsd | 56 +
.../shared-documentPropertiesVariantTypes.xsd | 195 +
.../ISO-IEC29500-4_2016/shared-math.xsd | 582 +++
.../shared-relationshipReference.xsd | 25 +
.../schemas/ISO-IEC29500-4_2016/sml.xsd | 4439 +++++++++++++++++
.../schemas/ISO-IEC29500-4_2016/vml-main.xsd | 570 +++
.../ISO-IEC29500-4_2016/vml-officeDrawing.xsd | 509 ++
.../vml-presentationDrawing.xsd | 12 +
.../vml-spreadsheetDrawing.xsd | 108 +
.../vml-wordprocessingDrawing.xsd | 96 +
.../schemas/ISO-IEC29500-4_2016/wml.xsd | 3646 ++++++++++++++
.../schemas/ISO-IEC29500-4_2016/xml.xsd | 116 +
.../ecma/fouth-edition/opc-contentTypes.xsd | 42 +
.../ecma/fouth-edition/opc-coreProperties.xsd | 50 +
.../schemas/ecma/fouth-edition/opc-digSig.xsd | 49 +
.../ecma/fouth-edition/opc-relationships.xsd | 33 +
.../pptx/scripts/office/schemas/mce/mc.xsd | 75 +
.../office/schemas/microsoft/wml-2010.xsd | 560 +++
.../office/schemas/microsoft/wml-2012.xsd | 67 +
.../office/schemas/microsoft/wml-2018.xsd | 14 +
.../office/schemas/microsoft/wml-cex-2018.xsd | 20 +
.../office/schemas/microsoft/wml-cid-2016.xsd | 13 +
.../microsoft/wml-sdtdatahash-2020.xsd | 4 +
.../schemas/microsoft/wml-symex-2015.xsd | 8 +
.../pptx/scripts/office/soffice.py | 183 +
.../pptx/scripts/office/unpack.py | 132 +
.../pptx/scripts/office/validate.py | 111 +
.../scripts/office/validators/__init__.py | 15 +
.../pptx/scripts/office/validators/base.py | 847 ++++
.../pptx/scripts/office/validators/docx.py | 446 ++
.../pptx/scripts/office/validators/pptx.py | 275 +
.../scripts/office/validators/redlining.py | 247 +
.../builtin_skills/pptx/scripts/thumbnail.py | 289 ++
.../core/builtin_skills/xlsx/LICENSE.txt | 30 +
src/crates/core/builtin_skills/xlsx/SKILL.md | 292 ++
.../xlsx/scripts/office/helpers/__init__.py | 0
.../xlsx/scripts/office/helpers/merge_runs.py | 199 +
.../office/helpers/simplify_redlines.py | 197 +
.../xlsx/scripts/office/pack.py | 159 +
.../schemas/ISO-IEC29500-4_2016/dml-chart.xsd | 1499 ++++++
.../ISO-IEC29500-4_2016/dml-chartDrawing.xsd | 146 +
.../ISO-IEC29500-4_2016/dml-diagram.xsd | 1085 ++++
.../ISO-IEC29500-4_2016/dml-lockedCanvas.xsd | 11 +
.../schemas/ISO-IEC29500-4_2016/dml-main.xsd | 3081 ++++++++++++
.../ISO-IEC29500-4_2016/dml-picture.xsd | 23 +
.../dml-spreadsheetDrawing.xsd | 185 +
.../dml-wordprocessingDrawing.xsd | 287 ++
.../schemas/ISO-IEC29500-4_2016/pml.xsd | 1676 +++++++
.../shared-additionalCharacteristics.xsd | 28 +
.../shared-bibliography.xsd | 144 +
.../shared-commonSimpleTypes.xsd | 174 +
.../shared-customXmlDataProperties.xsd | 25 +
.../shared-customXmlSchemaProperties.xsd | 18 +
.../shared-documentPropertiesCustom.xsd | 59 +
.../shared-documentPropertiesExtended.xsd | 56 +
.../shared-documentPropertiesVariantTypes.xsd | 195 +
.../ISO-IEC29500-4_2016/shared-math.xsd | 582 +++
.../shared-relationshipReference.xsd | 25 +
.../schemas/ISO-IEC29500-4_2016/sml.xsd | 4439 +++++++++++++++++
.../schemas/ISO-IEC29500-4_2016/vml-main.xsd | 570 +++
.../ISO-IEC29500-4_2016/vml-officeDrawing.xsd | 509 ++
.../vml-presentationDrawing.xsd | 12 +
.../vml-spreadsheetDrawing.xsd | 108 +
.../vml-wordprocessingDrawing.xsd | 96 +
.../schemas/ISO-IEC29500-4_2016/wml.xsd | 3646 ++++++++++++++
.../schemas/ISO-IEC29500-4_2016/xml.xsd | 116 +
.../ecma/fouth-edition/opc-contentTypes.xsd | 42 +
.../ecma/fouth-edition/opc-coreProperties.xsd | 50 +
.../schemas/ecma/fouth-edition/opc-digSig.xsd | 49 +
.../ecma/fouth-edition/opc-relationships.xsd | 33 +
.../xlsx/scripts/office/schemas/mce/mc.xsd | 75 +
.../office/schemas/microsoft/wml-2010.xsd | 560 +++
.../office/schemas/microsoft/wml-2012.xsd | 67 +
.../office/schemas/microsoft/wml-2018.xsd | 14 +
.../office/schemas/microsoft/wml-cex-2018.xsd | 20 +
.../office/schemas/microsoft/wml-cid-2016.xsd | 13 +
.../microsoft/wml-sdtdatahash-2020.xsd | 4 +
.../schemas/microsoft/wml-symex-2015.xsd | 8 +
.../xlsx/scripts/office/soffice.py | 183 +
.../xlsx/scripts/office/unpack.py | 132 +
.../xlsx/scripts/office/validate.py | 111 +
.../scripts/office/validators/__init__.py | 15 +
.../xlsx/scripts/office/validators/base.py | 847 ++++
.../xlsx/scripts/office/validators/docx.py | 446 ++
.../xlsx/scripts/office/validators/pptx.py | 275 +
.../scripts/office/validators/redlining.py | 247 +
.../builtin_skills/xlsx/scripts/recalc.py | 184 +
.../core/src/agentic/agents/cowork_mode.rs | 70 +
src/crates/core/src/agentic/agents/mod.rs | 2 +
.../src/agentic/agents/prompts/cowork_mode.md | 33 +
.../core/src/agentic/agents/registry.rs | 6 +-
.../tools/implementations/skills/builtin.rs | 100 +
.../tools/implementations/skills/mod.rs | 2 +-
.../tools/implementations/skills/registry.rs | 5 +
.../infrastructure/filesystem/path_manager.rs | 10 +
.../src/service/mcp/config/cursor_format.rs | 1 +
.../src/service/mcp/config/json_config.rs | 2 +-
.../core/src/service/mcp/server/manager.rs | 61 +-
.../core/src/service/mcp/server/process.rs | 34 +-
src/web-ui/src/infrastructure/api/index.ts | 4 +-
.../api/service-api/PluginAPI.ts | 64 +
.../config/components/ConfigCenterPanel.tsx | 11 +-
.../config/components/PluginsConfig.scss | 152 +
.../config/components/PluginsConfig.tsx | 250 +
.../infrastructure/i18n/core/I18nService.ts | 5 +
src/web-ui/src/locales/en-US/settings.json | 1 +
.../src/locales/en-US/settings/plugins.json | 39 +
src/web-ui/src/locales/zh-CN/settings.json | 1 +
.../src/locales/zh-CN/settings/plugins.json | 39 +
214 files changed, 74009 insertions(+), 24 deletions(-)
create mode 100644 src/apps/desktop/src/api/plugin_api.rs
create mode 100644 src/crates/core/builtin_skills/docx/LICENSE.txt
create mode 100644 src/crates/core/builtin_skills/docx/SKILL.md
create mode 100755 src/crates/core/builtin_skills/docx/scripts/__init__.py
create mode 100755 src/crates/core/builtin_skills/docx/scripts/accept_changes.py
create mode 100755 src/crates/core/builtin_skills/docx/scripts/comment.py
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/helpers/__init__.py
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/helpers/merge_runs.py
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/helpers/simplify_redlines.py
create mode 100755 src/crates/core/builtin_skills/docx/scripts/office/pack.py
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/mce/mc.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/soffice.py
create mode 100755 src/crates/core/builtin_skills/docx/scripts/office/unpack.py
create mode 100755 src/crates/core/builtin_skills/docx/scripts/office/validate.py
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/validators/__init__.py
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/validators/base.py
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/validators/docx.py
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/validators/pptx.py
create mode 100644 src/crates/core/builtin_skills/docx/scripts/office/validators/redlining.py
create mode 100644 src/crates/core/builtin_skills/docx/scripts/templates/comments.xml
create mode 100644 src/crates/core/builtin_skills/docx/scripts/templates/commentsExtended.xml
create mode 100644 src/crates/core/builtin_skills/docx/scripts/templates/commentsExtensible.xml
create mode 100644 src/crates/core/builtin_skills/docx/scripts/templates/commentsIds.xml
create mode 100644 src/crates/core/builtin_skills/docx/scripts/templates/people.xml
create mode 100644 src/crates/core/builtin_skills/pdf/LICENSE.txt
create mode 100644 src/crates/core/builtin_skills/pdf/SKILL.md
create mode 100644 src/crates/core/builtin_skills/pdf/forms.md
create mode 100644 src/crates/core/builtin_skills/pdf/reference.md
create mode 100644 src/crates/core/builtin_skills/pdf/scripts/check_bounding_boxes.py
create mode 100644 src/crates/core/builtin_skills/pdf/scripts/check_fillable_fields.py
create mode 100644 src/crates/core/builtin_skills/pdf/scripts/convert_pdf_to_images.py
create mode 100644 src/crates/core/builtin_skills/pdf/scripts/create_validation_image.py
create mode 100644 src/crates/core/builtin_skills/pdf/scripts/extract_form_field_info.py
create mode 100755 src/crates/core/builtin_skills/pdf/scripts/extract_form_structure.py
create mode 100644 src/crates/core/builtin_skills/pdf/scripts/fill_fillable_fields.py
create mode 100644 src/crates/core/builtin_skills/pdf/scripts/fill_pdf_form_with_annotations.py
create mode 100644 src/crates/core/builtin_skills/pptx/LICENSE.txt
create mode 100644 src/crates/core/builtin_skills/pptx/SKILL.md
create mode 100644 src/crates/core/builtin_skills/pptx/editing.md
create mode 100644 src/crates/core/builtin_skills/pptx/pptxgenjs.md
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/__init__.py
create mode 100755 src/crates/core/builtin_skills/pptx/scripts/add_slide.py
create mode 100755 src/crates/core/builtin_skills/pptx/scripts/clean.py
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/helpers/__init__.py
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/helpers/merge_runs.py
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/helpers/simplify_redlines.py
create mode 100755 src/crates/core/builtin_skills/pptx/scripts/office/pack.py
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/mce/mc.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2010.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2012.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-2018.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-cex-2018.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-cid-2016.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/schemas/microsoft/wml-symex-2015.xsd
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/soffice.py
create mode 100755 src/crates/core/builtin_skills/pptx/scripts/office/unpack.py
create mode 100755 src/crates/core/builtin_skills/pptx/scripts/office/validate.py
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/validators/__init__.py
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/validators/base.py
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/validators/docx.py
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/validators/pptx.py
create mode 100644 src/crates/core/builtin_skills/pptx/scripts/office/validators/redlining.py
create mode 100755 src/crates/core/builtin_skills/pptx/scripts/thumbnail.py
create mode 100644 src/crates/core/builtin_skills/xlsx/LICENSE.txt
create mode 100644 src/crates/core/builtin_skills/xlsx/SKILL.md
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/helpers/__init__.py
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/helpers/merge_runs.py
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/helpers/simplify_redlines.py
create mode 100755 src/crates/core/builtin_skills/xlsx/scripts/office/pack.py
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/mce/mc.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2010.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2012.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-2018.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-cex-2018.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-cid-2016.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/schemas/microsoft/wml-symex-2015.xsd
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/soffice.py
create mode 100755 src/crates/core/builtin_skills/xlsx/scripts/office/unpack.py
create mode 100755 src/crates/core/builtin_skills/xlsx/scripts/office/validate.py
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/validators/__init__.py
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/validators/base.py
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/validators/docx.py
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/validators/pptx.py
create mode 100644 src/crates/core/builtin_skills/xlsx/scripts/office/validators/redlining.py
create mode 100755 src/crates/core/builtin_skills/xlsx/scripts/recalc.py
create mode 100644 src/crates/core/src/agentic/agents/cowork_mode.rs
create mode 100644 src/crates/core/src/agentic/agents/prompts/cowork_mode.md
create mode 100644 src/crates/core/src/agentic/tools/implementations/skills/builtin.rs
create mode 100644 src/web-ui/src/infrastructure/api/service-api/PluginAPI.ts
create mode 100644 src/web-ui/src/infrastructure/config/components/PluginsConfig.scss
create mode 100644 src/web-ui/src/infrastructure/config/components/PluginsConfig.tsx
create mode 100644 src/web-ui/src/locales/en-US/settings/plugins.json
create mode 100644 src/web-ui/src/locales/zh-CN/settings/plugins.json
diff --git a/Cargo.toml b/Cargo.toml
index 4fe4157c..f388afc3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -56,6 +56,7 @@ lazy_static = "1.4"
dashmap = "5.5"
indexmap = "2.6"
num_cpus = "1.16"
+include_dir = "0.7.4"
# HTTP client
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "multipart"] }
diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml
index 76a4d1ce..12fa02fc 100644
--- a/src/apps/desktop/Cargo.toml
+++ b/src/apps/desktop/Cargo.toml
@@ -43,6 +43,8 @@ similar = { workspace = true }
dashmap = { workspace = true }
ignore = { workspace = true }
urlencoding = { workspace = true }
+uuid = { workspace = true }
+zip = { workspace = true }
[target.'cfg(windows)'.dependencies]
win32job = { workspace = true }
diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs
index f9e6f030..0dec465c 100644
--- a/src/apps/desktop/src/api/mod.rs
+++ b/src/apps/desktop/src/api/mod.rs
@@ -18,6 +18,7 @@ pub mod image_analysis_api;
pub mod lsp_api;
pub mod lsp_workspace_api;
pub mod mcp_api;
+pub mod plugin_api;
pub mod project_context_api;
pub mod prompt_template_api;
pub mod skill_api;
diff --git a/src/apps/desktop/src/api/plugin_api.rs b/src/apps/desktop/src/api/plugin_api.rs
new file mode 100644
index 00000000..8b095e51
--- /dev/null
+++ b/src/apps/desktop/src/api/plugin_api.rs
@@ -0,0 +1,526 @@
+//! Plugin management API
+//!
+//! Supports installing/uninstalling plugins, toggling enabled state, and importing MCP servers
+//! from plugin `.mcp.json` into the user's MCP config.
+
+use crate::api::app_state::AppState;
+use bitfun_core::infrastructure::get_path_manager_arc;
+use log::{debug, info, warn};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use tauri::State;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PluginManifest {
+ pub name: String,
+ pub version: Option,
+ pub description: Option,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PluginState {
+ pub enabled: bool,
+}
+
+impl Default for PluginState {
+ fn default() -> Self {
+ Self { enabled: true }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PluginInfo {
+ pub id: String,
+ pub name: String,
+ pub version: Option,
+ pub description: Option,
+ pub path: String,
+ pub enabled: bool,
+ pub has_mcp_config: bool,
+ pub mcp_server_count: usize,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ImportMcpServersResult {
+ pub added: usize,
+ pub skipped: usize,
+ pub overwritten: usize,
+}
+
+fn plugin_state_path(plugin_dir: &std::path::Path) -> std::path::PathBuf {
+ plugin_dir.join(".bitfun-plugin").join("state.json")
+}
+
+fn plugin_manifest_path(plugin_dir: &std::path::Path) -> std::path::PathBuf {
+ plugin_dir.join(".claude-plugin").join("plugin.json")
+}
+
+fn plugin_mcp_path(plugin_dir: &std::path::Path) -> std::path::PathBuf {
+ plugin_dir.join(".mcp.json")
+}
+
+fn validate_plugin_id(id: &str) -> Result<(), String> {
+ if id.trim().is_empty() {
+ return Err("Plugin id cannot be empty".to_string());
+ }
+ if id.contains('/') || id.contains('\\') {
+ return Err("Plugin id must not contain path separators".to_string());
+ }
+ Ok(())
+}
+
+async fn read_plugin_state(plugin_dir: &std::path::Path) -> PluginState {
+ let path = plugin_state_path(plugin_dir);
+ match tokio::fs::read_to_string(&path).await {
+ Ok(content) => serde_json::from_str::(&content).unwrap_or_default(),
+ Err(_) => PluginState::default(),
+ }
+}
+
+async fn write_plugin_state(plugin_dir: &std::path::Path, state: &PluginState) -> Result<(), String> {
+ let state_path = plugin_state_path(plugin_dir);
+ if let Some(parent) = state_path.parent() {
+ tokio::fs::create_dir_all(parent)
+ .await
+ .map_err(|e| format!("Failed to create plugin state directory: {}", e))?;
+ }
+ let content = serde_json::to_string_pretty(state)
+ .map_err(|e| format!("Failed to serialize plugin state: {}", e))?;
+ tokio::fs::write(&state_path, content)
+ .await
+ .map_err(|e| format!("Failed to write plugin state: {}", e))?;
+ Ok(())
+}
+
+async fn read_plugin_manifest(plugin_dir: &std::path::Path) -> Result {
+ let path = plugin_manifest_path(plugin_dir);
+ let content = tokio::fs::read_to_string(&path)
+ .await
+ .map_err(|e| format!("Failed to read plugin manifest: {}", e))?;
+ serde_json::from_str::(&content)
+ .map_err(|e| format!("Failed to parse plugin manifest: {}", e))
+}
+
+async fn count_mcp_servers(plugin_dir: &std::path::Path) -> (bool, usize) {
+ let path = plugin_mcp_path(plugin_dir);
+ let content = match tokio::fs::read_to_string(&path).await {
+ Ok(c) => c,
+ Err(_) => return (false, 0),
+ };
+ let parsed = serde_json::from_str::(&content).ok();
+ let count = parsed
+ .as_ref()
+ .and_then(|v| v.get("mcpServers"))
+ .and_then(|v| v.as_object())
+ .map(|o| o.len())
+ .unwrap_or(0);
+ (true, count)
+}
+
+async fn build_plugin_info(plugin_dir: &std::path::Path) -> Result {
+ let manifest = read_plugin_manifest(plugin_dir).await?;
+ let state = read_plugin_state(plugin_dir).await;
+ let (has_mcp_config, mcp_server_count) = count_mcp_servers(plugin_dir).await;
+
+ let id = manifest.name.clone();
+ validate_plugin_id(&id)?;
+
+ Ok(PluginInfo {
+ id: id.clone(),
+ name: manifest.name,
+ version: manifest.version,
+ description: manifest.description,
+ path: plugin_dir.to_string_lossy().to_string(),
+ enabled: state.enabled,
+ has_mcp_config,
+ mcp_server_count,
+ })
+}
+
+async fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
+ tokio::fs::create_dir_all(dst).await?;
+
+ let mut entries = tokio::fs::read_dir(src).await?;
+ while let Some(entry) = entries.next_entry().await? {
+ let ty = entry.file_type().await?;
+ let src_path = entry.path();
+ let dst_path = dst.join(entry.file_name());
+
+ if ty.is_dir() {
+ Box::pin(copy_dir_all(&src_path, &dst_path)).await?;
+ } else {
+ tokio::fs::copy(&src_path, &dst_path).await?;
+ }
+ }
+
+ Ok(())
+}
+
+fn resolve_plugin_root(extracted_root: &std::path::Path) -> Option {
+ let direct = extracted_root.to_path_buf();
+ if plugin_manifest_path(&direct).exists() {
+ return Some(direct);
+ }
+
+ // If there is exactly one top-level directory, treat it as plugin root.
+ let mut dirs = Vec::new();
+ if let Ok(read_dir) = std::fs::read_dir(extracted_root) {
+ for entry in read_dir.flatten() {
+ if let Ok(ft) = entry.file_type() {
+ if ft.is_dir() {
+ dirs.push(entry.path());
+ }
+ }
+ }
+ }
+ if dirs.len() == 1 && plugin_manifest_path(&dirs[0]).exists() {
+ return Some(dirs.remove(0));
+ }
+
+ None
+}
+
+fn safe_join(root: &std::path::Path, relative: &std::path::Path) -> Result {
+ use std::path::Component;
+ if relative.is_absolute() {
+ return Err(format!(
+ "Unexpected absolute path in plugin archive: {}",
+ relative.display()
+ ));
+ }
+ for c in relative.components() {
+ if matches!(c, Component::ParentDir) {
+ return Err(format!(
+ "Unexpected parent dir component in plugin archive path: {}",
+ relative.display()
+ ));
+ }
+ if matches!(c, Component::Prefix(_)) {
+ return Err(format!(
+ "Unexpected prefix component in plugin archive path: {}",
+ relative.display()
+ ));
+ }
+ }
+ Ok(root.join(relative))
+}
+
+async fn extract_zip_to_dir(zip_path: &std::path::Path, dest_dir: &std::path::Path) -> Result<(), String> {
+ let zip_path = zip_path.to_path_buf();
+ let dest_dir = dest_dir.to_path_buf();
+ tokio::task::spawn_blocking(move || -> Result<(), String> {
+ let file = std::fs::File::open(&zip_path)
+ .map_err(|e| format!("Failed to open plugin archive: {}", e))?;
+ let mut archive = zip::ZipArchive::new(file)
+ .map_err(|e| format!("Failed to read plugin archive: {}", e))?;
+
+ std::fs::create_dir_all(&dest_dir)
+ .map_err(|e| format!("Failed to create extraction directory: {}", e))?;
+
+ for i in 0..archive.len() {
+ let mut entry = archive
+ .by_index(i)
+ .map_err(|e| format!("Failed to read archive entry: {}", e))?;
+
+ let Some(name) = entry.enclosed_name() else {
+ return Err(format!("Unsafe path in plugin archive at entry {}", i));
+ };
+
+ let out_path = safe_join(&dest_dir, name)?;
+
+ if entry.name().ends_with('/') {
+ std::fs::create_dir_all(&out_path)
+ .map_err(|e| format!("Failed to create directory: {}", e))?;
+ continue;
+ }
+
+ if let Some(parent) = out_path.parent() {
+ std::fs::create_dir_all(parent)
+ .map_err(|e| format!("Failed to create directory: {}", e))?;
+ }
+
+ let mut out_file = std::fs::File::create(&out_path)
+ .map_err(|e| format!("Failed to create file: {}", e))?;
+ std::io::copy(&mut entry, &mut out_file)
+ .map_err(|e| format!("Failed to extract file: {}", e))?;
+ }
+
+ Ok(())
+ })
+ .await
+ .map_err(|e| format!("Plugin extraction task failed: {}", e))?
+}
+
+#[tauri::command]
+pub async fn list_plugins(_state: State<'_, AppState>) -> Result, String> {
+ let pm = get_path_manager_arc();
+ let plugins_dir = pm.user_plugins_dir();
+
+ if let Err(e) = tokio::fs::create_dir_all(&plugins_dir).await {
+ return Err(format!("Failed to create plugins directory: {}", e));
+ }
+
+ let mut result = Vec::new();
+ let mut entries = tokio::fs::read_dir(&plugins_dir)
+ .await
+ .map_err(|e| format!("Failed to read plugins directory: {}", e))?;
+
+ while let Ok(Some(entry)) = entries.next_entry().await {
+ let path = entry.path();
+ if !path.is_dir() {
+ continue;
+ }
+
+ if !plugin_manifest_path(&path).exists() {
+ continue;
+ }
+
+ match build_plugin_info(&path).await {
+ Ok(info) => result.push(info),
+ Err(e) => {
+ warn!("Skipping invalid plugin directory: path={}, error={}", path.display(), e);
+ }
+ }
+ }
+
+ result.sort_by(|a, b| a.id.cmp(&b.id));
+ Ok(result)
+}
+
+#[tauri::command]
+pub async fn install_plugin(
+ _state: State<'_, AppState>,
+ source_path: String,
+) -> Result {
+ use std::path::Path;
+
+ let pm = get_path_manager_arc();
+ let plugins_dir = pm.user_plugins_dir();
+ tokio::fs::create_dir_all(&plugins_dir)
+ .await
+ .map_err(|e| format!("Failed to create plugins directory: {}", e))?;
+
+ let source = Path::new(&source_path);
+ if !source.exists() {
+ return Err("Source path does not exist".to_string());
+ }
+
+ let temp_root = pm.temp_dir().join(format!("plugin_install_{}", uuid::Uuid::new_v4()));
+ tokio::fs::create_dir_all(&temp_root)
+ .await
+ .map_err(|e| format!("Failed to create temp directory: {}", e))?;
+
+ let plugin_root: std::path::PathBuf;
+
+ if source.is_file() {
+ extract_zip_to_dir(source, &temp_root).await?;
+ plugin_root = resolve_plugin_root(&temp_root)
+ .ok_or_else(|| "Plugin archive does not contain a valid .claude-plugin/plugin.json".to_string())?;
+ } else if source.is_dir() {
+ if !plugin_manifest_path(source).exists() {
+ return Err("Plugin folder is missing .claude-plugin/plugin.json".to_string());
+ }
+ plugin_root = source.to_path_buf();
+ } else {
+ return Err("Source path is neither file nor directory".to_string());
+ }
+
+ let manifest = read_plugin_manifest(&plugin_root).await?;
+ validate_plugin_id(&manifest.name)?;
+
+ let dest_dir = plugins_dir.join(&manifest.name);
+ if dest_dir.exists() {
+ return Err(format!("Plugin '{}' is already installed", manifest.name));
+ }
+
+ if source.is_dir() {
+ copy_dir_all(&plugin_root, &dest_dir)
+ .await
+ .map_err(|e| format!("Failed to copy plugin folder: {}", e))?;
+ } else {
+ copy_dir_all(&plugin_root, &dest_dir)
+ .await
+ .map_err(|e| format!("Failed to install plugin from archive: {}", e))?;
+ }
+
+ // Ensure default state exists (enabled=true).
+ let state = PluginState::default();
+ if let Err(e) = write_plugin_state(&dest_dir, &state).await {
+ warn!("Failed to write plugin state, continuing: {}", e);
+ }
+
+ // Cleanup temp extraction directory if used.
+ if source.is_file() {
+ if let Err(e) = tokio::fs::remove_dir_all(&temp_root).await {
+ debug!("Failed to remove temp plugin dir: path={}, error={}", temp_root.display(), e);
+ }
+ }
+
+ info!("Plugin installed: id={}, path={}", manifest.name, dest_dir.display());
+ build_plugin_info(&dest_dir).await
+}
+
+#[tauri::command]
+pub async fn uninstall_plugin(
+ _state: State<'_, AppState>,
+ plugin_id: String,
+) -> Result {
+ validate_plugin_id(&plugin_id)?;
+
+ let pm = get_path_manager_arc();
+ let plugin_dir = pm.user_plugins_dir().join(&plugin_id);
+ if !plugin_dir.exists() {
+ return Err(format!("Plugin '{}' not found", plugin_id));
+ }
+
+ tokio::fs::remove_dir_all(&plugin_dir)
+ .await
+ .map_err(|e| format!("Failed to uninstall plugin: {}", e))?;
+
+ info!("Plugin uninstalled: id={}", plugin_id);
+ Ok(format!("Plugin '{}' uninstalled", plugin_id))
+}
+
+#[tauri::command]
+pub async fn set_plugin_enabled(
+ _state: State<'_, AppState>,
+ plugin_id: String,
+ enabled: bool,
+) -> Result {
+ validate_plugin_id(&plugin_id)?;
+
+ let pm = get_path_manager_arc();
+ let plugin_dir = pm.user_plugins_dir().join(&plugin_id);
+ if !plugin_dir.exists() {
+ return Err(format!("Plugin '{}' not found", plugin_id));
+ }
+ if !plugin_manifest_path(&plugin_dir).exists() {
+ return Err(format!("Plugin '{}' is missing manifest", plugin_id));
+ }
+
+ let state = PluginState { enabled };
+ write_plugin_state(&plugin_dir, &state).await?;
+
+ info!("Plugin state updated: id={}, enabled={}", plugin_id, enabled);
+ Ok(format!(
+ "Plugin '{}' {}",
+ plugin_id,
+ if enabled { "enabled" } else { "disabled" }
+ ))
+}
+
+#[tauri::command]
+pub async fn import_plugin_mcp_servers(
+ state: State<'_, AppState>,
+ plugin_id: String,
+ overwrite_existing: bool,
+) -> Result {
+ validate_plugin_id(&plugin_id)?;
+
+ let pm = get_path_manager_arc();
+ let plugin_dir = pm.user_plugins_dir().join(&plugin_id);
+ if !plugin_dir.exists() {
+ return Err(format!("Plugin '{}' not found", plugin_id));
+ }
+
+ let mcp_path = plugin_mcp_path(&plugin_dir);
+ if !mcp_path.exists() {
+ return Err("Plugin does not provide .mcp.json".to_string());
+ }
+
+ let plugin_mcp_content = tokio::fs::read_to_string(&mcp_path)
+ .await
+ .map_err(|e| format!("Failed to read plugin .mcp.json: {}", e))?;
+ let plugin_mcp_json: Value = serde_json::from_str(&plugin_mcp_content)
+ .map_err(|e| format!("Invalid plugin .mcp.json: {}", e))?;
+
+ let plugin_servers = plugin_mcp_json
+ .get("mcpServers")
+ .and_then(|v| v.as_object())
+ .ok_or_else(|| "Plugin .mcp.json missing 'mcpServers' object".to_string())?;
+
+ // Load existing user MCP config (Cursor format).
+ let current_value = state
+ .config_service
+ .get_config::(Some("mcp_servers"))
+ .await
+ .unwrap_or_else(|_| serde_json::json!({ "mcpServers": {} }));
+
+ let mut merged_root = if current_value.is_null() {
+ serde_json::json!({ "mcpServers": {} })
+ } else {
+ current_value
+ };
+
+ if merged_root.get("mcpServers").is_none() {
+ // Support array format by converting to cursor format-ish.
+ if let Some(arr) = merged_root.as_array() {
+ let mut map = serde_json::Map::new();
+ for item in arr {
+ if let Some(id) = item.get("id").and_then(|v| v.as_str()) {
+ map.insert(id.to_string(), item.clone());
+ }
+ }
+ merged_root = serde_json::json!({ "mcpServers": map });
+ } else {
+ merged_root = serde_json::json!({ "mcpServers": {} });
+ }
+ }
+
+ let merged_servers = merged_root
+ .get_mut("mcpServers")
+ .and_then(|v| v.as_object_mut())
+ .ok_or_else(|| "Internal error: mcpServers is not an object".to_string())?;
+
+ let mut added = 0usize;
+ let mut skipped = 0usize;
+ let mut overwritten = 0usize;
+
+ for (server_id, server_config) in plugin_servers {
+ if merged_servers.contains_key(server_id) {
+ if overwrite_existing {
+ merged_servers.insert(server_id.clone(), server_config.clone());
+ overwritten += 1;
+ } else {
+ skipped += 1;
+ }
+ } else {
+ merged_servers.insert(server_id.clone(), server_config.clone());
+ added += 1;
+ }
+ }
+
+ state
+ .config_service
+ .set_config("mcp_servers", merged_root)
+ .await
+ .map_err(|e| format!("Failed to save MCP config: {}", e))?;
+
+ // Best-effort: register imported servers into the running MCP registry so they can be
+ // started/restarted immediately without requiring a full initialize.
+ if let Some(mcp_service) = state.mcp_service.as_ref() {
+ for server_id in plugin_servers.keys() {
+ if let Err(e) = mcp_service.server_manager().ensure_registered(server_id).await {
+ warn!(
+ "Failed to register imported MCP server (continuing): server_id={} error={}",
+ server_id, e
+ );
+ }
+ }
+ }
+
+ info!(
+ "Imported plugin MCP servers: plugin={}, added={}, overwritten={}, skipped={}",
+ plugin_id, added, overwritten, skipped
+ );
+
+ Ok(ImportMcpServersResult {
+ added,
+ skipped,
+ overwritten,
+ })
+}
diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs
index 687f11cb..9cd3f759 100644
--- a/src/apps/desktop/src/lib.rs
+++ b/src/apps/desktop/src/lib.rs
@@ -418,6 +418,11 @@ pub async fn run() {
api::project_context_api::delete_imported_document,
api::project_context_api::toggle_imported_document_enabled,
api::project_context_api::delete_context_document,
+ api::plugin_api::list_plugins,
+ api::plugin_api::install_plugin,
+ api::plugin_api::uninstall_plugin,
+ api::plugin_api::set_plugin_enabled,
+ api::plugin_api::import_plugin_mcp_servers,
initialize_mcp_servers,
get_mcp_servers,
start_mcp_server,
diff --git a/src/crates/core/Cargo.toml b/src/crates/core/Cargo.toml
index 1521ea88..19cad046 100644
--- a/src/crates/core/Cargo.toml
+++ b/src/crates/core/Cargo.toml
@@ -52,6 +52,7 @@ dunce = { workspace = true }
filetime = { workspace = true }
zip = { workspace = true }
flate2 = { workspace = true }
+include_dir = { workspace = true }
git2 = { workspace = true }
portable-pty = { workspace = true }
@@ -95,4 +96,3 @@ win32job = { workspace = true }
[features]
default = []
tauri-support = ["tauri"] # Optional tauri support
-
diff --git a/src/crates/core/builtin_skills/docx/LICENSE.txt b/src/crates/core/builtin_skills/docx/LICENSE.txt
new file mode 100644
index 00000000..c55ab422
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/LICENSE.txt
@@ -0,0 +1,30 @@
+© 2025 Anthropic, PBC. All rights reserved.
+
+LICENSE: Use of these materials (including all code, prompts, assets, files,
+and other components of this Skill) is governed by your agreement with
+Anthropic regarding use of Anthropic's services. If no separate agreement
+exists, use is governed by Anthropic's Consumer Terms of Service or
+Commercial Terms of Service, as applicable:
+https://www.anthropic.com/legal/consumer-terms
+https://www.anthropic.com/legal/commercial-terms
+Your applicable agreement is referred to as the "Agreement." "Services" are
+as defined in the Agreement.
+
+ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the
+contrary, users may not:
+
+- Extract these materials from the Services or retain copies of these
+ materials outside the Services
+- Reproduce or copy these materials, except for temporary copies created
+ automatically during authorized use of the Services
+- Create derivative works based on these materials
+- Distribute, sublicense, or transfer these materials to any third party
+- Make, offer to sell, sell, or import any inventions embodied in these
+ materials
+- Reverse engineer, decompile, or disassemble these materials
+
+The receipt, viewing, or possession of these materials does not convey or
+imply any license or right beyond those expressly granted above.
+
+Anthropic retains all right, title, and interest in these materials,
+including all copyrights, patents, and other intellectual property rights.
diff --git a/src/crates/core/builtin_skills/docx/SKILL.md b/src/crates/core/builtin_skills/docx/SKILL.md
new file mode 100644
index 00000000..ad2e1750
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/SKILL.md
@@ -0,0 +1,481 @@
+---
+name: docx
+description: "Use this skill whenever the user wants to create, read, edit, or manipulate Word documents (.docx files). Triggers include: any mention of \"Word doc\", \"word document\", \".docx\", or requests to produce professional documents with formatting like tables of contents, headings, page numbers, or letterheads. Also use when extracting or reorganizing content from .docx files, inserting or replacing images in documents, performing find-and-replace in Word files, working with tracked changes or comments, or converting content into a polished Word document. If the user asks for a \"report\", \"memo\", \"letter\", \"template\", or similar deliverable as a Word or .docx file, use this skill. Do NOT use for PDFs, spreadsheets, Google Docs, or general coding tasks unrelated to document generation."
+license: Proprietary. LICENSE.txt has complete terms
+---
+
+# DOCX creation, editing, and analysis
+
+## Overview
+
+A .docx file is a ZIP archive containing XML files.
+
+## Quick Reference
+
+| Task | Approach |
+|------|----------|
+| Read/analyze content | `pandoc` or unpack for raw XML |
+| Create new document | Use `docx-js` - see Creating New Documents below |
+| Edit existing document | Unpack → edit XML → repack - see Editing Existing Documents below |
+
+### Converting .doc to .docx
+
+Legacy `.doc` files must be converted before editing:
+
+```bash
+python scripts/office/soffice.py --headless --convert-to docx document.doc
+```
+
+### Reading Content
+
+```bash
+# Text extraction with tracked changes
+pandoc --track-changes=all document.docx -o output.md
+
+# Raw XML access
+python scripts/office/unpack.py document.docx unpacked/
+```
+
+### Converting to Images
+
+```bash
+python scripts/office/soffice.py --headless --convert-to pdf document.docx
+pdftoppm -jpeg -r 150 document.pdf page
+```
+
+### Accepting Tracked Changes
+
+To produce a clean document with all tracked changes accepted (requires LibreOffice):
+
+```bash
+python scripts/accept_changes.py input.docx output.docx
+```
+
+---
+
+## Creating New Documents
+
+Generate .docx files with JavaScript, then validate. Install: `npm install -g docx`
+
+### Setup
+```javascript
+const { Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell, ImageRun,
+ Header, Footer, AlignmentType, PageOrientation, LevelFormat, ExternalHyperlink,
+ TableOfContents, HeadingLevel, BorderStyle, WidthType, ShadingType,
+ VerticalAlign, PageNumber, PageBreak } = require('docx');
+
+const doc = new Document({ sections: [{ children: [/* content */] }] });
+Packer.toBuffer(doc).then(buffer => fs.writeFileSync("doc.docx", buffer));
+```
+
+### Validation
+After creating the file, validate it. If validation fails, unpack, fix the XML, and repack.
+```bash
+python scripts/office/validate.py doc.docx
+```
+
+### Page Size
+
+```javascript
+// CRITICAL: docx-js defaults to A4, not US Letter
+// Always set page size explicitly for consistent results
+sections: [{
+ properties: {
+ page: {
+ size: {
+ width: 12240, // 8.5 inches in DXA
+ height: 15840 // 11 inches in DXA
+ },
+ margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 } // 1 inch margins
+ }
+ },
+ children: [/* content */]
+}]
+```
+
+**Common page sizes (DXA units, 1440 DXA = 1 inch):**
+
+| Paper | Width | Height | Content Width (1" margins) |
+|-------|-------|--------|---------------------------|
+| US Letter | 12,240 | 15,840 | 9,360 |
+| A4 (default) | 11,906 | 16,838 | 9,026 |
+
+**Landscape orientation:** docx-js swaps width/height internally, so pass portrait dimensions and let it handle the swap:
+```javascript
+size: {
+ width: 12240, // Pass SHORT edge as width
+ height: 15840, // Pass LONG edge as height
+ orientation: PageOrientation.LANDSCAPE // docx-js swaps them in the XML
+},
+// Content width = 15840 - left margin - right margin (uses the long edge)
+```
+
+### Styles (Override Built-in Headings)
+
+Use Arial as the default font (universally supported). Keep titles black for readability.
+
+```javascript
+const doc = new Document({
+ styles: {
+ default: { document: { run: { font: "Arial", size: 24 } } }, // 12pt default
+ paragraphStyles: [
+ // IMPORTANT: Use exact IDs to override built-in styles
+ { id: "Heading1", name: "Heading 1", basedOn: "Normal", next: "Normal", quickFormat: true,
+ run: { size: 32, bold: true, font: "Arial" },
+ paragraph: { spacing: { before: 240, after: 240 }, outlineLevel: 0 } }, // outlineLevel required for TOC
+ { id: "Heading2", name: "Heading 2", basedOn: "Normal", next: "Normal", quickFormat: true,
+ run: { size: 28, bold: true, font: "Arial" },
+ paragraph: { spacing: { before: 180, after: 180 }, outlineLevel: 1 } },
+ ]
+ },
+ sections: [{
+ children: [
+ new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun("Title")] }),
+ ]
+ }]
+});
+```
+
+### Lists (NEVER use unicode bullets)
+
+```javascript
+// ❌ WRONG - never manually insert bullet characters
+new Paragraph({ children: [new TextRun("• Item")] }) // BAD
+new Paragraph({ children: [new TextRun("\u2022 Item")] }) // BAD
+
+// ✅ CORRECT - use numbering config with LevelFormat.BULLET
+const doc = new Document({
+ numbering: {
+ config: [
+ { reference: "bullets",
+ levels: [{ level: 0, format: LevelFormat.BULLET, text: "•", alignment: AlignmentType.LEFT,
+ style: { paragraph: { indent: { left: 720, hanging: 360 } } } }] },
+ { reference: "numbers",
+ levels: [{ level: 0, format: LevelFormat.DECIMAL, text: "%1.", alignment: AlignmentType.LEFT,
+ style: { paragraph: { indent: { left: 720, hanging: 360 } } } }] },
+ ]
+ },
+ sections: [{
+ children: [
+ new Paragraph({ numbering: { reference: "bullets", level: 0 },
+ children: [new TextRun("Bullet item")] }),
+ new Paragraph({ numbering: { reference: "numbers", level: 0 },
+ children: [new TextRun("Numbered item")] }),
+ ]
+ }]
+});
+
+// ⚠️ Each reference creates INDEPENDENT numbering
+// Same reference = continues (1,2,3 then 4,5,6)
+// Different reference = restarts (1,2,3 then 1,2,3)
+```
+
+### Tables
+
+**CRITICAL: Tables need dual widths** - set both `columnWidths` on the table AND `width` on each cell. Without both, tables render incorrectly on some platforms.
+
+```javascript
+// CRITICAL: Always set table width for consistent rendering
+// CRITICAL: Use ShadingType.CLEAR (not SOLID) to prevent black backgrounds
+const border = { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" };
+const borders = { top: border, bottom: border, left: border, right: border };
+
+new Table({
+ width: { size: 9360, type: WidthType.DXA }, // Always use DXA (percentages break in Google Docs)
+ columnWidths: [4680, 4680], // Must sum to table width (DXA: 1440 = 1 inch)
+ rows: [
+ new TableRow({
+ children: [
+ new TableCell({
+ borders,
+ width: { size: 4680, type: WidthType.DXA }, // Also set on each cell
+ shading: { fill: "D5E8F0", type: ShadingType.CLEAR }, // CLEAR not SOLID
+ margins: { top: 80, bottom: 80, left: 120, right: 120 }, // Cell padding (internal, not added to width)
+ children: [new Paragraph({ children: [new TextRun("Cell")] })]
+ })
+ ]
+ })
+ ]
+})
+```
+
+**Table width calculation:**
+
+Always use `WidthType.DXA` — `WidthType.PERCENTAGE` breaks in Google Docs.
+
+```javascript
+// Table width = sum of columnWidths = content width
+// US Letter with 1" margins: 12240 - 2880 = 9360 DXA
+width: { size: 9360, type: WidthType.DXA },
+columnWidths: [7000, 2360] // Must sum to table width
+```
+
+**Width rules:**
+- **Always use `WidthType.DXA`** — never `WidthType.PERCENTAGE` (incompatible with Google Docs)
+- Table width must equal the sum of `columnWidths`
+- Cell `width` must match corresponding `columnWidth`
+- Cell `margins` are internal padding - they reduce content area, not add to cell width
+- For full-width tables: use content width (page width minus left and right margins)
+
+### Images
+
+```javascript
+// CRITICAL: type parameter is REQUIRED
+new Paragraph({
+ children: [new ImageRun({
+ type: "png", // Required: png, jpg, jpeg, gif, bmp, svg
+ data: fs.readFileSync("image.png"),
+ transformation: { width: 200, height: 150 },
+ altText: { title: "Title", description: "Desc", name: "Name" } // All three required
+ })]
+})
+```
+
+### Page Breaks
+
+```javascript
+// CRITICAL: PageBreak must be inside a Paragraph
+new Paragraph({ children: [new PageBreak()] })
+
+// Or use pageBreakBefore
+new Paragraph({ pageBreakBefore: true, children: [new TextRun("New page")] })
+```
+
+### Table of Contents
+
+```javascript
+// CRITICAL: Headings must use HeadingLevel ONLY - no custom styles
+new TableOfContents("Table of Contents", { hyperlink: true, headingStyleRange: "1-3" })
+```
+
+### Headers/Footers
+
+```javascript
+sections: [{
+ properties: {
+ page: { margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 } } // 1440 = 1 inch
+ },
+ headers: {
+ default: new Header({ children: [new Paragraph({ children: [new TextRun("Header")] })] })
+ },
+ footers: {
+ default: new Footer({ children: [new Paragraph({
+ children: [new TextRun("Page "), new TextRun({ children: [PageNumber.CURRENT] })]
+ })] })
+ },
+ children: [/* content */]
+}]
+```
+
+### Critical Rules for docx-js
+
+- **Set page size explicitly** - docx-js defaults to A4; use US Letter (12240 x 15840 DXA) for US documents
+- **Landscape: pass portrait dimensions** - docx-js swaps width/height internally; pass short edge as `width`, long edge as `height`, and set `orientation: PageOrientation.LANDSCAPE`
+- **Never use `\n`** - use separate Paragraph elements
+- **Never use unicode bullets** - use `LevelFormat.BULLET` with numbering config
+- **PageBreak must be in Paragraph** - standalone creates invalid XML
+- **ImageRun requires `type`** - always specify png/jpg/etc
+- **Always set table `width` with DXA** - never use `WidthType.PERCENTAGE` (breaks in Google Docs)
+- **Tables need dual widths** - `columnWidths` array AND cell `width`, both must match
+- **Table width = sum of columnWidths** - for DXA, ensure they add up exactly
+- **Always add cell margins** - use `margins: { top: 80, bottom: 80, left: 120, right: 120 }` for readable padding
+- **Use `ShadingType.CLEAR`** - never SOLID for table shading
+- **TOC requires HeadingLevel only** - no custom styles on heading paragraphs
+- **Override built-in styles** - use exact IDs: "Heading1", "Heading2", etc.
+- **Include `outlineLevel`** - required for TOC (0 for H1, 1 for H2, etc.)
+
+---
+
+## Editing Existing Documents
+
+**Follow all 3 steps in order.**
+
+### Step 1: Unpack
+```bash
+python scripts/office/unpack.py document.docx unpacked/
+```
+Extracts XML, pretty-prints, merges adjacent runs, and converts smart quotes to XML entities (`“` etc.) so they survive editing. Use `--merge-runs false` to skip run merging.
+
+### Step 2: Edit XML
+
+Edit files in `unpacked/word/`. See XML Reference below for patterns.
+
+**Use "Claude" as the author** for tracked changes and comments, unless the user explicitly requests use of a different name.
+
+**Use the Edit tool directly for string replacement. Do not write Python scripts.** Scripts introduce unnecessary complexity. The Edit tool shows exactly what is being replaced.
+
+**CRITICAL: Use smart quotes for new content.** When adding text with apostrophes or quotes, use XML entities to produce smart quotes:
+```xml
+
+Here’s a quote: “Hello”
+```
+| Entity | Character |
+|--------|-----------|
+| `‘` | ‘ (left single) |
+| `’` | ’ (right single / apostrophe) |
+| `“` | “ (left double) |
+| `”` | ” (right double) |
+
+**Adding comments:** Use `comment.py` to handle boilerplate across multiple XML files (text must be pre-escaped XML):
+```bash
+python scripts/comment.py unpacked/ 0 "Comment text with & and ’"
+python scripts/comment.py unpacked/ 1 "Reply text" --parent 0 # reply to comment 0
+python scripts/comment.py unpacked/ 0 "Text" --author "Custom Author" # custom author name
+```
+Then add markers to document.xml (see Comments in XML Reference).
+
+### Step 3: Pack
+```bash
+python scripts/office/pack.py unpacked/ output.docx --original document.docx
+```
+Validates with auto-repair, condenses XML, and creates DOCX. Use `--validate false` to skip.
+
+**Auto-repair will fix:**
+- `durableId` >= 0x7FFFFFFF (regenerates valid ID)
+- Missing `xml:space="preserve"` on `` with whitespace
+
+**Auto-repair won't fix:**
+- Malformed XML, invalid element nesting, missing relationships, schema violations
+
+### Common Pitfalls
+
+- **Replace entire `` elements**: When adding tracked changes, replace the whole `...` block with `......` as siblings. Don't inject tracked change tags inside a run.
+- **Preserve `` formatting**: Copy the original run's `` block into your tracked change runs to maintain bold, font size, etc.
+
+---
+
+## XML Reference
+
+### Schema Compliance
+
+- **Element order in ``**: ``, ``, ``, ``, ``, `` last
+- **Whitespace**: Add `xml:space="preserve"` to `` with leading/trailing spaces
+- **RSIDs**: Must be 8-digit hex (e.g., `00AB1234`)
+
+### Tracked Changes
+
+**Insertion:**
+```xml
+
+ inserted text
+
+```
+
+**Deletion:**
+```xml
+
+ deleted text
+
+```
+
+**Inside ``**: Use `` instead of ``, and `` instead of ``.
+
+**Minimal edits** - only mark what changes:
+```xml
+
+The term is
+
+ 30
+
+
+ 60
+
+ days.
+```
+
+**Deleting entire paragraphs/list items** - when removing ALL content from a paragraph, also mark the paragraph mark as deleted so it merges with the next paragraph. Add `` inside ``:
+```xml
+
+
+ ...
+
+
+
+
+
+ Entire paragraph content being deleted...
+
+
+```
+Without the `` in ``, accepting changes leaves an empty paragraph/list item.
+
+**Rejecting another author's insertion** - nest deletion inside their insertion:
+```xml
+
+
+ their inserted text
+
+
+```
+
+**Restoring another author's deletion** - add insertion after (don't modify their deletion):
+```xml
+
+ deleted text
+
+
+ deleted text
+
+```
+
+### Comments
+
+After running `comment.py` (see Step 2), add markers to document.xml. For replies, use `--parent` flag and nest markers inside the parent's.
+
+**CRITICAL: `` and `` are siblings of ``, never inside ``.**
+
+```xml
+
+
+
+ deleted
+
+ more text
+
+
+
+
+
+
+ text
+
+
+
+
+```
+
+### Images
+
+1. Add image file to `word/media/`
+2. Add relationship to `word/_rels/document.xml.rels`:
+```xml
+
+```
+3. Add content type to `[Content_Types].xml`:
+```xml
+
+```
+4. Reference in document.xml:
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+---
+
+## Dependencies
+
+- **pandoc**: Text extraction
+- **docx**: `npm install -g docx` (new documents)
+- **LibreOffice**: PDF conversion (auto-configured for sandboxed environments via `scripts/office/soffice.py`)
+- **Poppler**: `pdftoppm` for images
diff --git a/src/crates/core/builtin_skills/docx/scripts/__init__.py b/src/crates/core/builtin_skills/docx/scripts/__init__.py
new file mode 100755
index 00000000..8b137891
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/__init__.py
@@ -0,0 +1 @@
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/accept_changes.py b/src/crates/core/builtin_skills/docx/scripts/accept_changes.py
new file mode 100755
index 00000000..8e363161
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/accept_changes.py
@@ -0,0 +1,135 @@
+"""Accept all tracked changes in a DOCX file using LibreOffice.
+
+Requires LibreOffice (soffice) to be installed.
+"""
+
+import argparse
+import logging
+import shutil
+import subprocess
+from pathlib import Path
+
+from office.soffice import get_soffice_env
+
+logger = logging.getLogger(__name__)
+
+LIBREOFFICE_PROFILE = "/tmp/libreoffice_docx_profile"
+MACRO_DIR = f"{LIBREOFFICE_PROFILE}/user/basic/Standard"
+
+ACCEPT_CHANGES_MACRO = """
+
+
+ Sub AcceptAllTrackedChanges()
+ Dim document As Object
+ Dim dispatcher As Object
+
+ document = ThisComponent.CurrentController.Frame
+ dispatcher = createUnoService("com.sun.star.frame.DispatchHelper")
+
+ dispatcher.executeDispatch(document, ".uno:AcceptAllTrackedChanges", "", 0, Array())
+ ThisComponent.store()
+ ThisComponent.close(True)
+ End Sub
+"""
+
+
+def accept_changes(
+ input_file: str,
+ output_file: str,
+) -> tuple[None, str]:
+ input_path = Path(input_file)
+ output_path = Path(output_file)
+
+ if not input_path.exists():
+ return None, f"Error: Input file not found: {input_file}"
+
+ if not input_path.suffix.lower() == ".docx":
+ return None, f"Error: Input file is not a DOCX file: {input_file}"
+
+ try:
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(input_path, output_path)
+ except Exception as e:
+ return None, f"Error: Failed to copy input file to output location: {e}"
+
+ if not _setup_libreoffice_macro():
+ return None, "Error: Failed to setup LibreOffice macro"
+
+ cmd = [
+ "soffice",
+ "--headless",
+ f"-env:UserInstallation=file://{LIBREOFFICE_PROFILE}",
+ "--norestore",
+ "vnd.sun.star.script:Standard.Module1.AcceptAllTrackedChanges?language=Basic&location=application",
+ str(output_path.absolute()),
+ ]
+
+ try:
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ check=False,
+ env=get_soffice_env(),
+ )
+ except subprocess.TimeoutExpired:
+ return (
+ None,
+ f"Successfully accepted all tracked changes: {input_file} -> {output_file}",
+ )
+
+ if result.returncode != 0:
+ return None, f"Error: LibreOffice failed: {result.stderr}"
+
+ return (
+ None,
+ f"Successfully accepted all tracked changes: {input_file} -> {output_file}",
+ )
+
+
+def _setup_libreoffice_macro() -> bool:
+ macro_dir = Path(MACRO_DIR)
+ macro_file = macro_dir / "Module1.xba"
+
+ if macro_file.exists() and "AcceptAllTrackedChanges" in macro_file.read_text():
+ return True
+
+ if not macro_dir.exists():
+ subprocess.run(
+ [
+ "soffice",
+ "--headless",
+ f"-env:UserInstallation=file://{LIBREOFFICE_PROFILE}",
+ "--terminate_after_init",
+ ],
+ capture_output=True,
+ timeout=10,
+ check=False,
+ env=get_soffice_env(),
+ )
+ macro_dir.mkdir(parents=True, exist_ok=True)
+
+ try:
+ macro_file.write_text(ACCEPT_CHANGES_MACRO)
+ return True
+ except Exception as e:
+ logger.warning(f"Failed to setup LibreOffice macro: {e}")
+ return False
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(
+ description="Accept all tracked changes in a DOCX file"
+ )
+ parser.add_argument("input_file", help="Input DOCX file with tracked changes")
+ parser.add_argument(
+ "output_file", help="Output DOCX file (clean, no tracked changes)"
+ )
+ args = parser.parse_args()
+
+ _, message = accept_changes(args.input_file, args.output_file)
+ print(message)
+
+ if "Error" in message:
+ raise SystemExit(1)
diff --git a/src/crates/core/builtin_skills/docx/scripts/comment.py b/src/crates/core/builtin_skills/docx/scripts/comment.py
new file mode 100755
index 00000000..36e1c935
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/comment.py
@@ -0,0 +1,318 @@
+"""Add comments to DOCX documents.
+
+Usage:
+ python comment.py unpacked/ 0 "Comment text"
+ python comment.py unpacked/ 1 "Reply text" --parent 0
+
+Text should be pre-escaped XML (e.g., & for &, ’ for smart quotes).
+
+After running, add markers to document.xml:
+
+ ... commented content ...
+
+
+"""
+
+import argparse
+import random
+import shutil
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+
+import defusedxml.minidom
+
+TEMPLATE_DIR = Path(__file__).parent / "templates"
+NS = {
+ "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
+ "w14": "http://schemas.microsoft.com/office/word/2010/wordml",
+ "w15": "http://schemas.microsoft.com/office/word/2012/wordml",
+ "w16cid": "http://schemas.microsoft.com/office/word/2016/wordml/cid",
+ "w16cex": "http://schemas.microsoft.com/office/word/2018/wordml/cex",
+}
+
+COMMENT_XML = """\
+
+
+
+
+
+
+
+
+
+
+
+
+ {text}
+
+
+"""
+
+COMMENT_MARKER_TEMPLATE = """
+Add to document.xml (markers must be direct children of w:p, never inside w:r):
+
+ ...
+
+ """
+
+REPLY_MARKER_TEMPLATE = """
+Nest markers inside parent {pid}'s markers (markers must be direct children of w:p, never inside w:r):
+
+ ...
+
+
+ """
+
+
+def _generate_hex_id() -> str:
+ return f"{random.randint(0, 0x7FFFFFFE):08X}"
+
+
+SMART_QUOTE_ENTITIES = {
+ "\u201c": "“",
+ "\u201d": "”",
+ "\u2018": "‘",
+ "\u2019": "’",
+}
+
+
+def _encode_smart_quotes(text: str) -> str:
+ for char, entity in SMART_QUOTE_ENTITIES.items():
+ text = text.replace(char, entity)
+ return text
+
+
+def _append_xml(xml_path: Path, root_tag: str, content: str) -> None:
+ dom = defusedxml.minidom.parseString(xml_path.read_text(encoding="utf-8"))
+ root = dom.getElementsByTagName(root_tag)[0]
+ ns_attrs = " ".join(f'xmlns:{k}="{v}"' for k, v in NS.items())
+ wrapper_dom = defusedxml.minidom.parseString(f"{content}")
+ for child in wrapper_dom.documentElement.childNodes:
+ if child.nodeType == child.ELEMENT_NODE:
+ root.appendChild(dom.importNode(child, True))
+ output = _encode_smart_quotes(dom.toxml(encoding="UTF-8").decode("utf-8"))
+ xml_path.write_text(output, encoding="utf-8")
+
+
+def _find_para_id(comments_path: Path, comment_id: int) -> str | None:
+ dom = defusedxml.minidom.parseString(comments_path.read_text(encoding="utf-8"))
+ for c in dom.getElementsByTagName("w:comment"):
+ if c.getAttribute("w:id") == str(comment_id):
+ for p in c.getElementsByTagName("w:p"):
+ if pid := p.getAttribute("w14:paraId"):
+ return pid
+ return None
+
+
+def _get_next_rid(rels_path: Path) -> int:
+ dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8"))
+ max_rid = 0
+ for rel in dom.getElementsByTagName("Relationship"):
+ rid = rel.getAttribute("Id")
+ if rid and rid.startswith("rId"):
+ try:
+ max_rid = max(max_rid, int(rid[3:]))
+ except ValueError:
+ pass
+ return max_rid + 1
+
+
+def _has_relationship(rels_path: Path, target: str) -> bool:
+ dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8"))
+ for rel in dom.getElementsByTagName("Relationship"):
+ if rel.getAttribute("Target") == target:
+ return True
+ return False
+
+
+def _has_content_type(ct_path: Path, part_name: str) -> bool:
+ dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8"))
+ for override in dom.getElementsByTagName("Override"):
+ if override.getAttribute("PartName") == part_name:
+ return True
+ return False
+
+
+def _ensure_comment_relationships(unpacked_dir: Path) -> None:
+ rels_path = unpacked_dir / "word" / "_rels" / "document.xml.rels"
+ if not rels_path.exists():
+ return
+
+ if _has_relationship(rels_path, "comments.xml"):
+ return
+
+ dom = defusedxml.minidom.parseString(rels_path.read_text(encoding="utf-8"))
+ root = dom.documentElement
+ next_rid = _get_next_rid(rels_path)
+
+ rels = [
+ (
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments",
+ "comments.xml",
+ ),
+ (
+ "http://schemas.microsoft.com/office/2011/relationships/commentsExtended",
+ "commentsExtended.xml",
+ ),
+ (
+ "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds",
+ "commentsIds.xml",
+ ),
+ (
+ "http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible",
+ "commentsExtensible.xml",
+ ),
+ ]
+
+ for rel_type, target in rels:
+ rel = dom.createElement("Relationship")
+ rel.setAttribute("Id", f"rId{next_rid}")
+ rel.setAttribute("Type", rel_type)
+ rel.setAttribute("Target", target)
+ root.appendChild(rel)
+ next_rid += 1
+
+ rels_path.write_bytes(dom.toxml(encoding="UTF-8"))
+
+
+def _ensure_comment_content_types(unpacked_dir: Path) -> None:
+ ct_path = unpacked_dir / "[Content_Types].xml"
+ if not ct_path.exists():
+ return
+
+ if _has_content_type(ct_path, "/word/comments.xml"):
+ return
+
+ dom = defusedxml.minidom.parseString(ct_path.read_text(encoding="utf-8"))
+ root = dom.documentElement
+
+ overrides = [
+ (
+ "/word/comments.xml",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
+ ),
+ (
+ "/word/commentsExtended.xml",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml",
+ ),
+ (
+ "/word/commentsIds.xml",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml",
+ ),
+ (
+ "/word/commentsExtensible.xml",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml",
+ ),
+ ]
+
+ for part_name, content_type in overrides:
+ override = dom.createElement("Override")
+ override.setAttribute("PartName", part_name)
+ override.setAttribute("ContentType", content_type)
+ root.appendChild(override)
+
+ ct_path.write_bytes(dom.toxml(encoding="UTF-8"))
+
+
+def add_comment(
+ unpacked_dir: str,
+ comment_id: int,
+ text: str,
+ author: str = "Claude",
+ initials: str = "C",
+ parent_id: int | None = None,
+) -> tuple[str, str]:
+ word = Path(unpacked_dir) / "word"
+ if not word.exists():
+ return "", f"Error: {word} not found"
+
+ para_id, durable_id = _generate_hex_id(), _generate_hex_id()
+ ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ comments = word / "comments.xml"
+ first_comment = not comments.exists()
+ if first_comment:
+ shutil.copy(TEMPLATE_DIR / "comments.xml", comments)
+ _ensure_comment_relationships(Path(unpacked_dir))
+ _ensure_comment_content_types(Path(unpacked_dir))
+ _append_xml(
+ comments,
+ "w:comments",
+ COMMENT_XML.format(
+ id=comment_id,
+ author=author,
+ date=ts,
+ initials=initials,
+ para_id=para_id,
+ text=text,
+ ),
+ )
+
+ ext = word / "commentsExtended.xml"
+ if not ext.exists():
+ shutil.copy(TEMPLATE_DIR / "commentsExtended.xml", ext)
+ if parent_id is not None:
+ parent_para = _find_para_id(comments, parent_id)
+ if not parent_para:
+ return "", f"Error: Parent comment {parent_id} not found"
+ _append_xml(
+ ext,
+ "w15:commentsEx",
+ f'',
+ )
+ else:
+ _append_xml(
+ ext,
+ "w15:commentsEx",
+ f'',
+ )
+
+ ids = word / "commentsIds.xml"
+ if not ids.exists():
+ shutil.copy(TEMPLATE_DIR / "commentsIds.xml", ids)
+ _append_xml(
+ ids,
+ "w16cid:commentsIds",
+ f'',
+ )
+
+ extensible = word / "commentsExtensible.xml"
+ if not extensible.exists():
+ shutil.copy(TEMPLATE_DIR / "commentsExtensible.xml", extensible)
+ _append_xml(
+ extensible,
+ "w16cex:commentsExtensible",
+ f'',
+ )
+
+ action = "reply" if parent_id is not None else "comment"
+ return para_id, f"Added {action} {comment_id} (para_id={para_id})"
+
+
+if __name__ == "__main__":
+ p = argparse.ArgumentParser(description="Add comments to DOCX documents")
+ p.add_argument("unpacked_dir", help="Unpacked DOCX directory")
+ p.add_argument("comment_id", type=int, help="Comment ID (must be unique)")
+ p.add_argument("text", help="Comment text")
+ p.add_argument("--author", default="Claude", help="Author name")
+ p.add_argument("--initials", default="C", help="Author initials")
+ p.add_argument("--parent", type=int, help="Parent comment ID (for replies)")
+ args = p.parse_args()
+
+ para_id, msg = add_comment(
+ args.unpacked_dir,
+ args.comment_id,
+ args.text,
+ args.author,
+ args.initials,
+ args.parent,
+ )
+ print(msg)
+ if "Error" in msg:
+ sys.exit(1)
+ cid = args.comment_id
+ if args.parent is not None:
+ print(REPLY_MARKER_TEMPLATE.format(pid=args.parent, cid=cid))
+ else:
+ print(COMMENT_MARKER_TEMPLATE.format(cid=cid))
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/helpers/__init__.py b/src/crates/core/builtin_skills/docx/scripts/office/helpers/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/helpers/merge_runs.py b/src/crates/core/builtin_skills/docx/scripts/office/helpers/merge_runs.py
new file mode 100644
index 00000000..ad7c25ee
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/helpers/merge_runs.py
@@ -0,0 +1,199 @@
+"""Merge adjacent runs with identical formatting in DOCX.
+
+Merges adjacent elements that have identical properties.
+Works on runs in paragraphs and inside tracked changes (, ).
+
+Also:
+- Removes rsid attributes from runs (revision metadata that doesn't affect rendering)
+- Removes proofErr elements (spell/grammar markers that block merging)
+"""
+
+from pathlib import Path
+
+import defusedxml.minidom
+
+
+def merge_runs(input_dir: str) -> tuple[int, str]:
+ doc_xml = Path(input_dir) / "word" / "document.xml"
+
+ if not doc_xml.exists():
+ return 0, f"Error: {doc_xml} not found"
+
+ try:
+ dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8"))
+ root = dom.documentElement
+
+ _remove_elements(root, "proofErr")
+ _strip_run_rsid_attrs(root)
+
+ containers = {run.parentNode for run in _find_elements(root, "r")}
+
+ merge_count = 0
+ for container in containers:
+ merge_count += _merge_runs_in(container)
+
+ doc_xml.write_bytes(dom.toxml(encoding="UTF-8"))
+ return merge_count, f"Merged {merge_count} runs"
+
+ except Exception as e:
+ return 0, f"Error: {e}"
+
+
+
+
+def _find_elements(root, tag: str) -> list:
+ results = []
+
+ def traverse(node):
+ if node.nodeType == node.ELEMENT_NODE:
+ name = node.localName or node.tagName
+ if name == tag or name.endswith(f":{tag}"):
+ results.append(node)
+ for child in node.childNodes:
+ traverse(child)
+
+ traverse(root)
+ return results
+
+
+def _get_child(parent, tag: str):
+ for child in parent.childNodes:
+ if child.nodeType == child.ELEMENT_NODE:
+ name = child.localName or child.tagName
+ if name == tag or name.endswith(f":{tag}"):
+ return child
+ return None
+
+
+def _get_children(parent, tag: str) -> list:
+ results = []
+ for child in parent.childNodes:
+ if child.nodeType == child.ELEMENT_NODE:
+ name = child.localName or child.tagName
+ if name == tag or name.endswith(f":{tag}"):
+ results.append(child)
+ return results
+
+
+def _is_adjacent(elem1, elem2) -> bool:
+ node = elem1.nextSibling
+ while node:
+ if node == elem2:
+ return True
+ if node.nodeType == node.ELEMENT_NODE:
+ return False
+ if node.nodeType == node.TEXT_NODE and node.data.strip():
+ return False
+ node = node.nextSibling
+ return False
+
+
+
+
+def _remove_elements(root, tag: str):
+ for elem in _find_elements(root, tag):
+ if elem.parentNode:
+ elem.parentNode.removeChild(elem)
+
+
+def _strip_run_rsid_attrs(root):
+ for run in _find_elements(root, "r"):
+ for attr in list(run.attributes.values()):
+ if "rsid" in attr.name.lower():
+ run.removeAttribute(attr.name)
+
+
+
+
+def _merge_runs_in(container) -> int:
+ merge_count = 0
+ run = _first_child_run(container)
+
+ while run:
+ while True:
+ next_elem = _next_element_sibling(run)
+ if next_elem and _is_run(next_elem) and _can_merge(run, next_elem):
+ _merge_run_content(run, next_elem)
+ container.removeChild(next_elem)
+ merge_count += 1
+ else:
+ break
+
+ _consolidate_text(run)
+ run = _next_sibling_run(run)
+
+ return merge_count
+
+
+def _first_child_run(container):
+ for child in container.childNodes:
+ if child.nodeType == child.ELEMENT_NODE and _is_run(child):
+ return child
+ return None
+
+
+def _next_element_sibling(node):
+ sibling = node.nextSibling
+ while sibling:
+ if sibling.nodeType == sibling.ELEMENT_NODE:
+ return sibling
+ sibling = sibling.nextSibling
+ return None
+
+
+def _next_sibling_run(node):
+ sibling = node.nextSibling
+ while sibling:
+ if sibling.nodeType == sibling.ELEMENT_NODE:
+ if _is_run(sibling):
+ return sibling
+ sibling = sibling.nextSibling
+ return None
+
+
+def _is_run(node) -> bool:
+ name = node.localName or node.tagName
+ return name == "r" or name.endswith(":r")
+
+
+def _can_merge(run1, run2) -> bool:
+ rpr1 = _get_child(run1, "rPr")
+ rpr2 = _get_child(run2, "rPr")
+
+ if (rpr1 is None) != (rpr2 is None):
+ return False
+ if rpr1 is None:
+ return True
+ return rpr1.toxml() == rpr2.toxml()
+
+
+def _merge_run_content(target, source):
+ for child in list(source.childNodes):
+ if child.nodeType == child.ELEMENT_NODE:
+ name = child.localName or child.tagName
+ if name != "rPr" and not name.endswith(":rPr"):
+ target.appendChild(child)
+
+
+def _consolidate_text(run):
+ t_elements = _get_children(run, "t")
+
+ for i in range(len(t_elements) - 1, 0, -1):
+ curr, prev = t_elements[i], t_elements[i - 1]
+
+ if _is_adjacent(prev, curr):
+ prev_text = prev.firstChild.data if prev.firstChild else ""
+ curr_text = curr.firstChild.data if curr.firstChild else ""
+ merged = prev_text + curr_text
+
+ if prev.firstChild:
+ prev.firstChild.data = merged
+ else:
+ prev.appendChild(run.ownerDocument.createTextNode(merged))
+
+ if merged.startswith(" ") or merged.endswith(" "):
+ prev.setAttribute("xml:space", "preserve")
+ elif prev.hasAttribute("xml:space"):
+ prev.removeAttribute("xml:space")
+
+ run.removeChild(curr)
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/helpers/simplify_redlines.py b/src/crates/core/builtin_skills/docx/scripts/office/helpers/simplify_redlines.py
new file mode 100644
index 00000000..db963bb9
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/helpers/simplify_redlines.py
@@ -0,0 +1,197 @@
+"""Simplify tracked changes by merging adjacent w:ins or w:del elements.
+
+Merges adjacent elements from the same author into a single element.
+Same for elements. This makes heavily-redlined documents easier to
+work with by reducing the number of tracked change wrappers.
+
+Rules:
+- Only merges w:ins with w:ins, w:del with w:del (same element type)
+- Only merges if same author (ignores timestamp differences)
+- Only merges if truly adjacent (only whitespace between them)
+"""
+
+import xml.etree.ElementTree as ET
+import zipfile
+from pathlib import Path
+
+import defusedxml.minidom
+
+WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
+
+
+def simplify_redlines(input_dir: str) -> tuple[int, str]:
+ doc_xml = Path(input_dir) / "word" / "document.xml"
+
+ if not doc_xml.exists():
+ return 0, f"Error: {doc_xml} not found"
+
+ try:
+ dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8"))
+ root = dom.documentElement
+
+ merge_count = 0
+
+ containers = _find_elements(root, "p") + _find_elements(root, "tc")
+
+ for container in containers:
+ merge_count += _merge_tracked_changes_in(container, "ins")
+ merge_count += _merge_tracked_changes_in(container, "del")
+
+ doc_xml.write_bytes(dom.toxml(encoding="UTF-8"))
+ return merge_count, f"Simplified {merge_count} tracked changes"
+
+ except Exception as e:
+ return 0, f"Error: {e}"
+
+
+def _merge_tracked_changes_in(container, tag: str) -> int:
+ merge_count = 0
+
+ tracked = [
+ child
+ for child in container.childNodes
+ if child.nodeType == child.ELEMENT_NODE and _is_element(child, tag)
+ ]
+
+ if len(tracked) < 2:
+ return 0
+
+ i = 0
+ while i < len(tracked) - 1:
+ curr = tracked[i]
+ next_elem = tracked[i + 1]
+
+ if _can_merge_tracked(curr, next_elem):
+ _merge_tracked_content(curr, next_elem)
+ container.removeChild(next_elem)
+ tracked.pop(i + 1)
+ merge_count += 1
+ else:
+ i += 1
+
+ return merge_count
+
+
+def _is_element(node, tag: str) -> bool:
+ name = node.localName or node.tagName
+ return name == tag or name.endswith(f":{tag}")
+
+
+def _get_author(elem) -> str:
+ author = elem.getAttribute("w:author")
+ if not author:
+ for attr in elem.attributes.values():
+ if attr.localName == "author" or attr.name.endswith(":author"):
+ return attr.value
+ return author
+
+
+def _can_merge_tracked(elem1, elem2) -> bool:
+ if _get_author(elem1) != _get_author(elem2):
+ return False
+
+ node = elem1.nextSibling
+ while node and node != elem2:
+ if node.nodeType == node.ELEMENT_NODE:
+ return False
+ if node.nodeType == node.TEXT_NODE and node.data.strip():
+ return False
+ node = node.nextSibling
+
+ return True
+
+
+def _merge_tracked_content(target, source):
+ while source.firstChild:
+ child = source.firstChild
+ source.removeChild(child)
+ target.appendChild(child)
+
+
+def _find_elements(root, tag: str) -> list:
+ results = []
+
+ def traverse(node):
+ if node.nodeType == node.ELEMENT_NODE:
+ name = node.localName or node.tagName
+ if name == tag or name.endswith(f":{tag}"):
+ results.append(node)
+ for child in node.childNodes:
+ traverse(child)
+
+ traverse(root)
+ return results
+
+
+def get_tracked_change_authors(doc_xml_path: Path) -> dict[str, int]:
+ if not doc_xml_path.exists():
+ return {}
+
+ try:
+ tree = ET.parse(doc_xml_path)
+ root = tree.getroot()
+ except ET.ParseError:
+ return {}
+
+ namespaces = {"w": WORD_NS}
+ author_attr = f"{{{WORD_NS}}}author"
+
+ authors: dict[str, int] = {}
+ for tag in ["ins", "del"]:
+ for elem in root.findall(f".//w:{tag}", namespaces):
+ author = elem.get(author_attr)
+ if author:
+ authors[author] = authors.get(author, 0) + 1
+
+ return authors
+
+
+def _get_authors_from_docx(docx_path: Path) -> dict[str, int]:
+ try:
+ with zipfile.ZipFile(docx_path, "r") as zf:
+ if "word/document.xml" not in zf.namelist():
+ return {}
+ with zf.open("word/document.xml") as f:
+ tree = ET.parse(f)
+ root = tree.getroot()
+
+ namespaces = {"w": WORD_NS}
+ author_attr = f"{{{WORD_NS}}}author"
+
+ authors: dict[str, int] = {}
+ for tag in ["ins", "del"]:
+ for elem in root.findall(f".//w:{tag}", namespaces):
+ author = elem.get(author_attr)
+ if author:
+ authors[author] = authors.get(author, 0) + 1
+ return authors
+ except (zipfile.BadZipFile, ET.ParseError):
+ return {}
+
+
+def infer_author(modified_dir: Path, original_docx: Path, default: str = "Claude") -> str:
+ modified_xml = modified_dir / "word" / "document.xml"
+ modified_authors = get_tracked_change_authors(modified_xml)
+
+ if not modified_authors:
+ return default
+
+ original_authors = _get_authors_from_docx(original_docx)
+
+ new_changes: dict[str, int] = {}
+ for author, count in modified_authors.items():
+ original_count = original_authors.get(author, 0)
+ diff = count - original_count
+ if diff > 0:
+ new_changes[author] = diff
+
+ if not new_changes:
+ return default
+
+ if len(new_changes) == 1:
+ return next(iter(new_changes))
+
+ raise ValueError(
+ f"Multiple authors added new changes: {new_changes}. "
+ "Cannot infer which author to validate."
+ )
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/pack.py b/src/crates/core/builtin_skills/docx/scripts/office/pack.py
new file mode 100755
index 00000000..db29ed8b
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/pack.py
@@ -0,0 +1,159 @@
+"""Pack a directory into a DOCX, PPTX, or XLSX file.
+
+Validates with auto-repair, condenses XML formatting, and creates the Office file.
+
+Usage:
+ python pack.py [--original ] [--validate true|false]
+
+Examples:
+ python pack.py unpacked/ output.docx --original input.docx
+ python pack.py unpacked/ output.pptx --validate false
+"""
+
+import argparse
+import sys
+import shutil
+import tempfile
+import zipfile
+from pathlib import Path
+
+import defusedxml.minidom
+
+from validators import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator
+
+def pack(
+ input_directory: str,
+ output_file: str,
+ original_file: str | None = None,
+ validate: bool = True,
+ infer_author_func=None,
+) -> tuple[None, str]:
+ input_dir = Path(input_directory)
+ output_path = Path(output_file)
+ suffix = output_path.suffix.lower()
+
+ if not input_dir.is_dir():
+ return None, f"Error: {input_dir} is not a directory"
+
+ if suffix not in {".docx", ".pptx", ".xlsx"}:
+ return None, f"Error: {output_file} must be a .docx, .pptx, or .xlsx file"
+
+ if validate and original_file:
+ original_path = Path(original_file)
+ if original_path.exists():
+ success, output = _run_validation(
+ input_dir, original_path, suffix, infer_author_func
+ )
+ if output:
+ print(output)
+ if not success:
+ return None, f"Error: Validation failed for {input_dir}"
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_content_dir = Path(temp_dir) / "content"
+ shutil.copytree(input_dir, temp_content_dir)
+
+ for pattern in ["*.xml", "*.rels"]:
+ for xml_file in temp_content_dir.rglob(pattern):
+ _condense_xml(xml_file)
+
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
+ for f in temp_content_dir.rglob("*"):
+ if f.is_file():
+ zf.write(f, f.relative_to(temp_content_dir))
+
+ return None, f"Successfully packed {input_dir} to {output_file}"
+
+
+def _run_validation(
+ unpacked_dir: Path,
+ original_file: Path,
+ suffix: str,
+ infer_author_func=None,
+) -> tuple[bool, str | None]:
+ output_lines = []
+ validators = []
+
+ if suffix == ".docx":
+ author = "Claude"
+ if infer_author_func:
+ try:
+ author = infer_author_func(unpacked_dir, original_file)
+ except ValueError as e:
+ print(f"Warning: {e} Using default author 'Claude'.", file=sys.stderr)
+
+ validators = [
+ DOCXSchemaValidator(unpacked_dir, original_file),
+ RedliningValidator(unpacked_dir, original_file, author=author),
+ ]
+ elif suffix == ".pptx":
+ validators = [PPTXSchemaValidator(unpacked_dir, original_file)]
+
+ if not validators:
+ return True, None
+
+ total_repairs = sum(v.repair() for v in validators)
+ if total_repairs:
+ output_lines.append(f"Auto-repaired {total_repairs} issue(s)")
+
+ success = all(v.validate() for v in validators)
+
+ if success:
+ output_lines.append("All validations PASSED!")
+
+ return success, "\n".join(output_lines) if output_lines else None
+
+
+def _condense_xml(xml_file: Path) -> None:
+ try:
+ with open(xml_file, encoding="utf-8") as f:
+ dom = defusedxml.minidom.parse(f)
+
+ for element in dom.getElementsByTagName("*"):
+ if element.tagName.endswith(":t"):
+ continue
+
+ for child in list(element.childNodes):
+ if (
+ child.nodeType == child.TEXT_NODE
+ and child.nodeValue
+ and child.nodeValue.strip() == ""
+ ) or child.nodeType == child.COMMENT_NODE:
+ element.removeChild(child)
+
+ xml_file.write_bytes(dom.toxml(encoding="UTF-8"))
+ except Exception as e:
+ print(f"ERROR: Failed to parse {xml_file.name}: {e}", file=sys.stderr)
+ raise
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(
+ description="Pack a directory into a DOCX, PPTX, or XLSX file"
+ )
+ parser.add_argument("input_directory", help="Unpacked Office document directory")
+ parser.add_argument("output_file", help="Output Office file (.docx/.pptx/.xlsx)")
+ parser.add_argument(
+ "--original",
+ help="Original file for validation comparison",
+ )
+ parser.add_argument(
+ "--validate",
+ type=lambda x: x.lower() == "true",
+ default=True,
+ metavar="true|false",
+ help="Run validation with auto-repair (default: true)",
+ )
+ args = parser.parse_args()
+
+ _, message = pack(
+ args.input_directory,
+ args.output_file,
+ original_file=args.original,
+ validate=args.validate,
+ )
+ print(message)
+
+ if "Error" in message:
+ sys.exit(1)
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd
new file mode 100644
index 00000000..6454ef9a
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd
@@ -0,0 +1,1499 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd
new file mode 100644
index 00000000..afa4f463
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd
@@ -0,0 +1,146 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd
new file mode 100644
index 00000000..64e66b8a
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd
@@ -0,0 +1,1085 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd
new file mode 100644
index 00000000..687eea82
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd
new file mode 100644
index 00000000..6ac81b06
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd
@@ -0,0 +1,3081 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd
new file mode 100644
index 00000000..1dbf0514
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd
new file mode 100644
index 00000000..f1af17db
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd
@@ -0,0 +1,185 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd
new file mode 100644
index 00000000..0a185ab6
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd
@@ -0,0 +1,287 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd
new file mode 100644
index 00000000..14ef4888
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd
@@ -0,0 +1,1676 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd
new file mode 100644
index 00000000..c20f3bf1
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd
new file mode 100644
index 00000000..ac602522
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd
@@ -0,0 +1,144 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd
new file mode 100644
index 00000000..424b8ba8
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd
@@ -0,0 +1,174 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd
new file mode 100644
index 00000000..2bddce29
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd
new file mode 100644
index 00000000..8a8c18ba
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd
new file mode 100644
index 00000000..5c42706a
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd
new file mode 100644
index 00000000..853c341c
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd
new file mode 100644
index 00000000..da835ee8
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd
@@ -0,0 +1,195 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd
new file mode 100644
index 00000000..87ad2658
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd
@@ -0,0 +1,582 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd
new file mode 100644
index 00000000..9e86f1b2
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd
new file mode 100644
index 00000000..d0be42e7
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd
@@ -0,0 +1,4439 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd
new file mode 100644
index 00000000..8821dd18
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd
@@ -0,0 +1,570 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd
new file mode 100644
index 00000000..ca2575c7
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd
@@ -0,0 +1,509 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd
new file mode 100644
index 00000000..dd079e60
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd
new file mode 100644
index 00000000..3dd6cf62
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd
new file mode 100644
index 00000000..f1041e34
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd
new file mode 100644
index 00000000..9c5b7a63
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd
@@ -0,0 +1,3646 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd
new file mode 100644
index 00000000..0f13678d
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd
@@ -0,0 +1,116 @@
+
+
+
+
+
+ See http://www.w3.org/XML/1998/namespace.html and
+ http://www.w3.org/TR/REC-xml for information about this namespace.
+
+ This schema document describes the XML namespace, in a form
+ suitable for import by other schema documents.
+
+ Note that local names in this namespace are intended to be defined
+ only by the World Wide Web Consortium or its subgroups. The
+ following names are currently defined in this namespace and should
+ not be used with conflicting semantics by any Working Group,
+ specification, or document instance:
+
+ base (as an attribute name): denotes an attribute whose value
+ provides a URI to be used as the base for interpreting any
+ relative URIs in the scope of the element on which it
+ appears; its value is inherited. This name is reserved
+ by virtue of its definition in the XML Base specification.
+
+ lang (as an attribute name): denotes an attribute whose value
+ is a language code for the natural language of the content of
+ any element; its value is inherited. This name is reserved
+ by virtue of its definition in the XML specification.
+
+ space (as an attribute name): denotes an attribute whose
+ value is a keyword indicating what whitespace processing
+ discipline is intended for the content of the element; its
+ value is inherited. This name is reserved by virtue of its
+ definition in the XML specification.
+
+ Father (in any context at all): denotes Jon Bosak, the chair of
+ the original XML Working Group. This name is reserved by
+ the following decision of the W3C XML Plenary and
+ XML Coordination groups:
+
+ In appreciation for his vision, leadership and dedication
+ the W3C XML Plenary on this 10th day of February, 2000
+ reserves for Jon Bosak in perpetuity the XML name
+ xml:Father
+
+
+
+
+ This schema defines attributes and an attribute group
+ suitable for use by
+ schemas wishing to allow xml:base, xml:lang or xml:space attributes
+ on elements they define.
+
+ To enable this, such a schema must import this schema
+ for the XML namespace, e.g. as follows:
+ <schema . . .>
+ . . .
+ <import namespace="http://www.w3.org/XML/1998/namespace"
+ schemaLocation="http://www.w3.org/2001/03/xml.xsd"/>
+
+ Subsequently, qualified reference to any of the attributes
+ or the group defined below will have the desired effect, e.g.
+
+ <type . . .>
+ . . .
+ <attributeGroup ref="xml:specialAttrs"/>
+
+ will define a type which will schema-validate an instance
+ element with any of those attributes
+
+
+
+ In keeping with the XML Schema WG's standard versioning
+ policy, this schema document will persist at
+ http://www.w3.org/2001/03/xml.xsd.
+ At the date of issue it can also be found at
+ http://www.w3.org/2001/xml.xsd.
+ The schema document at that URI may however change in the future,
+ in order to remain compatible with the latest version of XML Schema
+ itself. In other words, if the XML Schema namespace changes, the version
+ of this document at
+ http://www.w3.org/2001/xml.xsd will change
+ accordingly; the version at
+ http://www.w3.org/2001/03/xml.xsd will not change.
+
+
+
+
+
+ In due course, we should install the relevant ISO 2- and 3-letter
+ codes as the enumerated possible values . . .
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ See http://www.w3.org/TR/xmlbase/ for
+ information about this attribute.
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd
new file mode 100644
index 00000000..a6de9d27
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd
new file mode 100644
index 00000000..10e978b6
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd
new file mode 100644
index 00000000..4248bf7a
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd
new file mode 100644
index 00000000..56497467
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/mce/mc.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/mce/mc.xsd
new file mode 100644
index 00000000..ef725457
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/mce/mc.xsd
@@ -0,0 +1,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd
new file mode 100644
index 00000000..f65f7777
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2010.xsd
@@ -0,0 +1,560 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd
new file mode 100644
index 00000000..6b00755a
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2012.xsd
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd
new file mode 100644
index 00000000..f321d333
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-2018.xsd
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd
new file mode 100644
index 00000000..364c6a9b
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cex-2018.xsd
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd
new file mode 100644
index 00000000..fed9d15b
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-cid-2016.xsd
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd
new file mode 100644
index 00000000..680cf154
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd b/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd
new file mode 100644
index 00000000..89ada908
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/schemas/microsoft/wml-symex-2015.xsd
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/soffice.py b/src/crates/core/builtin_skills/docx/scripts/office/soffice.py
new file mode 100644
index 00000000..c7f7e328
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/soffice.py
@@ -0,0 +1,183 @@
+"""
+Helper for running LibreOffice (soffice) in environments where AF_UNIX
+sockets may be blocked (e.g., sandboxed VMs). Detects the restriction
+at runtime and applies an LD_PRELOAD shim if needed.
+
+Usage:
+ from office.soffice import run_soffice, get_soffice_env
+
+ # Option 1 – run soffice directly
+ result = run_soffice(["--headless", "--convert-to", "pdf", "input.docx"])
+
+ # Option 2 – get env dict for your own subprocess calls
+ env = get_soffice_env()
+ subprocess.run(["soffice", ...], env=env)
+"""
+
+import os
+import socket
+import subprocess
+import tempfile
+from pathlib import Path
+
+
+def get_soffice_env() -> dict:
+ env = os.environ.copy()
+ env["SAL_USE_VCLPLUGIN"] = "svp"
+
+ if _needs_shim():
+ shim = _ensure_shim()
+ env["LD_PRELOAD"] = str(shim)
+
+ return env
+
+
+def run_soffice(args: list[str], **kwargs) -> subprocess.CompletedProcess:
+ env = get_soffice_env()
+ return subprocess.run(["soffice"] + args, env=env, **kwargs)
+
+
+
+_SHIM_SO = Path(tempfile.gettempdir()) / "lo_socket_shim.so"
+
+
+def _needs_shim() -> bool:
+ try:
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ s.close()
+ return False
+ except OSError:
+ return True
+
+
+def _ensure_shim() -> Path:
+ if _SHIM_SO.exists():
+ return _SHIM_SO
+
+ src = Path(tempfile.gettempdir()) / "lo_socket_shim.c"
+ src.write_text(_SHIM_SOURCE)
+ subprocess.run(
+ ["gcc", "-shared", "-fPIC", "-o", str(_SHIM_SO), str(src), "-ldl"],
+ check=True,
+ capture_output=True,
+ )
+ src.unlink()
+ return _SHIM_SO
+
+
+
+_SHIM_SOURCE = r"""
+#define _GNU_SOURCE
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+static int (*real_socket)(int, int, int);
+static int (*real_socketpair)(int, int, int, int[2]);
+static int (*real_listen)(int, int);
+static int (*real_accept)(int, struct sockaddr *, socklen_t *);
+static int (*real_close)(int);
+static int (*real_read)(int, void *, size_t);
+
+/* Per-FD bookkeeping (FDs >= 1024 are passed through unshimmed). */
+static int is_shimmed[1024];
+static int peer_of[1024];
+static int wake_r[1024]; /* accept() blocks reading this */
+static int wake_w[1024]; /* close() writes to this */
+static int listener_fd = -1; /* FD that received listen() */
+
+__attribute__((constructor))
+static void init(void) {
+ real_socket = dlsym(RTLD_NEXT, "socket");
+ real_socketpair = dlsym(RTLD_NEXT, "socketpair");
+ real_listen = dlsym(RTLD_NEXT, "listen");
+ real_accept = dlsym(RTLD_NEXT, "accept");
+ real_close = dlsym(RTLD_NEXT, "close");
+ real_read = dlsym(RTLD_NEXT, "read");
+ for (int i = 0; i < 1024; i++) {
+ peer_of[i] = -1;
+ wake_r[i] = -1;
+ wake_w[i] = -1;
+ }
+}
+
+/* ---- socket ---------------------------------------------------------- */
+int socket(int domain, int type, int protocol) {
+ if (domain == AF_UNIX) {
+ int fd = real_socket(domain, type, protocol);
+ if (fd >= 0) return fd;
+ /* socket(AF_UNIX) blocked – fall back to socketpair(). */
+ int sv[2];
+ if (real_socketpair(domain, type, protocol, sv) == 0) {
+ if (sv[0] >= 0 && sv[0] < 1024) {
+ is_shimmed[sv[0]] = 1;
+ peer_of[sv[0]] = sv[1];
+ int wp[2];
+ if (pipe(wp) == 0) {
+ wake_r[sv[0]] = wp[0];
+ wake_w[sv[0]] = wp[1];
+ }
+ }
+ return sv[0];
+ }
+ errno = EPERM;
+ return -1;
+ }
+ return real_socket(domain, type, protocol);
+}
+
+/* ---- listen ---------------------------------------------------------- */
+int listen(int sockfd, int backlog) {
+ if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) {
+ listener_fd = sockfd;
+ return 0;
+ }
+ return real_listen(sockfd, backlog);
+}
+
+/* ---- accept ---------------------------------------------------------- */
+int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen) {
+ if (sockfd >= 0 && sockfd < 1024 && is_shimmed[sockfd]) {
+ /* Block until close() writes to the wake pipe. */
+ if (wake_r[sockfd] >= 0) {
+ char buf;
+ real_read(wake_r[sockfd], &buf, 1);
+ }
+ errno = ECONNABORTED;
+ return -1;
+ }
+ return real_accept(sockfd, addr, addrlen);
+}
+
+/* ---- close ----------------------------------------------------------- */
+int close(int fd) {
+ if (fd >= 0 && fd < 1024 && is_shimmed[fd]) {
+ int was_listener = (fd == listener_fd);
+ is_shimmed[fd] = 0;
+
+ if (wake_w[fd] >= 0) { /* unblock accept() */
+ char c = 0;
+ write(wake_w[fd], &c, 1);
+ real_close(wake_w[fd]);
+ wake_w[fd] = -1;
+ }
+ if (wake_r[fd] >= 0) { real_close(wake_r[fd]); wake_r[fd] = -1; }
+ if (peer_of[fd] >= 0) { real_close(peer_of[fd]); peer_of[fd] = -1; }
+
+ if (was_listener)
+ _exit(0); /* conversion done – exit */
+ }
+ return real_close(fd);
+}
+"""
+
+
+
+if __name__ == "__main__":
+ import sys
+ result = run_soffice(sys.argv[1:])
+ sys.exit(result.returncode)
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/unpack.py b/src/crates/core/builtin_skills/docx/scripts/office/unpack.py
new file mode 100755
index 00000000..00152533
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/unpack.py
@@ -0,0 +1,132 @@
+"""Unpack Office files (DOCX, PPTX, XLSX) for editing.
+
+Extracts the ZIP archive, pretty-prints XML files, and optionally:
+- Merges adjacent runs with identical formatting (DOCX only)
+- Simplifies adjacent tracked changes from same author (DOCX only)
+
+Usage:
+ python unpack.py [options]
+
+Examples:
+ python unpack.py document.docx unpacked/
+ python unpack.py presentation.pptx unpacked/
+ python unpack.py document.docx unpacked/ --merge-runs false
+"""
+
+import argparse
+import sys
+import zipfile
+from pathlib import Path
+
+import defusedxml.minidom
+
+from helpers.merge_runs import merge_runs as do_merge_runs
+from helpers.simplify_redlines import simplify_redlines as do_simplify_redlines
+
+SMART_QUOTE_REPLACEMENTS = {
+ "\u201c": "“",
+ "\u201d": "”",
+ "\u2018": "‘",
+ "\u2019": "’",
+}
+
+
+def unpack(
+ input_file: str,
+ output_directory: str,
+ merge_runs: bool = True,
+ simplify_redlines: bool = True,
+) -> tuple[None, str]:
+ input_path = Path(input_file)
+ output_path = Path(output_directory)
+ suffix = input_path.suffix.lower()
+
+ if not input_path.exists():
+ return None, f"Error: {input_file} does not exist"
+
+ if suffix not in {".docx", ".pptx", ".xlsx"}:
+ return None, f"Error: {input_file} must be a .docx, .pptx, or .xlsx file"
+
+ try:
+ output_path.mkdir(parents=True, exist_ok=True)
+
+ with zipfile.ZipFile(input_path, "r") as zf:
+ zf.extractall(output_path)
+
+ xml_files = list(output_path.rglob("*.xml")) + list(output_path.rglob("*.rels"))
+ for xml_file in xml_files:
+ _pretty_print_xml(xml_file)
+
+ message = f"Unpacked {input_file} ({len(xml_files)} XML files)"
+
+ if suffix == ".docx":
+ if simplify_redlines:
+ simplify_count, _ = do_simplify_redlines(str(output_path))
+ message += f", simplified {simplify_count} tracked changes"
+
+ if merge_runs:
+ merge_count, _ = do_merge_runs(str(output_path))
+ message += f", merged {merge_count} runs"
+
+ for xml_file in xml_files:
+ _escape_smart_quotes(xml_file)
+
+ return None, message
+
+ except zipfile.BadZipFile:
+ return None, f"Error: {input_file} is not a valid Office file"
+ except Exception as e:
+ return None, f"Error unpacking: {e}"
+
+
+def _pretty_print_xml(xml_file: Path) -> None:
+ try:
+ content = xml_file.read_text(encoding="utf-8")
+ dom = defusedxml.minidom.parseString(content)
+ xml_file.write_bytes(dom.toprettyxml(indent=" ", encoding="utf-8"))
+ except Exception:
+ pass
+
+
+def _escape_smart_quotes(xml_file: Path) -> None:
+ try:
+ content = xml_file.read_text(encoding="utf-8")
+ for char, entity in SMART_QUOTE_REPLACEMENTS.items():
+ content = content.replace(char, entity)
+ xml_file.write_text(content, encoding="utf-8")
+ except Exception:
+ pass
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(
+ description="Unpack an Office file (DOCX, PPTX, XLSX) for editing"
+ )
+ parser.add_argument("input_file", help="Office file to unpack")
+ parser.add_argument("output_directory", help="Output directory")
+ parser.add_argument(
+ "--merge-runs",
+ type=lambda x: x.lower() == "true",
+ default=True,
+ metavar="true|false",
+ help="Merge adjacent runs with identical formatting (DOCX only, default: true)",
+ )
+ parser.add_argument(
+ "--simplify-redlines",
+ type=lambda x: x.lower() == "true",
+ default=True,
+ metavar="true|false",
+ help="Merge adjacent tracked changes from same author (DOCX only, default: true)",
+ )
+ args = parser.parse_args()
+
+ _, message = unpack(
+ args.input_file,
+ args.output_directory,
+ merge_runs=args.merge_runs,
+ simplify_redlines=args.simplify_redlines,
+ )
+ print(message)
+
+ if "Error" in message:
+ sys.exit(1)
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/validate.py b/src/crates/core/builtin_skills/docx/scripts/office/validate.py
new file mode 100755
index 00000000..03b01f6e
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/validate.py
@@ -0,0 +1,111 @@
+"""
+Command line tool to validate Office document XML files against XSD schemas and tracked changes.
+
+Usage:
+ python validate.py [--original ] [--auto-repair] [--author NAME]
+
+The first argument can be either:
+- An unpacked directory containing the Office document XML files
+- A packed Office file (.docx/.pptx/.xlsx) which will be unpacked to a temp directory
+
+Auto-repair fixes:
+- paraId/durableId values that exceed OOXML limits
+- Missing xml:space="preserve" on w:t elements with whitespace
+"""
+
+import argparse
+import sys
+import tempfile
+import zipfile
+from pathlib import Path
+
+from validators import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Validate Office document XML files")
+ parser.add_argument(
+ "path",
+ help="Path to unpacked directory or packed Office file (.docx/.pptx/.xlsx)",
+ )
+ parser.add_argument(
+ "--original",
+ required=False,
+ default=None,
+ help="Path to original file (.docx/.pptx/.xlsx). If omitted, all XSD errors are reported and redlining validation is skipped.",
+ )
+ parser.add_argument(
+ "-v",
+ "--verbose",
+ action="store_true",
+ help="Enable verbose output",
+ )
+ parser.add_argument(
+ "--auto-repair",
+ action="store_true",
+ help="Automatically repair common issues (hex IDs, whitespace preservation)",
+ )
+ parser.add_argument(
+ "--author",
+ default="Claude",
+ help="Author name for redlining validation (default: Claude)",
+ )
+ args = parser.parse_args()
+
+ path = Path(args.path)
+ assert path.exists(), f"Error: {path} does not exist"
+
+ original_file = None
+ if args.original:
+ original_file = Path(args.original)
+ assert original_file.is_file(), f"Error: {original_file} is not a file"
+ assert original_file.suffix.lower() in [".docx", ".pptx", ".xlsx"], (
+ f"Error: {original_file} must be a .docx, .pptx, or .xlsx file"
+ )
+
+ file_extension = (original_file or path).suffix.lower()
+ assert file_extension in [".docx", ".pptx", ".xlsx"], (
+ f"Error: Cannot determine file type from {path}. Use --original or provide a .docx/.pptx/.xlsx file."
+ )
+
+ if path.is_file() and path.suffix.lower() in [".docx", ".pptx", ".xlsx"]:
+ temp_dir = tempfile.mkdtemp()
+ with zipfile.ZipFile(path, "r") as zf:
+ zf.extractall(temp_dir)
+ unpacked_dir = Path(temp_dir)
+ else:
+ assert path.is_dir(), f"Error: {path} is not a directory or Office file"
+ unpacked_dir = path
+
+ match file_extension:
+ case ".docx":
+ validators = [
+ DOCXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose),
+ ]
+ if original_file:
+ validators.append(
+ RedliningValidator(unpacked_dir, original_file, verbose=args.verbose, author=args.author)
+ )
+ case ".pptx":
+ validators = [
+ PPTXSchemaValidator(unpacked_dir, original_file, verbose=args.verbose),
+ ]
+ case _:
+ print(f"Error: Validation not supported for file type {file_extension}")
+ sys.exit(1)
+
+ if args.auto_repair:
+ total_repairs = sum(v.repair() for v in validators)
+ if total_repairs:
+ print(f"Auto-repaired {total_repairs} issue(s)")
+
+ success = all(v.validate() for v in validators)
+
+ if success:
+ print("All validations PASSED!")
+
+ sys.exit(0 if success else 1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/validators/__init__.py b/src/crates/core/builtin_skills/docx/scripts/office/validators/__init__.py
new file mode 100644
index 00000000..db092ece
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/validators/__init__.py
@@ -0,0 +1,15 @@
+"""
+Validation modules for Word document processing.
+"""
+
+from .base import BaseSchemaValidator
+from .docx import DOCXSchemaValidator
+from .pptx import PPTXSchemaValidator
+from .redlining import RedliningValidator
+
+__all__ = [
+ "BaseSchemaValidator",
+ "DOCXSchemaValidator",
+ "PPTXSchemaValidator",
+ "RedliningValidator",
+]
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/validators/base.py b/src/crates/core/builtin_skills/docx/scripts/office/validators/base.py
new file mode 100644
index 00000000..db4a06a2
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/validators/base.py
@@ -0,0 +1,847 @@
+"""
+Base validator with common validation logic for document files.
+"""
+
+import re
+from pathlib import Path
+
+import defusedxml.minidom
+import lxml.etree
+
+
+class BaseSchemaValidator:
+
+ IGNORED_VALIDATION_ERRORS = [
+ "hyphenationZone",
+ "purl.org/dc/terms",
+ ]
+
+ UNIQUE_ID_REQUIREMENTS = {
+ "comment": ("id", "file"),
+ "commentrangestart": ("id", "file"),
+ "commentrangeend": ("id", "file"),
+ "bookmarkstart": ("id", "file"),
+ "bookmarkend": ("id", "file"),
+ "sldid": ("id", "file"),
+ "sldmasterid": ("id", "global"),
+ "sldlayoutid": ("id", "global"),
+ "cm": ("authorid", "file"),
+ "sheet": ("sheetid", "file"),
+ "definedname": ("id", "file"),
+ "cxnsp": ("id", "file"),
+ "sp": ("id", "file"),
+ "pic": ("id", "file"),
+ "grpsp": ("id", "file"),
+ }
+
+ EXCLUDED_ID_CONTAINERS = {
+ "sectionlst",
+ }
+
+ ELEMENT_RELATIONSHIP_TYPES = {}
+
+ SCHEMA_MAPPINGS = {
+ "word": "ISO-IEC29500-4_2016/wml.xsd",
+ "ppt": "ISO-IEC29500-4_2016/pml.xsd",
+ "xl": "ISO-IEC29500-4_2016/sml.xsd",
+ "[Content_Types].xml": "ecma/fouth-edition/opc-contentTypes.xsd",
+ "app.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd",
+ "core.xml": "ecma/fouth-edition/opc-coreProperties.xsd",
+ "custom.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd",
+ ".rels": "ecma/fouth-edition/opc-relationships.xsd",
+ "people.xml": "microsoft/wml-2012.xsd",
+ "commentsIds.xml": "microsoft/wml-cid-2016.xsd",
+ "commentsExtensible.xml": "microsoft/wml-cex-2018.xsd",
+ "commentsExtended.xml": "microsoft/wml-2012.xsd",
+ "chart": "ISO-IEC29500-4_2016/dml-chart.xsd",
+ "theme": "ISO-IEC29500-4_2016/dml-main.xsd",
+ "drawing": "ISO-IEC29500-4_2016/dml-main.xsd",
+ }
+
+ MC_NAMESPACE = "http://schemas.openxmlformats.org/markup-compatibility/2006"
+ XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace"
+
+ PACKAGE_RELATIONSHIPS_NAMESPACE = (
+ "http://schemas.openxmlformats.org/package/2006/relationships"
+ )
+ OFFICE_RELATIONSHIPS_NAMESPACE = (
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
+ )
+ CONTENT_TYPES_NAMESPACE = (
+ "http://schemas.openxmlformats.org/package/2006/content-types"
+ )
+
+ MAIN_CONTENT_FOLDERS = {"word", "ppt", "xl"}
+
+ OOXML_NAMESPACES = {
+ "http://schemas.openxmlformats.org/officeDocument/2006/math",
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
+ "http://schemas.openxmlformats.org/schemaLibrary/2006/main",
+ "http://schemas.openxmlformats.org/drawingml/2006/main",
+ "http://schemas.openxmlformats.org/drawingml/2006/chart",
+ "http://schemas.openxmlformats.org/drawingml/2006/chartDrawing",
+ "http://schemas.openxmlformats.org/drawingml/2006/diagram",
+ "http://schemas.openxmlformats.org/drawingml/2006/picture",
+ "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing",
+ "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
+ "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
+ "http://schemas.openxmlformats.org/presentationml/2006/main",
+ "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
+ "http://schemas.openxmlformats.org/officeDocument/2006/sharedTypes",
+ "http://www.w3.org/XML/1998/namespace",
+ }
+
+ def __init__(self, unpacked_dir, original_file=None, verbose=False):
+ self.unpacked_dir = Path(unpacked_dir).resolve()
+ self.original_file = Path(original_file) if original_file else None
+ self.verbose = verbose
+
+ self.schemas_dir = Path(__file__).parent.parent / "schemas"
+
+ patterns = ["*.xml", "*.rels"]
+ self.xml_files = [
+ f for pattern in patterns for f in self.unpacked_dir.rglob(pattern)
+ ]
+
+ if not self.xml_files:
+ print(f"Warning: No XML files found in {self.unpacked_dir}")
+
+ def validate(self):
+ raise NotImplementedError("Subclasses must implement the validate method")
+
+ def repair(self) -> int:
+ return self.repair_whitespace_preservation()
+
+ def repair_whitespace_preservation(self) -> int:
+ repairs = 0
+
+ for xml_file in self.xml_files:
+ try:
+ content = xml_file.read_text(encoding="utf-8")
+ dom = defusedxml.minidom.parseString(content)
+ modified = False
+
+ for elem in dom.getElementsByTagName("*"):
+ if elem.tagName.endswith(":t") and elem.firstChild:
+ text = elem.firstChild.nodeValue
+ if text and (text.startswith((' ', '\t')) or text.endswith((' ', '\t'))):
+ if elem.getAttribute("xml:space") != "preserve":
+ elem.setAttribute("xml:space", "preserve")
+ text_preview = repr(text[:30]) + "..." if len(text) > 30 else repr(text)
+ print(f" Repaired: {xml_file.name}: Added xml:space='preserve' to {elem.tagName}: {text_preview}")
+ repairs += 1
+ modified = True
+
+ if modified:
+ xml_file.write_bytes(dom.toxml(encoding="UTF-8"))
+
+ except Exception:
+ pass
+
+ return repairs
+
+ def validate_xml(self):
+ errors = []
+
+ for xml_file in self.xml_files:
+ try:
+ lxml.etree.parse(str(xml_file))
+ except lxml.etree.XMLSyntaxError as e:
+ errors.append(
+ f" {xml_file.relative_to(self.unpacked_dir)}: "
+ f"Line {e.lineno}: {e.msg}"
+ )
+ except Exception as e:
+ errors.append(
+ f" {xml_file.relative_to(self.unpacked_dir)}: "
+ f"Unexpected error: {str(e)}"
+ )
+
+ if errors:
+ print(f"FAILED - Found {len(errors)} XML violations:")
+ for error in errors:
+ print(error)
+ return False
+ else:
+ if self.verbose:
+ print("PASSED - All XML files are well-formed")
+ return True
+
+ def validate_namespaces(self):
+ errors = []
+
+ for xml_file in self.xml_files:
+ try:
+ root = lxml.etree.parse(str(xml_file)).getroot()
+ declared = set(root.nsmap.keys()) - {None}
+
+ for attr_val in [
+ v for k, v in root.attrib.items() if k.endswith("Ignorable")
+ ]:
+ undeclared = set(attr_val.split()) - declared
+ errors.extend(
+ f" {xml_file.relative_to(self.unpacked_dir)}: "
+ f"Namespace '{ns}' in Ignorable but not declared"
+ for ns in undeclared
+ )
+ except lxml.etree.XMLSyntaxError:
+ continue
+
+ if errors:
+ print(f"FAILED - {len(errors)} namespace issues:")
+ for error in errors:
+ print(error)
+ return False
+ if self.verbose:
+ print("PASSED - All namespace prefixes properly declared")
+ return True
+
+ def validate_unique_ids(self):
+ errors = []
+ global_ids = {}
+
+ for xml_file in self.xml_files:
+ try:
+ root = lxml.etree.parse(str(xml_file)).getroot()
+ file_ids = {}
+
+ mc_elements = root.xpath(
+ ".//mc:AlternateContent", namespaces={"mc": self.MC_NAMESPACE}
+ )
+ for elem in mc_elements:
+ elem.getparent().remove(elem)
+
+ for elem in root.iter():
+ tag = (
+ elem.tag.split("}")[-1].lower()
+ if "}" in elem.tag
+ else elem.tag.lower()
+ )
+
+ if tag in self.UNIQUE_ID_REQUIREMENTS:
+ in_excluded_container = any(
+ ancestor.tag.split("}")[-1].lower() in self.EXCLUDED_ID_CONTAINERS
+ for ancestor in elem.iterancestors()
+ )
+ if in_excluded_container:
+ continue
+
+ attr_name, scope = self.UNIQUE_ID_REQUIREMENTS[tag]
+
+ id_value = None
+ for attr, value in elem.attrib.items():
+ attr_local = (
+ attr.split("}")[-1].lower()
+ if "}" in attr
+ else attr.lower()
+ )
+ if attr_local == attr_name:
+ id_value = value
+ break
+
+ if id_value is not None:
+ if scope == "global":
+ if id_value in global_ids:
+ prev_file, prev_line, prev_tag = global_ids[
+ id_value
+ ]
+ errors.append(
+ f" {xml_file.relative_to(self.unpacked_dir)}: "
+ f"Line {elem.sourceline}: Global ID '{id_value}' in <{tag}> "
+ f"already used in {prev_file} at line {prev_line} in <{prev_tag}>"
+ )
+ else:
+ global_ids[id_value] = (
+ xml_file.relative_to(self.unpacked_dir),
+ elem.sourceline,
+ tag,
+ )
+ elif scope == "file":
+ key = (tag, attr_name)
+ if key not in file_ids:
+ file_ids[key] = {}
+
+ if id_value in file_ids[key]:
+ prev_line = file_ids[key][id_value]
+ errors.append(
+ f" {xml_file.relative_to(self.unpacked_dir)}: "
+ f"Line {elem.sourceline}: Duplicate {attr_name}='{id_value}' in <{tag}> "
+ f"(first occurrence at line {prev_line})"
+ )
+ else:
+ file_ids[key][id_value] = elem.sourceline
+
+ except (lxml.etree.XMLSyntaxError, Exception) as e:
+ errors.append(
+ f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}"
+ )
+
+ if errors:
+ print(f"FAILED - Found {len(errors)} ID uniqueness violations:")
+ for error in errors:
+ print(error)
+ return False
+ else:
+ if self.verbose:
+ print("PASSED - All required IDs are unique")
+ return True
+
+ def validate_file_references(self):
+ errors = []
+
+ rels_files = list(self.unpacked_dir.rglob("*.rels"))
+
+ if not rels_files:
+ if self.verbose:
+ print("PASSED - No .rels files found")
+ return True
+
+ all_files = []
+ for file_path in self.unpacked_dir.rglob("*"):
+ if (
+ file_path.is_file()
+ and file_path.name != "[Content_Types].xml"
+ and not file_path.name.endswith(".rels")
+ ):
+ all_files.append(file_path.resolve())
+
+ all_referenced_files = set()
+
+ if self.verbose:
+ print(
+ f"Found {len(rels_files)} .rels files and {len(all_files)} target files"
+ )
+
+ for rels_file in rels_files:
+ try:
+ rels_root = lxml.etree.parse(str(rels_file)).getroot()
+
+ rels_dir = rels_file.parent
+
+ referenced_files = set()
+ broken_refs = []
+
+ for rel in rels_root.findall(
+ ".//ns:Relationship",
+ namespaces={"ns": self.PACKAGE_RELATIONSHIPS_NAMESPACE},
+ ):
+ target = rel.get("Target")
+ if target and not target.startswith(
+ ("http", "mailto:")
+ ):
+ if target.startswith("/"):
+ target_path = self.unpacked_dir / target.lstrip("/")
+ elif rels_file.name == ".rels":
+ target_path = self.unpacked_dir / target
+ else:
+ base_dir = rels_dir.parent
+ target_path = base_dir / target
+
+ try:
+ target_path = target_path.resolve()
+ if target_path.exists() and target_path.is_file():
+ referenced_files.add(target_path)
+ all_referenced_files.add(target_path)
+ else:
+ broken_refs.append((target, rel.sourceline))
+ except (OSError, ValueError):
+ broken_refs.append((target, rel.sourceline))
+
+ if broken_refs:
+ rel_path = rels_file.relative_to(self.unpacked_dir)
+ for broken_ref, line_num in broken_refs:
+ errors.append(
+ f" {rel_path}: Line {line_num}: Broken reference to {broken_ref}"
+ )
+
+ except Exception as e:
+ rel_path = rels_file.relative_to(self.unpacked_dir)
+ errors.append(f" Error parsing {rel_path}: {e}")
+
+ unreferenced_files = set(all_files) - all_referenced_files
+
+ if unreferenced_files:
+ for unref_file in sorted(unreferenced_files):
+ unref_rel_path = unref_file.relative_to(self.unpacked_dir)
+ errors.append(f" Unreferenced file: {unref_rel_path}")
+
+ if errors:
+ print(f"FAILED - Found {len(errors)} relationship validation errors:")
+ for error in errors:
+ print(error)
+ print(
+ "CRITICAL: These errors will cause the document to appear corrupt. "
+ + "Broken references MUST be fixed, "
+ + "and unreferenced files MUST be referenced or removed."
+ )
+ return False
+ else:
+ if self.verbose:
+ print(
+ "PASSED - All references are valid and all files are properly referenced"
+ )
+ return True
+
+ def validate_all_relationship_ids(self):
+ import lxml.etree
+
+ errors = []
+
+ for xml_file in self.xml_files:
+ if xml_file.suffix == ".rels":
+ continue
+
+ rels_dir = xml_file.parent / "_rels"
+ rels_file = rels_dir / f"{xml_file.name}.rels"
+
+ if not rels_file.exists():
+ continue
+
+ try:
+ rels_root = lxml.etree.parse(str(rels_file)).getroot()
+ rid_to_type = {}
+
+ for rel in rels_root.findall(
+ f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship"
+ ):
+ rid = rel.get("Id")
+ rel_type = rel.get("Type", "")
+ if rid:
+ if rid in rid_to_type:
+ rels_rel_path = rels_file.relative_to(self.unpacked_dir)
+ errors.append(
+ f" {rels_rel_path}: Line {rel.sourceline}: "
+ f"Duplicate relationship ID '{rid}' (IDs must be unique)"
+ )
+ type_name = (
+ rel_type.split("/")[-1] if "/" in rel_type else rel_type
+ )
+ rid_to_type[rid] = type_name
+
+ xml_root = lxml.etree.parse(str(xml_file)).getroot()
+
+ r_ns = self.OFFICE_RELATIONSHIPS_NAMESPACE
+ rid_attrs_to_check = ["id", "embed", "link"]
+ for elem in xml_root.iter():
+ for attr_name in rid_attrs_to_check:
+ rid_attr = elem.get(f"{{{r_ns}}}{attr_name}")
+ if not rid_attr:
+ continue
+ xml_rel_path = xml_file.relative_to(self.unpacked_dir)
+ elem_name = (
+ elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag
+ )
+
+ if rid_attr not in rid_to_type:
+ errors.append(
+ f" {xml_rel_path}: Line {elem.sourceline}: "
+ f"<{elem_name}> r:{attr_name} references non-existent relationship '{rid_attr}' "
+ f"(valid IDs: {', '.join(sorted(rid_to_type.keys())[:5])}{'...' if len(rid_to_type) > 5 else ''})"
+ )
+ elif attr_name == "id" and self.ELEMENT_RELATIONSHIP_TYPES:
+ expected_type = self._get_expected_relationship_type(
+ elem_name
+ )
+ if expected_type:
+ actual_type = rid_to_type[rid_attr]
+ if expected_type not in actual_type.lower():
+ errors.append(
+ f" {xml_rel_path}: Line {elem.sourceline}: "
+ f"<{elem_name}> references '{rid_attr}' which points to '{actual_type}' "
+ f"but should point to a '{expected_type}' relationship"
+ )
+
+ except Exception as e:
+ xml_rel_path = xml_file.relative_to(self.unpacked_dir)
+ errors.append(f" Error processing {xml_rel_path}: {e}")
+
+ if errors:
+ print(f"FAILED - Found {len(errors)} relationship ID reference errors:")
+ for error in errors:
+ print(error)
+ print("\nThese ID mismatches will cause the document to appear corrupt!")
+ return False
+ else:
+ if self.verbose:
+ print("PASSED - All relationship ID references are valid")
+ return True
+
+ def _get_expected_relationship_type(self, element_name):
+ elem_lower = element_name.lower()
+
+ if elem_lower in self.ELEMENT_RELATIONSHIP_TYPES:
+ return self.ELEMENT_RELATIONSHIP_TYPES[elem_lower]
+
+ if elem_lower.endswith("id") and len(elem_lower) > 2:
+ prefix = elem_lower[:-2]
+ if prefix.endswith("master"):
+ return prefix.lower()
+ elif prefix.endswith("layout"):
+ return prefix.lower()
+ else:
+ if prefix == "sld":
+ return "slide"
+ return prefix.lower()
+
+ if elem_lower.endswith("reference") and len(elem_lower) > 9:
+ prefix = elem_lower[:-9]
+ return prefix.lower()
+
+ return None
+
+ def validate_content_types(self):
+ errors = []
+
+ content_types_file = self.unpacked_dir / "[Content_Types].xml"
+ if not content_types_file.exists():
+ print("FAILED - [Content_Types].xml file not found")
+ return False
+
+ try:
+ root = lxml.etree.parse(str(content_types_file)).getroot()
+ declared_parts = set()
+ declared_extensions = set()
+
+ for override in root.findall(
+ f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Override"
+ ):
+ part_name = override.get("PartName")
+ if part_name is not None:
+ declared_parts.add(part_name.lstrip("/"))
+
+ for default in root.findall(
+ f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Default"
+ ):
+ extension = default.get("Extension")
+ if extension is not None:
+ declared_extensions.add(extension.lower())
+
+ declarable_roots = {
+ "sld",
+ "sldLayout",
+ "sldMaster",
+ "presentation",
+ "document",
+ "workbook",
+ "worksheet",
+ "theme",
+ }
+
+ media_extensions = {
+ "png": "image/png",
+ "jpg": "image/jpeg",
+ "jpeg": "image/jpeg",
+ "gif": "image/gif",
+ "bmp": "image/bmp",
+ "tiff": "image/tiff",
+ "wmf": "image/x-wmf",
+ "emf": "image/x-emf",
+ }
+
+ all_files = list(self.unpacked_dir.rglob("*"))
+ all_files = [f for f in all_files if f.is_file()]
+
+ for xml_file in self.xml_files:
+ path_str = str(xml_file.relative_to(self.unpacked_dir)).replace(
+ "\\", "/"
+ )
+
+ if any(
+ skip in path_str
+ for skip in [".rels", "[Content_Types]", "docProps/", "_rels/"]
+ ):
+ continue
+
+ try:
+ root_tag = lxml.etree.parse(str(xml_file)).getroot().tag
+ root_name = root_tag.split("}")[-1] if "}" in root_tag else root_tag
+
+ if root_name in declarable_roots and path_str not in declared_parts:
+ errors.append(
+ f" {path_str}: File with <{root_name}> root not declared in [Content_Types].xml"
+ )
+
+ except Exception:
+ continue
+
+ for file_path in all_files:
+ if file_path.suffix.lower() in {".xml", ".rels"}:
+ continue
+ if file_path.name == "[Content_Types].xml":
+ continue
+ if "_rels" in file_path.parts or "docProps" in file_path.parts:
+ continue
+
+ extension = file_path.suffix.lstrip(".").lower()
+ if extension and extension not in declared_extensions:
+ if extension in media_extensions:
+ relative_path = file_path.relative_to(self.unpacked_dir)
+ errors.append(
+ f' {relative_path}: File with extension \'{extension}\' not declared in [Content_Types].xml - should add: '
+ )
+
+ except Exception as e:
+ errors.append(f" Error parsing [Content_Types].xml: {e}")
+
+ if errors:
+ print(f"FAILED - Found {len(errors)} content type declaration errors:")
+ for error in errors:
+ print(error)
+ return False
+ else:
+ if self.verbose:
+ print(
+ "PASSED - All content files are properly declared in [Content_Types].xml"
+ )
+ return True
+
+ def validate_file_against_xsd(self, xml_file, verbose=False):
+ xml_file = Path(xml_file).resolve()
+ unpacked_dir = self.unpacked_dir.resolve()
+
+ is_valid, current_errors = self._validate_single_file_xsd(
+ xml_file, unpacked_dir
+ )
+
+ if is_valid is None:
+ return None, set()
+ elif is_valid:
+ return True, set()
+
+ original_errors = self._get_original_file_errors(xml_file)
+
+ assert current_errors is not None
+ new_errors = current_errors - original_errors
+
+ new_errors = {
+ e for e in new_errors
+ if not any(pattern in e for pattern in self.IGNORED_VALIDATION_ERRORS)
+ }
+
+ if new_errors:
+ if verbose:
+ relative_path = xml_file.relative_to(unpacked_dir)
+ print(f"FAILED - {relative_path}: {len(new_errors)} new error(s)")
+ for error in list(new_errors)[:3]:
+ truncated = error[:250] + "..." if len(error) > 250 else error
+ print(f" - {truncated}")
+ return False, new_errors
+ else:
+ if verbose:
+ print(
+ f"PASSED - No new errors (original had {len(current_errors)} errors)"
+ )
+ return True, set()
+
+ def validate_against_xsd(self):
+ new_errors = []
+ original_error_count = 0
+ valid_count = 0
+ skipped_count = 0
+
+ for xml_file in self.xml_files:
+ relative_path = str(xml_file.relative_to(self.unpacked_dir))
+ is_valid, new_file_errors = self.validate_file_against_xsd(
+ xml_file, verbose=False
+ )
+
+ if is_valid is None:
+ skipped_count += 1
+ continue
+ elif is_valid and not new_file_errors:
+ valid_count += 1
+ continue
+ elif is_valid:
+ original_error_count += 1
+ valid_count += 1
+ continue
+
+ new_errors.append(f" {relative_path}: {len(new_file_errors)} new error(s)")
+ for error in list(new_file_errors)[:3]:
+ new_errors.append(
+ f" - {error[:250]}..." if len(error) > 250 else f" - {error}"
+ )
+
+ if self.verbose:
+ print(f"Validated {len(self.xml_files)} files:")
+ print(f" - Valid: {valid_count}")
+ print(f" - Skipped (no schema): {skipped_count}")
+ if original_error_count:
+ print(f" - With original errors (ignored): {original_error_count}")
+ print(
+ f" - With NEW errors: {len(new_errors) > 0 and len([e for e in new_errors if not e.startswith(' ')]) or 0}"
+ )
+
+ if new_errors:
+ print("\nFAILED - Found NEW validation errors:")
+ for error in new_errors:
+ print(error)
+ return False
+ else:
+ if self.verbose:
+ print("\nPASSED - No new XSD validation errors introduced")
+ return True
+
+ def _get_schema_path(self, xml_file):
+ if xml_file.name in self.SCHEMA_MAPPINGS:
+ return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.name]
+
+ if xml_file.suffix == ".rels":
+ return self.schemas_dir / self.SCHEMA_MAPPINGS[".rels"]
+
+ if "charts/" in str(xml_file) and xml_file.name.startswith("chart"):
+ return self.schemas_dir / self.SCHEMA_MAPPINGS["chart"]
+
+ if "theme/" in str(xml_file) and xml_file.name.startswith("theme"):
+ return self.schemas_dir / self.SCHEMA_MAPPINGS["theme"]
+
+ if xml_file.parent.name in self.MAIN_CONTENT_FOLDERS:
+ return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.parent.name]
+
+ return None
+
+ def _clean_ignorable_namespaces(self, xml_doc):
+ xml_string = lxml.etree.tostring(xml_doc, encoding="unicode")
+ xml_copy = lxml.etree.fromstring(xml_string)
+
+ for elem in xml_copy.iter():
+ attrs_to_remove = []
+
+ for attr in elem.attrib:
+ if "{" in attr:
+ ns = attr.split("}")[0][1:]
+ if ns not in self.OOXML_NAMESPACES:
+ attrs_to_remove.append(attr)
+
+ for attr in attrs_to_remove:
+ del elem.attrib[attr]
+
+ self._remove_ignorable_elements(xml_copy)
+
+ return lxml.etree.ElementTree(xml_copy)
+
+ def _remove_ignorable_elements(self, root):
+ elements_to_remove = []
+
+ for elem in list(root):
+ if not hasattr(elem, "tag") or callable(elem.tag):
+ continue
+
+ tag_str = str(elem.tag)
+ if tag_str.startswith("{"):
+ ns = tag_str.split("}")[0][1:]
+ if ns not in self.OOXML_NAMESPACES:
+ elements_to_remove.append(elem)
+ continue
+
+ self._remove_ignorable_elements(elem)
+
+ for elem in elements_to_remove:
+ root.remove(elem)
+
+ def _preprocess_for_mc_ignorable(self, xml_doc):
+ root = xml_doc.getroot()
+
+ if f"{{{self.MC_NAMESPACE}}}Ignorable" in root.attrib:
+ del root.attrib[f"{{{self.MC_NAMESPACE}}}Ignorable"]
+
+ return xml_doc
+
+ def _validate_single_file_xsd(self, xml_file, base_path):
+ schema_path = self._get_schema_path(xml_file)
+ if not schema_path:
+ return None, None
+
+ try:
+ with open(schema_path, "rb") as xsd_file:
+ parser = lxml.etree.XMLParser()
+ xsd_doc = lxml.etree.parse(
+ xsd_file, parser=parser, base_url=str(schema_path)
+ )
+ schema = lxml.etree.XMLSchema(xsd_doc)
+
+ with open(xml_file, "r") as f:
+ xml_doc = lxml.etree.parse(f)
+
+ xml_doc, _ = self._remove_template_tags_from_text_nodes(xml_doc)
+ xml_doc = self._preprocess_for_mc_ignorable(xml_doc)
+
+ relative_path = xml_file.relative_to(base_path)
+ if (
+ relative_path.parts
+ and relative_path.parts[0] in self.MAIN_CONTENT_FOLDERS
+ ):
+ xml_doc = self._clean_ignorable_namespaces(xml_doc)
+
+ if schema.validate(xml_doc):
+ return True, set()
+ else:
+ errors = set()
+ for error in schema.error_log:
+ errors.add(error.message)
+ return False, errors
+
+ except Exception as e:
+ return False, {str(e)}
+
+ def _get_original_file_errors(self, xml_file):
+ if self.original_file is None:
+ return set()
+
+ import tempfile
+ import zipfile
+
+ xml_file = Path(xml_file).resolve()
+ unpacked_dir = self.unpacked_dir.resolve()
+ relative_path = xml_file.relative_to(unpacked_dir)
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ with zipfile.ZipFile(self.original_file, "r") as zip_ref:
+ zip_ref.extractall(temp_path)
+
+ original_xml_file = temp_path / relative_path
+
+ if not original_xml_file.exists():
+ return set()
+
+ is_valid, errors = self._validate_single_file_xsd(
+ original_xml_file, temp_path
+ )
+ return errors if errors else set()
+
+ def _remove_template_tags_from_text_nodes(self, xml_doc):
+ warnings = []
+ template_pattern = re.compile(r"\{\{[^}]*\}\}")
+
+ xml_string = lxml.etree.tostring(xml_doc, encoding="unicode")
+ xml_copy = lxml.etree.fromstring(xml_string)
+
+ def process_text_content(text, content_type):
+ if not text:
+ return text
+ matches = list(template_pattern.finditer(text))
+ if matches:
+ for match in matches:
+ warnings.append(
+ f"Found template tag in {content_type}: {match.group()}"
+ )
+ return template_pattern.sub("", text)
+ return text
+
+ for elem in xml_copy.iter():
+ if not hasattr(elem, "tag") or callable(elem.tag):
+ continue
+ tag_str = str(elem.tag)
+ if tag_str.endswith("}t") or tag_str == "t":
+ continue
+
+ elem.text = process_text_content(elem.text, "text content")
+ elem.tail = process_text_content(elem.tail, "tail content")
+
+ return lxml.etree.ElementTree(xml_copy), warnings
+
+
+if __name__ == "__main__":
+ raise RuntimeError("This module should not be run directly.")
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/validators/docx.py b/src/crates/core/builtin_skills/docx/scripts/office/validators/docx.py
new file mode 100644
index 00000000..fec405e6
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/validators/docx.py
@@ -0,0 +1,446 @@
+"""
+Validator for Word document XML files against XSD schemas.
+"""
+
+import random
+import re
+import tempfile
+import zipfile
+
+import defusedxml.minidom
+import lxml.etree
+
+from .base import BaseSchemaValidator
+
+
+class DOCXSchemaValidator(BaseSchemaValidator):
+
+ WORD_2006_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
+ W14_NAMESPACE = "http://schemas.microsoft.com/office/word/2010/wordml"
+ W16CID_NAMESPACE = "http://schemas.microsoft.com/office/word/2016/wordml/cid"
+
+ ELEMENT_RELATIONSHIP_TYPES = {}
+
+ def validate(self):
+ if not self.validate_xml():
+ return False
+
+ all_valid = True
+ if not self.validate_namespaces():
+ all_valid = False
+
+ if not self.validate_unique_ids():
+ all_valid = False
+
+ if not self.validate_file_references():
+ all_valid = False
+
+ if not self.validate_content_types():
+ all_valid = False
+
+ if not self.validate_against_xsd():
+ all_valid = False
+
+ if not self.validate_whitespace_preservation():
+ all_valid = False
+
+ if not self.validate_deletions():
+ all_valid = False
+
+ if not self.validate_insertions():
+ all_valid = False
+
+ if not self.validate_all_relationship_ids():
+ all_valid = False
+
+ if not self.validate_id_constraints():
+ all_valid = False
+
+ if not self.validate_comment_markers():
+ all_valid = False
+
+ self.compare_paragraph_counts()
+
+ return all_valid
+
+ def validate_whitespace_preservation(self):
+ errors = []
+
+ for xml_file in self.xml_files:
+ if xml_file.name != "document.xml":
+ continue
+
+ try:
+ root = lxml.etree.parse(str(xml_file)).getroot()
+
+ for elem in root.iter(f"{{{self.WORD_2006_NAMESPACE}}}t"):
+ if elem.text:
+ text = elem.text
+ if re.search(r"^[ \t\n\r]", text) or re.search(
+ r"[ \t\n\r]$", text
+ ):
+ xml_space_attr = f"{{{self.XML_NAMESPACE}}}space"
+ if (
+ xml_space_attr not in elem.attrib
+ or elem.attrib[xml_space_attr] != "preserve"
+ ):
+ text_preview = (
+ repr(text)[:50] + "..."
+ if len(repr(text)) > 50
+ else repr(text)
+ )
+ errors.append(
+ f" {xml_file.relative_to(self.unpacked_dir)}: "
+ f"Line {elem.sourceline}: w:t element with whitespace missing xml:space='preserve': {text_preview}"
+ )
+
+ except (lxml.etree.XMLSyntaxError, Exception) as e:
+ errors.append(
+ f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}"
+ )
+
+ if errors:
+ print(f"FAILED - Found {len(errors)} whitespace preservation violations:")
+ for error in errors:
+ print(error)
+ return False
+ else:
+ if self.verbose:
+ print("PASSED - All whitespace is properly preserved")
+ return True
+
+ def validate_deletions(self):
+ errors = []
+
+ for xml_file in self.xml_files:
+ if xml_file.name != "document.xml":
+ continue
+
+ try:
+ root = lxml.etree.parse(str(xml_file)).getroot()
+ namespaces = {"w": self.WORD_2006_NAMESPACE}
+
+ for t_elem in root.xpath(".//w:del//w:t", namespaces=namespaces):
+ if t_elem.text:
+ text_preview = (
+ repr(t_elem.text)[:50] + "..."
+ if len(repr(t_elem.text)) > 50
+ else repr(t_elem.text)
+ )
+ errors.append(
+ f" {xml_file.relative_to(self.unpacked_dir)}: "
+ f"Line {t_elem.sourceline}: found within : {text_preview}"
+ )
+
+ for instr_elem in root.xpath(
+ ".//w:del//w:instrText", namespaces=namespaces
+ ):
+ text_preview = (
+ repr(instr_elem.text or "")[:50] + "..."
+ if len(repr(instr_elem.text or "")) > 50
+ else repr(instr_elem.text or "")
+ )
+ errors.append(
+ f" {xml_file.relative_to(self.unpacked_dir)}: "
+ f"Line {instr_elem.sourceline}: found within (use ): {text_preview}"
+ )
+
+ except (lxml.etree.XMLSyntaxError, Exception) as e:
+ errors.append(
+ f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}"
+ )
+
+ if errors:
+ print(f"FAILED - Found {len(errors)} deletion validation violations:")
+ for error in errors:
+ print(error)
+ return False
+ else:
+ if self.verbose:
+ print("PASSED - No w:t elements found within w:del elements")
+ return True
+
+ def count_paragraphs_in_unpacked(self):
+ count = 0
+
+ for xml_file in self.xml_files:
+ if xml_file.name != "document.xml":
+ continue
+
+ try:
+ root = lxml.etree.parse(str(xml_file)).getroot()
+ paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p")
+ count = len(paragraphs)
+ except Exception as e:
+ print(f"Error counting paragraphs in unpacked document: {e}")
+
+ return count
+
+ def count_paragraphs_in_original(self):
+ original = self.original_file
+ if original is None:
+ return 0
+
+ count = 0
+
+ try:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ with zipfile.ZipFile(original, "r") as zip_ref:
+ zip_ref.extractall(temp_dir)
+
+ doc_xml_path = temp_dir + "/word/document.xml"
+ root = lxml.etree.parse(doc_xml_path).getroot()
+
+ paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p")
+ count = len(paragraphs)
+
+ except Exception as e:
+ print(f"Error counting paragraphs in original document: {e}")
+
+ return count
+
+ def validate_insertions(self):
+ errors = []
+
+ for xml_file in self.xml_files:
+ if xml_file.name != "document.xml":
+ continue
+
+ try:
+ root = lxml.etree.parse(str(xml_file)).getroot()
+ namespaces = {"w": self.WORD_2006_NAMESPACE}
+
+ invalid_elements = root.xpath(
+ ".//w:ins//w:delText[not(ancestor::w:del)]", namespaces=namespaces
+ )
+
+ for elem in invalid_elements:
+ text_preview = (
+ repr(elem.text or "")[:50] + "..."
+ if len(repr(elem.text or "")) > 50
+ else repr(elem.text or "")
+ )
+ errors.append(
+ f" {xml_file.relative_to(self.unpacked_dir)}: "
+ f"Line {elem.sourceline}: within : {text_preview}"
+ )
+
+ except (lxml.etree.XMLSyntaxError, Exception) as e:
+ errors.append(
+ f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}"
+ )
+
+ if errors:
+ print(f"FAILED - Found {len(errors)} insertion validation violations:")
+ for error in errors:
+ print(error)
+ return False
+ else:
+ if self.verbose:
+ print("PASSED - No w:delText elements within w:ins elements")
+ return True
+
+ def compare_paragraph_counts(self):
+ original_count = self.count_paragraphs_in_original()
+ new_count = self.count_paragraphs_in_unpacked()
+
+ diff = new_count - original_count
+ diff_str = f"+{diff}" if diff > 0 else str(diff)
+ print(f"\nParagraphs: {original_count} → {new_count} ({diff_str})")
+
+ def _parse_id_value(self, val: str, base: int = 16) -> int:
+ return int(val, base)
+
+ def validate_id_constraints(self):
+ errors = []
+ para_id_attr = f"{{{self.W14_NAMESPACE}}}paraId"
+ durable_id_attr = f"{{{self.W16CID_NAMESPACE}}}durableId"
+
+ for xml_file in self.xml_files:
+ try:
+ for elem in lxml.etree.parse(str(xml_file)).iter():
+ if val := elem.get(para_id_attr):
+ if self._parse_id_value(val, base=16) >= 0x80000000:
+ errors.append(
+ f" {xml_file.name}:{elem.sourceline}: paraId={val} >= 0x80000000"
+ )
+
+ if val := elem.get(durable_id_attr):
+ if xml_file.name == "numbering.xml":
+ try:
+ if self._parse_id_value(val, base=10) >= 0x7FFFFFFF:
+ errors.append(
+ f" {xml_file.name}:{elem.sourceline}: "
+ f"durableId={val} >= 0x7FFFFFFF"
+ )
+ except ValueError:
+ errors.append(
+ f" {xml_file.name}:{elem.sourceline}: "
+ f"durableId={val} must be decimal in numbering.xml"
+ )
+ else:
+ if self._parse_id_value(val, base=16) >= 0x7FFFFFFF:
+ errors.append(
+ f" {xml_file.name}:{elem.sourceline}: "
+ f"durableId={val} >= 0x7FFFFFFF"
+ )
+ except Exception:
+ pass
+
+ if errors:
+ print(f"FAILED - {len(errors)} ID constraint violations:")
+ for e in errors:
+ print(e)
+ elif self.verbose:
+ print("PASSED - All paraId/durableId values within constraints")
+ return not errors
+
+ def validate_comment_markers(self):
+ errors = []
+
+ document_xml = None
+ comments_xml = None
+ for xml_file in self.xml_files:
+ if xml_file.name == "document.xml" and "word" in str(xml_file):
+ document_xml = xml_file
+ elif xml_file.name == "comments.xml":
+ comments_xml = xml_file
+
+ if not document_xml:
+ if self.verbose:
+ print("PASSED - No document.xml found (skipping comment validation)")
+ return True
+
+ try:
+ doc_root = lxml.etree.parse(str(document_xml)).getroot()
+ namespaces = {"w": self.WORD_2006_NAMESPACE}
+
+ range_starts = {
+ elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id")
+ for elem in doc_root.xpath(
+ ".//w:commentRangeStart", namespaces=namespaces
+ )
+ }
+ range_ends = {
+ elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id")
+ for elem in doc_root.xpath(
+ ".//w:commentRangeEnd", namespaces=namespaces
+ )
+ }
+ references = {
+ elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id")
+ for elem in doc_root.xpath(
+ ".//w:commentReference", namespaces=namespaces
+ )
+ }
+
+ orphaned_ends = range_ends - range_starts
+ for comment_id in sorted(
+ orphaned_ends, key=lambda x: int(x) if x and x.isdigit() else 0
+ ):
+ errors.append(
+ f' document.xml: commentRangeEnd id="{comment_id}" has no matching commentRangeStart'
+ )
+
+ orphaned_starts = range_starts - range_ends
+ for comment_id in sorted(
+ orphaned_starts, key=lambda x: int(x) if x and x.isdigit() else 0
+ ):
+ errors.append(
+ f' document.xml: commentRangeStart id="{comment_id}" has no matching commentRangeEnd'
+ )
+
+ comment_ids = set()
+ if comments_xml and comments_xml.exists():
+ comments_root = lxml.etree.parse(str(comments_xml)).getroot()
+ comment_ids = {
+ elem.get(f"{{{self.WORD_2006_NAMESPACE}}}id")
+ for elem in comments_root.xpath(
+ ".//w:comment", namespaces=namespaces
+ )
+ }
+
+ marker_ids = range_starts | range_ends | references
+ invalid_refs = marker_ids - comment_ids
+ for comment_id in sorted(
+ invalid_refs, key=lambda x: int(x) if x and x.isdigit() else 0
+ ):
+ if comment_id:
+ errors.append(
+ f' document.xml: marker id="{comment_id}" references non-existent comment'
+ )
+
+ except (lxml.etree.XMLSyntaxError, Exception) as e:
+ errors.append(f" Error parsing XML: {e}")
+
+ if errors:
+ print(f"FAILED - {len(errors)} comment marker violations:")
+ for error in errors:
+ print(error)
+ return False
+ else:
+ if self.verbose:
+ print("PASSED - All comment markers properly paired")
+ return True
+
+ def repair(self) -> int:
+ repairs = super().repair()
+ repairs += self.repair_durableId()
+ return repairs
+
+ def repair_durableId(self) -> int:
+ repairs = 0
+
+ for xml_file in self.xml_files:
+ try:
+ content = xml_file.read_text(encoding="utf-8")
+ dom = defusedxml.minidom.parseString(content)
+ modified = False
+
+ for elem in dom.getElementsByTagName("*"):
+ if not elem.hasAttribute("w16cid:durableId"):
+ continue
+
+ durable_id = elem.getAttribute("w16cid:durableId")
+ needs_repair = False
+
+ if xml_file.name == "numbering.xml":
+ try:
+ needs_repair = (
+ self._parse_id_value(durable_id, base=10) >= 0x7FFFFFFF
+ )
+ except ValueError:
+ needs_repair = True
+ else:
+ try:
+ needs_repair = (
+ self._parse_id_value(durable_id, base=16) >= 0x7FFFFFFF
+ )
+ except ValueError:
+ needs_repair = True
+
+ if needs_repair:
+ value = random.randint(1, 0x7FFFFFFE)
+ if xml_file.name == "numbering.xml":
+ new_id = str(value)
+ else:
+ new_id = f"{value:08X}"
+
+ elem.setAttribute("w16cid:durableId", new_id)
+ print(
+ f" Repaired: {xml_file.name}: durableId {durable_id} → {new_id}"
+ )
+ repairs += 1
+ modified = True
+
+ if modified:
+ xml_file.write_bytes(dom.toxml(encoding="UTF-8"))
+
+ except Exception:
+ pass
+
+ return repairs
+
+
+if __name__ == "__main__":
+ raise RuntimeError("This module should not be run directly.")
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/validators/pptx.py b/src/crates/core/builtin_skills/docx/scripts/office/validators/pptx.py
new file mode 100644
index 00000000..09842aa9
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/validators/pptx.py
@@ -0,0 +1,275 @@
+"""
+Validator for PowerPoint presentation XML files against XSD schemas.
+"""
+
+import re
+
+from .base import BaseSchemaValidator
+
+
+class PPTXSchemaValidator(BaseSchemaValidator):
+
+ PRESENTATIONML_NAMESPACE = (
+ "http://schemas.openxmlformats.org/presentationml/2006/main"
+ )
+
+ ELEMENT_RELATIONSHIP_TYPES = {
+ "sldid": "slide",
+ "sldmasterid": "slidemaster",
+ "notesmasterid": "notesmaster",
+ "sldlayoutid": "slidelayout",
+ "themeid": "theme",
+ "tablestyleid": "tablestyles",
+ }
+
+ def validate(self):
+ if not self.validate_xml():
+ return False
+
+ all_valid = True
+ if not self.validate_namespaces():
+ all_valid = False
+
+ if not self.validate_unique_ids():
+ all_valid = False
+
+ if not self.validate_uuid_ids():
+ all_valid = False
+
+ if not self.validate_file_references():
+ all_valid = False
+
+ if not self.validate_slide_layout_ids():
+ all_valid = False
+
+ if not self.validate_content_types():
+ all_valid = False
+
+ if not self.validate_against_xsd():
+ all_valid = False
+
+ if not self.validate_notes_slide_references():
+ all_valid = False
+
+ if not self.validate_all_relationship_ids():
+ all_valid = False
+
+ if not self.validate_no_duplicate_slide_layouts():
+ all_valid = False
+
+ return all_valid
+
+ def validate_uuid_ids(self):
+ import lxml.etree
+
+ errors = []
+ uuid_pattern = re.compile(
+ r"^[\{\(]?[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{12}[\}\)]?$"
+ )
+
+ for xml_file in self.xml_files:
+ try:
+ root = lxml.etree.parse(str(xml_file)).getroot()
+
+ for elem in root.iter():
+ for attr, value in elem.attrib.items():
+ attr_name = attr.split("}")[-1].lower()
+ if attr_name == "id" or attr_name.endswith("id"):
+ if self._looks_like_uuid(value):
+ if not uuid_pattern.match(value):
+ errors.append(
+ f" {xml_file.relative_to(self.unpacked_dir)}: "
+ f"Line {elem.sourceline}: ID '{value}' appears to be a UUID but contains invalid hex characters"
+ )
+
+ except (lxml.etree.XMLSyntaxError, Exception) as e:
+ errors.append(
+ f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}"
+ )
+
+ if errors:
+ print(f"FAILED - Found {len(errors)} UUID ID validation errors:")
+ for error in errors:
+ print(error)
+ return False
+ else:
+ if self.verbose:
+ print("PASSED - All UUID-like IDs contain valid hex values")
+ return True
+
+ def _looks_like_uuid(self, value):
+ clean_value = value.strip("{}()").replace("-", "")
+ return len(clean_value) == 32 and all(c.isalnum() for c in clean_value)
+
+ def validate_slide_layout_ids(self):
+ import lxml.etree
+
+ errors = []
+
+ slide_masters = list(self.unpacked_dir.glob("ppt/slideMasters/*.xml"))
+
+ if not slide_masters:
+ if self.verbose:
+ print("PASSED - No slide masters found")
+ return True
+
+ for slide_master in slide_masters:
+ try:
+ root = lxml.etree.parse(str(slide_master)).getroot()
+
+ rels_file = slide_master.parent / "_rels" / f"{slide_master.name}.rels"
+
+ if not rels_file.exists():
+ errors.append(
+ f" {slide_master.relative_to(self.unpacked_dir)}: "
+ f"Missing relationships file: {rels_file.relative_to(self.unpacked_dir)}"
+ )
+ continue
+
+ rels_root = lxml.etree.parse(str(rels_file)).getroot()
+
+ valid_layout_rids = set()
+ for rel in rels_root.findall(
+ f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship"
+ ):
+ rel_type = rel.get("Type", "")
+ if "slideLayout" in rel_type:
+ valid_layout_rids.add(rel.get("Id"))
+
+ for sld_layout_id in root.findall(
+ f".//{{{self.PRESENTATIONML_NAMESPACE}}}sldLayoutId"
+ ):
+ r_id = sld_layout_id.get(
+ f"{{{self.OFFICE_RELATIONSHIPS_NAMESPACE}}}id"
+ )
+ layout_id = sld_layout_id.get("id")
+
+ if r_id and r_id not in valid_layout_rids:
+ errors.append(
+ f" {slide_master.relative_to(self.unpacked_dir)}: "
+ f"Line {sld_layout_id.sourceline}: sldLayoutId with id='{layout_id}' "
+ f"references r:id='{r_id}' which is not found in slide layout relationships"
+ )
+
+ except (lxml.etree.XMLSyntaxError, Exception) as e:
+ errors.append(
+ f" {slide_master.relative_to(self.unpacked_dir)}: Error: {e}"
+ )
+
+ if errors:
+ print(f"FAILED - Found {len(errors)} slide layout ID validation errors:")
+ for error in errors:
+ print(error)
+ print(
+ "Remove invalid references or add missing slide layouts to the relationships file."
+ )
+ return False
+ else:
+ if self.verbose:
+ print("PASSED - All slide layout IDs reference valid slide layouts")
+ return True
+
+ def validate_no_duplicate_slide_layouts(self):
+ import lxml.etree
+
+ errors = []
+ slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels"))
+
+ for rels_file in slide_rels_files:
+ try:
+ root = lxml.etree.parse(str(rels_file)).getroot()
+
+ layout_rels = [
+ rel
+ for rel in root.findall(
+ f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship"
+ )
+ if "slideLayout" in rel.get("Type", "")
+ ]
+
+ if len(layout_rels) > 1:
+ errors.append(
+ f" {rels_file.relative_to(self.unpacked_dir)}: has {len(layout_rels)} slideLayout references"
+ )
+
+ except Exception as e:
+ errors.append(
+ f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}"
+ )
+
+ if errors:
+ print("FAILED - Found slides with duplicate slideLayout references:")
+ for error in errors:
+ print(error)
+ return False
+ else:
+ if self.verbose:
+ print("PASSED - All slides have exactly one slideLayout reference")
+ return True
+
+ def validate_notes_slide_references(self):
+ import lxml.etree
+
+ errors = []
+ notes_slide_references = {}
+
+ slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels"))
+
+ if not slide_rels_files:
+ if self.verbose:
+ print("PASSED - No slide relationship files found")
+ return True
+
+ for rels_file in slide_rels_files:
+ try:
+ root = lxml.etree.parse(str(rels_file)).getroot()
+
+ for rel in root.findall(
+ f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship"
+ ):
+ rel_type = rel.get("Type", "")
+ if "notesSlide" in rel_type:
+ target = rel.get("Target", "")
+ if target:
+ normalized_target = target.replace("../", "")
+
+ slide_name = rels_file.stem.replace(
+ ".xml", ""
+ )
+
+ if normalized_target not in notes_slide_references:
+ notes_slide_references[normalized_target] = []
+ notes_slide_references[normalized_target].append(
+ (slide_name, rels_file)
+ )
+
+ except (lxml.etree.XMLSyntaxError, Exception) as e:
+ errors.append(
+ f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}"
+ )
+
+ for target, references in notes_slide_references.items():
+ if len(references) > 1:
+ slide_names = [ref[0] for ref in references]
+ errors.append(
+ f" Notes slide '{target}' is referenced by multiple slides: {', '.join(slide_names)}"
+ )
+ for slide_name, rels_file in references:
+ errors.append(f" - {rels_file.relative_to(self.unpacked_dir)}")
+
+ if errors:
+ print(
+ f"FAILED - Found {len([e for e in errors if not e.startswith(' ')])} notes slide reference validation errors:"
+ )
+ for error in errors:
+ print(error)
+ print("Each slide may optionally have its own slide file.")
+ return False
+ else:
+ if self.verbose:
+ print("PASSED - All notes slide references are unique")
+ return True
+
+
+if __name__ == "__main__":
+ raise RuntimeError("This module should not be run directly.")
diff --git a/src/crates/core/builtin_skills/docx/scripts/office/validators/redlining.py b/src/crates/core/builtin_skills/docx/scripts/office/validators/redlining.py
new file mode 100644
index 00000000..71c81b6b
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/office/validators/redlining.py
@@ -0,0 +1,247 @@
+"""
+Validator for tracked changes in Word documents.
+"""
+
+import subprocess
+import tempfile
+import zipfile
+from pathlib import Path
+
+
+class RedliningValidator:
+
+ def __init__(self, unpacked_dir, original_docx, verbose=False, author="Claude"):
+ self.unpacked_dir = Path(unpacked_dir)
+ self.original_docx = Path(original_docx)
+ self.verbose = verbose
+ self.author = author
+ self.namespaces = {
+ "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
+ }
+
+ def repair(self) -> int:
+ return 0
+
+ def validate(self):
+ modified_file = self.unpacked_dir / "word" / "document.xml"
+ if not modified_file.exists():
+ print(f"FAILED - Modified document.xml not found at {modified_file}")
+ return False
+
+ try:
+ import xml.etree.ElementTree as ET
+
+ tree = ET.parse(modified_file)
+ root = tree.getroot()
+
+ del_elements = root.findall(".//w:del", self.namespaces)
+ ins_elements = root.findall(".//w:ins", self.namespaces)
+
+ author_del_elements = [
+ elem
+ for elem in del_elements
+ if elem.get(f"{{{self.namespaces['w']}}}author") == self.author
+ ]
+ author_ins_elements = [
+ elem
+ for elem in ins_elements
+ if elem.get(f"{{{self.namespaces['w']}}}author") == self.author
+ ]
+
+ if not author_del_elements and not author_ins_elements:
+ if self.verbose:
+ print(f"PASSED - No tracked changes by {self.author} found.")
+ return True
+
+ except Exception:
+ pass
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ try:
+ with zipfile.ZipFile(self.original_docx, "r") as zip_ref:
+ zip_ref.extractall(temp_path)
+ except Exception as e:
+ print(f"FAILED - Error unpacking original docx: {e}")
+ return False
+
+ original_file = temp_path / "word" / "document.xml"
+ if not original_file.exists():
+ print(
+ f"FAILED - Original document.xml not found in {self.original_docx}"
+ )
+ return False
+
+ try:
+ import xml.etree.ElementTree as ET
+
+ modified_tree = ET.parse(modified_file)
+ modified_root = modified_tree.getroot()
+ original_tree = ET.parse(original_file)
+ original_root = original_tree.getroot()
+ except ET.ParseError as e:
+ print(f"FAILED - Error parsing XML files: {e}")
+ return False
+
+ self._remove_author_tracked_changes(original_root)
+ self._remove_author_tracked_changes(modified_root)
+
+ modified_text = self._extract_text_content(modified_root)
+ original_text = self._extract_text_content(original_root)
+
+ if modified_text != original_text:
+ error_message = self._generate_detailed_diff(
+ original_text, modified_text
+ )
+ print(error_message)
+ return False
+
+ if self.verbose:
+ print(f"PASSED - All changes by {self.author} are properly tracked")
+ return True
+
+ def _generate_detailed_diff(self, original_text, modified_text):
+ error_parts = [
+ f"FAILED - Document text doesn't match after removing {self.author}'s tracked changes",
+ "",
+ "Likely causes:",
+ " 1. Modified text inside another author's or tags",
+ " 2. Made edits without proper tracked changes",
+ " 3. Didn't nest inside when deleting another's insertion",
+ "",
+ "For pre-redlined documents, use correct patterns:",
+ " - To reject another's INSERTION: Nest inside their ",
+ " - To restore another's DELETION: Add new AFTER their ",
+ "",
+ ]
+
+ git_diff = self._get_git_word_diff(original_text, modified_text)
+ if git_diff:
+ error_parts.extend(["Differences:", "============", git_diff])
+ else:
+ error_parts.append("Unable to generate word diff (git not available)")
+
+ return "\n".join(error_parts)
+
+ def _get_git_word_diff(self, original_text, modified_text):
+ try:
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+
+ original_file = temp_path / "original.txt"
+ modified_file = temp_path / "modified.txt"
+
+ original_file.write_text(original_text, encoding="utf-8")
+ modified_file.write_text(modified_text, encoding="utf-8")
+
+ result = subprocess.run(
+ [
+ "git",
+ "diff",
+ "--word-diff=plain",
+ "--word-diff-regex=.",
+ "-U0",
+ "--no-index",
+ str(original_file),
+ str(modified_file),
+ ],
+ capture_output=True,
+ text=True,
+ )
+
+ if result.stdout.strip():
+ lines = result.stdout.split("\n")
+ content_lines = []
+ in_content = False
+ for line in lines:
+ if line.startswith("@@"):
+ in_content = True
+ continue
+ if in_content and line.strip():
+ content_lines.append(line)
+
+ if content_lines:
+ return "\n".join(content_lines)
+
+ result = subprocess.run(
+ [
+ "git",
+ "diff",
+ "--word-diff=plain",
+ "-U0",
+ "--no-index",
+ str(original_file),
+ str(modified_file),
+ ],
+ capture_output=True,
+ text=True,
+ )
+
+ if result.stdout.strip():
+ lines = result.stdout.split("\n")
+ content_lines = []
+ in_content = False
+ for line in lines:
+ if line.startswith("@@"):
+ in_content = True
+ continue
+ if in_content and line.strip():
+ content_lines.append(line)
+ return "\n".join(content_lines)
+
+ except (subprocess.CalledProcessError, FileNotFoundError, Exception):
+ pass
+
+ return None
+
+ def _remove_author_tracked_changes(self, root):
+ ins_tag = f"{{{self.namespaces['w']}}}ins"
+ del_tag = f"{{{self.namespaces['w']}}}del"
+ author_attr = f"{{{self.namespaces['w']}}}author"
+
+ for parent in root.iter():
+ to_remove = []
+ for child in parent:
+ if child.tag == ins_tag and child.get(author_attr) == self.author:
+ to_remove.append(child)
+ for elem in to_remove:
+ parent.remove(elem)
+
+ deltext_tag = f"{{{self.namespaces['w']}}}delText"
+ t_tag = f"{{{self.namespaces['w']}}}t"
+
+ for parent in root.iter():
+ to_process = []
+ for child in parent:
+ if child.tag == del_tag and child.get(author_attr) == self.author:
+ to_process.append((child, list(parent).index(child)))
+
+ for del_elem, del_index in reversed(to_process):
+ for elem in del_elem.iter():
+ if elem.tag == deltext_tag:
+ elem.tag = t_tag
+
+ for child in reversed(list(del_elem)):
+ parent.insert(del_index, child)
+ parent.remove(del_elem)
+
+ def _extract_text_content(self, root):
+ p_tag = f"{{{self.namespaces['w']}}}p"
+ t_tag = f"{{{self.namespaces['w']}}}t"
+
+ paragraphs = []
+ for p_elem in root.findall(f".//{p_tag}"):
+ text_parts = []
+ for t_elem in p_elem.findall(f".//{t_tag}"):
+ if t_elem.text:
+ text_parts.append(t_elem.text)
+ paragraph_text = "".join(text_parts)
+ if paragraph_text:
+ paragraphs.append(paragraph_text)
+
+ return "\n".join(paragraphs)
+
+
+if __name__ == "__main__":
+ raise RuntimeError("This module should not be run directly.")
diff --git a/src/crates/core/builtin_skills/docx/scripts/templates/comments.xml b/src/crates/core/builtin_skills/docx/scripts/templates/comments.xml
new file mode 100644
index 00000000..cd01a7d7
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/templates/comments.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/templates/commentsExtended.xml b/src/crates/core/builtin_skills/docx/scripts/templates/commentsExtended.xml
new file mode 100644
index 00000000..411003cc
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/templates/commentsExtended.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/templates/commentsExtensible.xml b/src/crates/core/builtin_skills/docx/scripts/templates/commentsExtensible.xml
new file mode 100644
index 00000000..f5572d71
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/templates/commentsExtensible.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/templates/commentsIds.xml b/src/crates/core/builtin_skills/docx/scripts/templates/commentsIds.xml
new file mode 100644
index 00000000..32f1629f
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/templates/commentsIds.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/crates/core/builtin_skills/docx/scripts/templates/people.xml b/src/crates/core/builtin_skills/docx/scripts/templates/people.xml
new file mode 100644
index 00000000..3803d2de
--- /dev/null
+++ b/src/crates/core/builtin_skills/docx/scripts/templates/people.xml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/crates/core/builtin_skills/pdf/LICENSE.txt b/src/crates/core/builtin_skills/pdf/LICENSE.txt
new file mode 100644
index 00000000..c55ab422
--- /dev/null
+++ b/src/crates/core/builtin_skills/pdf/LICENSE.txt
@@ -0,0 +1,30 @@
+© 2025 Anthropic, PBC. All rights reserved.
+
+LICENSE: Use of these materials (including all code, prompts, assets, files,
+and other components of this Skill) is governed by your agreement with
+Anthropic regarding use of Anthropic's services. If no separate agreement
+exists, use is governed by Anthropic's Consumer Terms of Service or
+Commercial Terms of Service, as applicable:
+https://www.anthropic.com/legal/consumer-terms
+https://www.anthropic.com/legal/commercial-terms
+Your applicable agreement is referred to as the "Agreement." "Services" are
+as defined in the Agreement.
+
+ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the
+contrary, users may not:
+
+- Extract these materials from the Services or retain copies of these
+ materials outside the Services
+- Reproduce or copy these materials, except for temporary copies created
+ automatically during authorized use of the Services
+- Create derivative works based on these materials
+- Distribute, sublicense, or transfer these materials to any third party
+- Make, offer to sell, sell, or import any inventions embodied in these
+ materials
+- Reverse engineer, decompile, or disassemble these materials
+
+The receipt, viewing, or possession of these materials does not convey or
+imply any license or right beyond those expressly granted above.
+
+Anthropic retains all right, title, and interest in these materials,
+including all copyrights, patents, and other intellectual property rights.
diff --git a/src/crates/core/builtin_skills/pdf/SKILL.md b/src/crates/core/builtin_skills/pdf/SKILL.md
new file mode 100644
index 00000000..d3e046a5
--- /dev/null
+++ b/src/crates/core/builtin_skills/pdf/SKILL.md
@@ -0,0 +1,314 @@
+---
+name: pdf
+description: Use this skill whenever the user wants to do anything with PDF files. This includes reading or extracting text/tables from PDFs, combining or merging multiple PDFs into one, splitting PDFs apart, rotating pages, adding watermarks, creating new PDFs, filling PDF forms, encrypting/decrypting PDFs, extracting images, and OCR on scanned PDFs to make them searchable. If the user mentions a .pdf file or asks to produce one, use this skill.
+license: Proprietary. LICENSE.txt has complete terms
+---
+
+# PDF Processing Guide
+
+## Overview
+
+This guide covers essential PDF processing operations using Python libraries and command-line tools. For advanced features, JavaScript libraries, and detailed examples, see REFERENCE.md. If you need to fill out a PDF form, read FORMS.md and follow its instructions.
+
+## Quick Start
+
+```python
+from pypdf import PdfReader, PdfWriter
+
+# Read a PDF
+reader = PdfReader("document.pdf")
+print(f"Pages: {len(reader.pages)}")
+
+# Extract text
+text = ""
+for page in reader.pages:
+ text += page.extract_text()
+```
+
+## Python Libraries
+
+### pypdf - Basic Operations
+
+#### Merge PDFs
+```python
+from pypdf import PdfWriter, PdfReader
+
+writer = PdfWriter()
+for pdf_file in ["doc1.pdf", "doc2.pdf", "doc3.pdf"]:
+ reader = PdfReader(pdf_file)
+ for page in reader.pages:
+ writer.add_page(page)
+
+with open("merged.pdf", "wb") as output:
+ writer.write(output)
+```
+
+#### Split PDF
+```python
+reader = PdfReader("input.pdf")
+for i, page in enumerate(reader.pages):
+ writer = PdfWriter()
+ writer.add_page(page)
+ with open(f"page_{i+1}.pdf", "wb") as output:
+ writer.write(output)
+```
+
+#### Extract Metadata
+```python
+reader = PdfReader("document.pdf")
+meta = reader.metadata
+print(f"Title: {meta.title}")
+print(f"Author: {meta.author}")
+print(f"Subject: {meta.subject}")
+print(f"Creator: {meta.creator}")
+```
+
+#### Rotate Pages
+```python
+reader = PdfReader("input.pdf")
+writer = PdfWriter()
+
+page = reader.pages[0]
+page.rotate(90) # Rotate 90 degrees clockwise
+writer.add_page(page)
+
+with open("rotated.pdf", "wb") as output:
+ writer.write(output)
+```
+
+### pdfplumber - Text and Table Extraction
+
+#### Extract Text with Layout
+```python
+import pdfplumber
+
+with pdfplumber.open("document.pdf") as pdf:
+ for page in pdf.pages:
+ text = page.extract_text()
+ print(text)
+```
+
+#### Extract Tables
+```python
+with pdfplumber.open("document.pdf") as pdf:
+ for i, page in enumerate(pdf.pages):
+ tables = page.extract_tables()
+ for j, table in enumerate(tables):
+ print(f"Table {j+1} on page {i+1}:")
+ for row in table:
+ print(row)
+```
+
+#### Advanced Table Extraction
+```python
+import pandas as pd
+
+with pdfplumber.open("document.pdf") as pdf:
+ all_tables = []
+ for page in pdf.pages:
+ tables = page.extract_tables()
+ for table in tables:
+ if table: # Check if table is not empty
+ df = pd.DataFrame(table[1:], columns=table[0])
+ all_tables.append(df)
+
+# Combine all tables
+if all_tables:
+ combined_df = pd.concat(all_tables, ignore_index=True)
+ combined_df.to_excel("extracted_tables.xlsx", index=False)
+```
+
+### reportlab - Create PDFs
+
+#### Basic PDF Creation
+```python
+from reportlab.lib.pagesizes import letter
+from reportlab.pdfgen import canvas
+
+c = canvas.Canvas("hello.pdf", pagesize=letter)
+width, height = letter
+
+# Add text
+c.drawString(100, height - 100, "Hello World!")
+c.drawString(100, height - 120, "This is a PDF created with reportlab")
+
+# Add a line
+c.line(100, height - 140, 400, height - 140)
+
+# Save
+c.save()
+```
+
+#### Create PDF with Multiple Pages
+```python
+from reportlab.lib.pagesizes import letter
+from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
+from reportlab.lib.styles import getSampleStyleSheet
+
+doc = SimpleDocTemplate("report.pdf", pagesize=letter)
+styles = getSampleStyleSheet()
+story = []
+
+# Add content
+title = Paragraph("Report Title", styles['Title'])
+story.append(title)
+story.append(Spacer(1, 12))
+
+body = Paragraph("This is the body of the report. " * 20, styles['Normal'])
+story.append(body)
+story.append(PageBreak())
+
+# Page 2
+story.append(Paragraph("Page 2", styles['Heading1']))
+story.append(Paragraph("Content for page 2", styles['Normal']))
+
+# Build PDF
+doc.build(story)
+```
+
+#### Subscripts and Superscripts
+
+**IMPORTANT**: Never use Unicode subscript/superscript characters (₀₁₂₃₄₅₆₇₈₉, ⁰¹²³⁴⁵⁶⁷⁸⁹) in ReportLab PDFs. The built-in fonts do not include these glyphs, causing them to render as solid black boxes.
+
+Instead, use ReportLab's XML markup tags in Paragraph objects:
+```python
+from reportlab.platypus import Paragraph
+from reportlab.lib.styles import getSampleStyleSheet
+
+styles = getSampleStyleSheet()
+
+# Subscripts: use tag
+chemical = Paragraph("H2O", styles['Normal'])
+
+# Superscripts: use tag
+squared = Paragraph("x2 + y2", styles['Normal'])
+```
+
+For canvas-drawn text (not Paragraph objects), manually adjust font the size and position rather than using Unicode subscripts/superscripts.
+
+## Command-Line Tools
+
+### pdftotext (poppler-utils)
+```bash
+# Extract text
+pdftotext input.pdf output.txt
+
+# Extract text preserving layout
+pdftotext -layout input.pdf output.txt
+
+# Extract specific pages
+pdftotext -f 1 -l 5 input.pdf output.txt # Pages 1-5
+```
+
+### qpdf
+```bash
+# Merge PDFs
+qpdf --empty --pages file1.pdf file2.pdf -- merged.pdf
+
+# Split pages
+qpdf input.pdf --pages . 1-5 -- pages1-5.pdf
+qpdf input.pdf --pages . 6-10 -- pages6-10.pdf
+
+# Rotate pages
+qpdf input.pdf output.pdf --rotate=+90:1 # Rotate page 1 by 90 degrees
+
+# Remove password
+qpdf --password=mypassword --decrypt encrypted.pdf decrypted.pdf
+```
+
+### pdftk (if available)
+```bash
+# Merge
+pdftk file1.pdf file2.pdf cat output merged.pdf
+
+# Split
+pdftk input.pdf burst
+
+# Rotate
+pdftk input.pdf rotate 1east output rotated.pdf
+```
+
+## Common Tasks
+
+### Extract Text from Scanned PDFs
+```python
+# Requires: pip install pytesseract pdf2image
+import pytesseract
+from pdf2image import convert_from_path
+
+# Convert PDF to images
+images = convert_from_path('scanned.pdf')
+
+# OCR each page
+text = ""
+for i, image in enumerate(images):
+ text += f"Page {i+1}:\n"
+ text += pytesseract.image_to_string(image)
+ text += "\n\n"
+
+print(text)
+```
+
+### Add Watermark
+```python
+from pypdf import PdfReader, PdfWriter
+
+# Create watermark (or load existing)
+watermark = PdfReader("watermark.pdf").pages[0]
+
+# Apply to all pages
+reader = PdfReader("document.pdf")
+writer = PdfWriter()
+
+for page in reader.pages:
+ page.merge_page(watermark)
+ writer.add_page(page)
+
+with open("watermarked.pdf", "wb") as output:
+ writer.write(output)
+```
+
+### Extract Images
+```bash
+# Using pdfimages (poppler-utils)
+pdfimages -j input.pdf output_prefix
+
+# This extracts all images as output_prefix-000.jpg, output_prefix-001.jpg, etc.
+```
+
+### Password Protection
+```python
+from pypdf import PdfReader, PdfWriter
+
+reader = PdfReader("input.pdf")
+writer = PdfWriter()
+
+for page in reader.pages:
+ writer.add_page(page)
+
+# Add password
+writer.encrypt("userpassword", "ownerpassword")
+
+with open("encrypted.pdf", "wb") as output:
+ writer.write(output)
+```
+
+## Quick Reference
+
+| Task | Best Tool | Command/Code |
+|------|-----------|--------------|
+| Merge PDFs | pypdf | `writer.add_page(page)` |
+| Split PDFs | pypdf | One page per file |
+| Extract text | pdfplumber | `page.extract_text()` |
+| Extract tables | pdfplumber | `page.extract_tables()` |
+| Create PDFs | reportlab | Canvas or Platypus |
+| Command line merge | qpdf | `qpdf --empty --pages ...` |
+| OCR scanned PDFs | pytesseract | Convert to image first |
+| Fill PDF forms | pdf-lib or pypdf (see FORMS.md) | See FORMS.md |
+
+## Next Steps
+
+- For advanced pypdfium2 usage, see REFERENCE.md
+- For JavaScript libraries (pdf-lib), see REFERENCE.md
+- If you need to fill out a PDF form, follow the instructions in FORMS.md
+- For troubleshooting guides, see REFERENCE.md
diff --git a/src/crates/core/builtin_skills/pdf/forms.md b/src/crates/core/builtin_skills/pdf/forms.md
new file mode 100644
index 00000000..6e7e1e0d
--- /dev/null
+++ b/src/crates/core/builtin_skills/pdf/forms.md
@@ -0,0 +1,294 @@
+**CRITICAL: You MUST complete these steps in order. Do not skip ahead to writing code.**
+
+If you need to fill out a PDF form, first check to see if the PDF has fillable form fields. Run this script from this file's directory:
+ `python scripts/check_fillable_fields `, and depending on the result go to either the "Fillable fields" or "Non-fillable fields" and follow those instructions.
+
+# Fillable fields
+If the PDF has fillable form fields:
+- Run this script from this file's directory: `python scripts/extract_form_field_info.py `. It will create a JSON file with a list of fields in this format:
+```
+[
+ {
+ "field_id": (unique ID for the field),
+ "page": (page number, 1-based),
+ "rect": ([left, bottom, right, top] bounding box in PDF coordinates, y=0 is the bottom of the page),
+ "type": ("text", "checkbox", "radio_group", or "choice"),
+ },
+ // Checkboxes have "checked_value" and "unchecked_value" properties:
+ {
+ "field_id": (unique ID for the field),
+ "page": (page number, 1-based),
+ "type": "checkbox",
+ "checked_value": (Set the field to this value to check the checkbox),
+ "unchecked_value": (Set the field to this value to uncheck the checkbox),
+ },
+ // Radio groups have a "radio_options" list with the possible choices.
+ {
+ "field_id": (unique ID for the field),
+ "page": (page number, 1-based),
+ "type": "radio_group",
+ "radio_options": [
+ {
+ "value": (set the field to this value to select this radio option),
+ "rect": (bounding box for the radio button for this option)
+ },
+ // Other radio options
+ ]
+ },
+ // Multiple choice fields have a "choice_options" list with the possible choices:
+ {
+ "field_id": (unique ID for the field),
+ "page": (page number, 1-based),
+ "type": "choice",
+ "choice_options": [
+ {
+ "value": (set the field to this value to select this option),
+ "text": (display text of the option)
+ },
+ // Other choice options
+ ],
+ }
+]
+```
+- Convert the PDF to PNGs (one image for each page) with this script (run from this file's directory):
+`python scripts/convert_pdf_to_images.py `
+Then analyze the images to determine the purpose of each form field (make sure to convert the bounding box PDF coordinates to image coordinates).
+- Create a `field_values.json` file in this format with the values to be entered for each field:
+```
+[
+ {
+ "field_id": "last_name", // Must match the field_id from `extract_form_field_info.py`
+ "description": "The user's last name",
+ "page": 1, // Must match the "page" value in field_info.json
+ "value": "Simpson"
+ },
+ {
+ "field_id": "Checkbox12",
+ "description": "Checkbox to be checked if the user is 18 or over",
+ "page": 1,
+ "value": "/On" // If this is a checkbox, use its "checked_value" value to check it. If it's a radio button group, use one of the "value" values in "radio_options".
+ },
+ // more fields
+]
+```
+- Run the `fill_fillable_fields.py` script from this file's directory to create a filled-in PDF:
+`python scripts/fill_fillable_fields.py
}
+ type="warning"
+ confirmDanger
+ confirmText={t('deleteModal.delete')}
+ cancelText={t('deleteModal.cancel')}
+ />
+
+
+ );
+};
+
+export default PluginsConfig;
+
diff --git a/src/web-ui/src/infrastructure/i18n/core/I18nService.ts b/src/web-ui/src/infrastructure/i18n/core/I18nService.ts
index d12b4686..16276026 100644
--- a/src/web-ui/src/infrastructure/i18n/core/I18nService.ts
+++ b/src/web-ui/src/infrastructure/i18n/core/I18nService.ts
@@ -51,6 +51,7 @@ import zhCNSettingsLogging from '../../../locales/zh-CN/settings/logging.json';
import zhCNSettingsEditor from '../../../locales/zh-CN/settings/editor.json';
import zhCNSettingsPromptTemplates from '../../../locales/zh-CN/settings/prompt-templates.json';
import zhCNSettingsSkills from '../../../locales/zh-CN/settings/skills.json';
+import zhCNSettingsPlugins from '../../../locales/zh-CN/settings/plugins.json';
import zhCNSettingsAiRules from '../../../locales/zh-CN/settings/ai-rules.json';
import zhCNSettingsAiMemory from '../../../locales/zh-CN/settings/ai-memory.json';
import zhCNSettingsAgents from '../../../locales/zh-CN/settings/agents.json';
@@ -87,6 +88,7 @@ import enUSSettingsLogging from '../../../locales/en-US/settings/logging.json';
import enUSSettingsEditor from '../../../locales/en-US/settings/editor.json';
import enUSSettingsPromptTemplates from '../../../locales/en-US/settings/prompt-templates.json';
import enUSSettingsSkills from '../../../locales/en-US/settings/skills.json';
+import enUSSettingsPlugins from '../../../locales/en-US/settings/plugins.json';
import enUSSettingsAiRules from '../../../locales/en-US/settings/ai-rules.json';
import enUSSettingsAiMemory from '../../../locales/en-US/settings/ai-memory.json';
import enUSSettingsAgents from '../../../locales/en-US/settings/agents.json';
@@ -130,6 +132,7 @@ const resources = {
'settings/editor': zhCNSettingsEditor,
'settings/prompt-templates': zhCNSettingsPromptTemplates,
'settings/skills': zhCNSettingsSkills,
+ 'settings/plugins': zhCNSettingsPlugins,
'settings/ai-rules': zhCNSettingsAiRules,
'settings/ai-memory': zhCNSettingsAiMemory,
'settings/agents': zhCNSettingsAgents,
@@ -167,6 +170,7 @@ const resources = {
'settings/editor': enUSSettingsEditor,
'settings/prompt-templates': enUSSettingsPromptTemplates,
'settings/skills': enUSSettingsSkills,
+ 'settings/plugins': enUSSettingsPlugins,
'settings/ai-rules': enUSSettingsAiRules,
'settings/ai-memory': enUSSettingsAiMemory,
'settings/agents': enUSSettingsAgents,
@@ -227,6 +231,7 @@ export class I18nService {
'settings/editor',
'settings/prompt-templates',
'settings/skills',
+ 'settings/plugins',
'settings/ai-rules',
'settings/ai-memory',
'settings/agents',
diff --git a/src/web-ui/src/locales/en-US/settings.json b/src/web-ui/src/locales/en-US/settings.json
index ee468c12..4939961f 100644
--- a/src/web-ui/src/locales/en-US/settings.json
+++ b/src/web-ui/src/locales/en-US/settings.json
@@ -20,6 +20,7 @@
"aiMemory": "Memory",
"promptTemplates": "Prompts",
"skills": "Skills",
+ "plugins": "Plugins",
"agents": "Sub Agent",
"mcp": "MCP",
"editor": "Editor",
diff --git a/src/web-ui/src/locales/en-US/settings/plugins.json b/src/web-ui/src/locales/en-US/settings/plugins.json
new file mode 100644
index 00000000..e56fb3c3
--- /dev/null
+++ b/src/web-ui/src/locales/en-US/settings/plugins.json
@@ -0,0 +1,39 @@
+{
+ "title": "Plugin Management",
+ "subtitle": "Install, enable, and manage plugins",
+ "toolbar": {
+ "searchPlaceholder": "Search plugins...",
+ "refreshTooltip": "Refresh",
+ "installFromFile": "Install (File)",
+ "installFromFolder": "Install (Folder)",
+ "overwriteExisting": "Overwrite existing MCP servers"
+ },
+ "messages": {
+ "installSuccess": "Plugin installed successfully",
+ "installFailed": "Failed to install plugin: {{error}}",
+ "toggleSuccess": "Updated plugin: {{name}}",
+ "toggleFailed": "Failed to update plugin: {{error}}",
+ "importSuccess": "Imported MCP servers (added: {{added}}, overwritten: {{overwritten}}, skipped: {{skipped}})",
+ "importFailed": "Failed to import MCP servers: {{error}}",
+ "uninstallSuccess": "Plugin uninstalled: {{name}}",
+ "uninstallFailed": "Failed to uninstall plugin: {{error}}"
+ },
+ "list": {
+ "loading": "Loading...",
+ "errorPrefix": "Failed to load: ",
+ "empty": "No plugins found",
+ "item": {
+ "mcpServers": "{{count}} MCP servers",
+ "noMcp": "No MCP config",
+ "importMcp": "Import MCP",
+ "uninstall": "Uninstall"
+ }
+ },
+ "deleteModal": {
+ "title": "Uninstall Plugin",
+ "message": "Are you sure you want to uninstall \"{{name}}\"?",
+ "delete": "Uninstall",
+ "cancel": "Cancel"
+ }
+}
+
diff --git a/src/web-ui/src/locales/zh-CN/settings.json b/src/web-ui/src/locales/zh-CN/settings.json
index 6e8230a9..91908ad6 100644
--- a/src/web-ui/src/locales/zh-CN/settings.json
+++ b/src/web-ui/src/locales/zh-CN/settings.json
@@ -20,6 +20,7 @@
"aiMemory": "记忆",
"promptTemplates": "提示词",
"skills": "技能",
+ "plugins": "插件",
"agents": "Sub Agent",
"mcp": "MCP",
"editor": "编辑器",
diff --git a/src/web-ui/src/locales/zh-CN/settings/plugins.json b/src/web-ui/src/locales/zh-CN/settings/plugins.json
new file mode 100644
index 00000000..9d89aa65
--- /dev/null
+++ b/src/web-ui/src/locales/zh-CN/settings/plugins.json
@@ -0,0 +1,39 @@
+{
+ "title": "插件管理",
+ "subtitle": "安装、启用并管理插件",
+ "toolbar": {
+ "searchPlaceholder": "搜索插件...",
+ "refreshTooltip": "刷新",
+ "installFromFile": "安装(文件)",
+ "installFromFolder": "安装(文件夹)",
+ "overwriteExisting": "覆盖已存在的 MCP 配置"
+ },
+ "messages": {
+ "installSuccess": "插件安装成功",
+ "installFailed": "插件安装失败:{{error}}",
+ "toggleSuccess": "插件已更新:{{name}}",
+ "toggleFailed": "插件更新失败:{{error}}",
+ "importSuccess": "已导入 MCP 配置(新增:{{added}},覆盖:{{overwritten}},跳过:{{skipped}})",
+ "importFailed": "导入 MCP 配置失败:{{error}}",
+ "uninstallSuccess": "插件已卸载:{{name}}",
+ "uninstallFailed": "插件卸载失败:{{error}}"
+ },
+ "list": {
+ "loading": "加载中...",
+ "errorPrefix": "加载失败:",
+ "empty": "未找到插件",
+ "item": {
+ "mcpServers": "{{count}} 个 MCP 服务",
+ "noMcp": "无 MCP 配置",
+ "importMcp": "导入 MCP",
+ "uninstall": "卸载"
+ }
+ },
+ "deleteModal": {
+ "title": "卸载插件",
+ "message": "确定要卸载 \"{{name}}\" 吗?",
+ "delete": "卸载",
+ "cancel": "取消"
+ }
+}
+
From a9c43abd0d9fc2213b3217c47fa1b48e6bd7fb72 Mon Sep 17 00:00:00 2001
From: wgqqqqq
Date: Wed, 11 Feb 2026 22:47:48 +0800
Subject: [PATCH 02/19] refactor(core): update agent pipeline and MCP remote
transport
---
src/crates/api-layer/src/dto.rs | 3 +-
src/crates/api-layer/src/lib.rs | 1 -
src/crates/core/Cargo.toml | 11 +
.../core/src/agentic/agents/cowork_mode.rs | 1 -
.../custom_subagent_loader.rs | 2 +-
.../core/src/agentic/agents/debug_mode.rs | 27 +-
src/crates/core/src/agentic/agents/mod.rs | 4 +-
.../src/agentic/agents/prompt_builder/mod.rs | 2 +-
.../src/agentic/agents/prompts/cowork_mode.md | 2 +-
.../src/agentic/coordination/coordinator.rs | 8 +-
.../core/src/agentic/coordination/mod.rs | 1 -
src/crates/core/src/agentic/core/mod.rs | 6 +-
src/crates/core/src/agentic/core/session.rs | 6 +-
src/crates/core/src/agentic/events/mod.rs | 8 +-
src/crates/core/src/agentic/execution/mod.rs | 9 +-
.../src/agentic/image_analysis/enhancer.rs | 8 +-
.../core/src/agentic/image_analysis/mod.rs | 11 +-
.../src/agentic/image_analysis/processor.rs | 13 +-
.../core/src/agentic/persistence/manager.rs | 107 ++-
.../core/src/agentic/persistence/mod.rs | 4 +-
.../src/agentic/session/history_manager.rs | 69 +-
src/crates/core/src/agentic/session/mod.rs | 12 +-
.../core/src/agentic/tools/image_context.rs | 7 +-
.../implementations/ask_user_question_tool.rs | 3 +-
.../tools/implementations/delete_file_tool.rs | 106 ++-
.../tools/implementations/ide_control_tool.rs | 16 +-
.../mermaid_interactive_tool.rs | 168 ++--
.../src/agentic/tools/implementations/mod.rs | 60 +-
.../tools/implementations/skills/mod.rs | 2 +-
.../tools/implementations/skills/registry.rs | 2 +-
.../tools/implementations/skills/types.rs | 28 +-
.../tool-runtime/src/fs/mod.rs | 2 +-
.../tool-runtime/src/util/string.rs | 2 +-
.../core/src/agentic/tools/pipeline/mod.rs | 7 +-
.../agentic/tools/pipeline/state_manager.rs | 103 +-
.../agentic/tools/pipeline/tool_pipeline.rs | 607 +++++++-----
.../core/src/agentic/tools/pipeline/types.rs | 7 +-
src/crates/core/src/agentic/tools/registry.rs | 8 +-
src/crates/core/src/agentic/util/mod.rs | 2 +-
.../git-func-agent/ai_service.rs | 117 +--
.../git-func-agent/commit_generator.rs | 99 +-
.../git-func-agent/context_analyzer.rs | 86 +-
.../src/function_agents/git-func-agent/mod.rs | 18 +-
.../function_agents/git-func-agent/types.rs | 65 +-
.../function_agents/git-func-agent/utils.rs | 71 +-
src/crates/core/src/function_agents/mod.rs | 16 +-
.../startchat-func-agent/ai_service.rs | 208 ++--
.../startchat-func-agent/mod.rs | 22 +-
.../startchat-func-agent/types.rs | 78 +-
.../work_state_analyzer.rs | 140 +--
.../src/stream_handler/mod.rs | 4 +-
.../ai/ai_stream_handlers/src/types/mod.rs | 4 +-
src/crates/core/src/infrastructure/ai/mod.rs | 4 +-
.../providers/anthropic/message_converter.rs | 26 +-
.../ai/providers/anthropic/mod.rs | 1 -
.../src/infrastructure/ai/providers/mod.rs | 3 +-
.../ai/providers/openai/message_converter.rs | 19 +-
.../infrastructure/ai/providers/openai/mod.rs | 1 -
.../infrastructure/debug_log/http_server.rs | 116 +--
.../core/src/infrastructure/debug_log/mod.rs | 17 +-
.../src/infrastructure/debug_log/types.rs | 15 +-
.../src/infrastructure/events/event_system.rs | 35 +-
.../core/src/infrastructure/events/mod.rs | 10 +-
.../core/src/infrastructure/filesystem/mod.rs | 30 +-
.../infrastructure/filesystem/path_manager.rs | 8 +
.../src/infrastructure/storage/cleanup.rs | 185 ++--
.../core/src/infrastructure/storage/mod.rs | 6 +-
.../src/infrastructure/storage/persistence.rs | 107 ++-
src/crates/core/src/lib.rs | 28 +-
.../core/src/service/ai_memory/manager.rs | 22 +-
src/crates/core/src/service/config/mod.rs | 1 -
.../conversation/persistence_manager.rs | 12 +-
src/crates/core/src/service/lsp/global.rs | 2 +-
src/crates/core/src/service/lsp/manager.rs | 3 -
.../src/service/mcp/config/cursor_format.rs | 54 +-
.../src/service/mcp/config/json_config.rs | 15 +-
.../service/mcp/protocol/transport_remote.rs | 892 +++++++++++++-----
.../core/src/service/mcp/server/connection.rs | 190 ++--
.../core/src/service/mcp/server/manager.rs | 54 +-
src/crates/core/src/service/mcp/server/mod.rs | 3 +
.../core/src/service/mcp/server/process.rs | 34 +-
.../src/service/snapshot/snapshot_core.rs | 14 +-
.../src/service/snapshot/snapshot_system.rs | 5 +-
.../core/src/service/workspace/service.rs | 4 +-
src/crates/core/src/util/errors.rs | 6 +-
src/crates/core/src/util/token_counter.rs | 4 +-
src/crates/core/src/util/types/config.rs | 7 +-
src/crates/core/src/util/types/mod.rs | 8 +-
.../core/tests/remote_mcp_streamable_http.rs | 168 ++++
src/crates/transport/src/adapters/cli.rs | 149 +--
src/crates/transport/src/adapters/mod.rs | 1 -
src/crates/transport/src/adapters/tauri.rs | 514 ++++++----
.../transport/src/adapters/websocket.rs | 85 +-
src/crates/transport/src/emitter.rs | 7 +-
src/crates/transport/src/event_bus.rs | 52 +-
src/crates/transport/src/events.rs | 13 +-
src/crates/transport/src/lib.rs | 17 +-
src/crates/transport/src/traits.rs | 42 +-
98 files changed, 3328 insertions(+), 2034 deletions(-)
create mode 100644 src/crates/core/tests/remote_mcp_streamable_http.rs
diff --git a/src/crates/api-layer/src/dto.rs b/src/crates/api-layer/src/dto.rs
index 0ab65a55..80ad3589 100644
--- a/src/crates/api-layer/src/dto.rs
+++ b/src/crates/api-layer/src/dto.rs
@@ -1,7 +1,6 @@
/// Data Transfer Objects (DTO) - Platform-agnostic request and response types
///
/// These types are used by all platforms (CLI, Tauri, Server)
-
use serde::{Deserialize, Serialize};
/// Execute agent task request
@@ -27,7 +26,7 @@ pub struct ExecuteAgentResponse {
/// Image data
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageData {
- pub data: String, // Base64
+ pub data: String, // Base64
pub mime_type: String,
}
diff --git a/src/crates/api-layer/src/lib.rs b/src/crates/api-layer/src/lib.rs
index c22940c3..1507afac 100644
--- a/src/crates/api-layer/src/lib.rs
+++ b/src/crates/api-layer/src/lib.rs
@@ -4,7 +4,6 @@
/// - CLI (apps/cli)
/// - Tauri Desktop (apps/desktop)
/// - Web Server (apps/server)
-
pub mod dto;
pub mod handlers;
diff --git a/src/crates/core/Cargo.toml b/src/crates/core/Cargo.toml
index 19cad046..5ff8230b 100644
--- a/src/crates/core/Cargo.toml
+++ b/src/crates/core/Cargo.toml
@@ -67,6 +67,17 @@ globset = { workspace = true }
eventsource-stream = { workspace = true }
+# MCP Streamable HTTP client (official rust-sdk used by Codex)
+rmcp = { version = "0.12.0", default-features = false, features = [
+ "base64",
+ "client",
+ "macros",
+ "schemars",
+ "server",
+ "transport-streamable-http-client-reqwest",
+] }
+sse-stream = "0.2.1"
+
# AI stream processor - local sub-crate
ai_stream_handlers = { path = "src/infrastructure/ai/ai_stream_handlers" }
diff --git a/src/crates/core/src/agentic/agents/cowork_mode.rs b/src/crates/core/src/agentic/agents/cowork_mode.rs
index bc95ccca..21709ebe 100644
--- a/src/crates/core/src/agentic/agents/cowork_mode.rs
+++ b/src/crates/core/src/agentic/agents/cowork_mode.rs
@@ -67,4 +67,3 @@ impl Agent for CoworkMode {
false
}
}
-
diff --git a/src/crates/core/src/agentic/agents/custom_subagents/custom_subagent_loader.rs b/src/crates/core/src/agentic/agents/custom_subagents/custom_subagent_loader.rs
index d3b44abd..0b2929df 100644
--- a/src/crates/core/src/agentic/agents/custom_subagents/custom_subagent_loader.rs
+++ b/src/crates/core/src/agentic/agents/custom_subagents/custom_subagent_loader.rs
@@ -1,6 +1,6 @@
-use log::{error};
use crate::agentic::agents::Agent;
use crate::infrastructure::get_path_manager_arc;
+use log::error;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
diff --git a/src/crates/core/src/agentic/agents/debug_mode.rs b/src/crates/core/src/agentic/agents/debug_mode.rs
index 8df8ce4d..963e319f 100644
--- a/src/crates/core/src/agentic/agents/debug_mode.rs
+++ b/src/crates/core/src/agentic/agents/debug_mode.rs
@@ -1,13 +1,13 @@
//! Debug Mode - Evidence-driven debugging mode
-use log::debug;
use super::prompt_builder::PromptBuilder;
use super::Agent;
-use async_trait::async_trait;
use crate::service::config::global::GlobalConfigManager;
use crate::service::config::types::{DebugModeConfig, LanguageDebugTemplate};
use crate::service::lsp::project_detector::{ProjectDetector, ProjectInfo};
use crate::util::errors::BitFunResult;
+use async_trait::async_trait;
+use log::debug;
use std::path::Path;
pub struct DebugMode;
@@ -70,7 +70,7 @@ impl DebugMode {
.get("javascript")
.map(|t| t.enabled && !t.instrumentation_template.trim().is_empty())
.unwrap_or(false);
-
+
if use_custom {
if let Some(template) = config.language_templates.get("javascript") {
output.push_str(&Self::render_template(template, config));
@@ -84,9 +84,9 @@ impl DebugMode {
let matched_user_templates: Vec<_> = user_other_templates
.iter()
.filter(|(lang, _)| {
- detected_languages.iter().any(|detected| {
- detected.to_lowercase() == lang.to_lowercase()
- })
+ detected_languages
+ .iter()
+ .any(|detected| detected.to_lowercase() == lang.to_lowercase())
})
.collect();
@@ -109,7 +109,7 @@ impl DebugMode {
output
}
-
+
fn render_builtin_js_template(config: &DebugModeConfig) -> String {
let mut section = "## JavaScript / TypeScript Instrumentation\n\n".to_string();
section.push_str("```javascript\n");
@@ -175,11 +175,7 @@ impl DebugMode {
}
/// Builds session-level configuration with dynamic values like server endpoint and log path.
- fn build_session_level_rule(
- &self,
- config: &DebugModeConfig,
- workspace_path: &str,
- ) -> String {
+ fn build_session_level_rule(&self, config: &DebugModeConfig, workspace_path: &str) -> String {
let log_path = if config.log_path.starts_with('/') || config.log_path.starts_with('.') {
config.log_path.clone()
} else {
@@ -290,12 +286,11 @@ impl Agent for DebugMode {
debug!(
"Debug mode project detection: languages={:?}, types={:?}",
- project_info.languages,
- project_info.project_types
+ project_info.languages, project_info.project_types
);
- let system_prompt_template =
- get_embedded_prompt("debug_mode").unwrap_or("Debug mode prompt not found in embedded files");
+ let system_prompt_template = get_embedded_prompt("debug_mode")
+ .unwrap_or("Debug mode prompt not found in embedded files");
let language_templates =
Self::build_language_templates_prompt(&debug_config, &project_info.languages);
diff --git a/src/crates/core/src/agentic/agents/mod.rs b/src/crates/core/src/agentic/agents/mod.rs
index b4e5bfe5..c75626ae 100644
--- a/src/crates/core/src/agentic/agents/mod.rs
+++ b/src/crates/core/src/agentic/agents/mod.rs
@@ -7,9 +7,9 @@ mod prompt_builder;
mod registry;
// Modes
mod agentic_mode;
+mod cowork_mode;
mod debug_mode;
mod plan_mode;
-mod cowork_mode;
// Built-in subagents
mod explore_agent;
mod file_finder_agent;
@@ -19,12 +19,12 @@ mod generate_doc_agent;
pub use agentic_mode::AgenticMode;
pub use code_review_agent::CodeReviewAgent;
+pub use cowork_mode::CoworkMode;
pub use debug_mode::DebugMode;
pub use explore_agent::ExploreAgent;
pub use file_finder_agent::FileFinderAgent;
pub use generate_doc_agent::GenerateDocAgent;
pub use plan_mode::PlanMode;
-pub use cowork_mode::CoworkMode;
use crate::util::errors::{BitFunError, BitFunResult};
use async_trait::async_trait;
diff --git a/src/crates/core/src/agentic/agents/prompt_builder/mod.rs b/src/crates/core/src/agentic/agents/prompt_builder/mod.rs
index 504c3421..b4c83123 100644
--- a/src/crates/core/src/agentic/agents/prompt_builder/mod.rs
+++ b/src/crates/core/src/agentic/agents/prompt_builder/mod.rs
@@ -1,3 +1,3 @@
mod prompt_builder;
-pub use prompt_builder::PromptBuilder;
\ No newline at end of file
+pub use prompt_builder::PromptBuilder;
diff --git a/src/crates/core/src/agentic/agents/prompts/cowork_mode.md b/src/crates/core/src/agentic/agents/prompts/cowork_mode.md
index 15311c7a..3013ff46 100644
--- a/src/crates/core/src/agentic/agents/prompts/cowork_mode.md
+++ b/src/crates/core/src/agentic/agents/prompts/cowork_mode.md
@@ -24,10 +24,10 @@ Use the Task tool to delegate independent, multi-step subtasks (especially: expl
- Refuse malicious code or instructions that enable abuse.
- Prefer evidence-driven answers; when unsure, investigate using available tools.
- Do not claim you did something unless you actually did it.
+- When WebFetch or WebSearch fails or reports that a domain cannot be fetched, do NOT attempt to retrieve the content through alternative means.
{ENV_INFO}
{PROJECT_LAYOUT}
{RULES}
{MEMORIES}
{PROJECT_CONTEXT_FILES:exclude=review}
-
diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs
index a8604dde..9879f2c9 100644
--- a/src/crates/core/src/agentic/coordination/coordinator.rs
+++ b/src/crates/core/src/agentic/coordination/coordinator.rs
@@ -544,7 +544,9 @@ impl ConversationCoordinator {
if let Some(token) = cancel_token {
if token.is_cancelled() {
debug!("Subagent task cancelled before execution");
- return Err(BitFunError::Cancelled("Subagent task has been cancelled".to_string()));
+ return Err(BitFunError::Cancelled(
+ "Subagent task has been cancelled".to_string(),
+ ));
}
}
@@ -562,7 +564,9 @@ impl ConversationCoordinator {
if token.is_cancelled() {
debug!("Subagent task cancelled before AI call, cleaning up resources");
let _ = self.cleanup_subagent_resources(&session.session_id).await;
- return Err(BitFunError::Cancelled("Subagent task has been cancelled".to_string()));
+ return Err(BitFunError::Cancelled(
+ "Subagent task has been cancelled".to_string(),
+ ));
}
}
diff --git a/src/crates/core/src/agentic/coordination/mod.rs b/src/crates/core/src/agentic/coordination/mod.rs
index 16297236..14475fe0 100644
--- a/src/crates/core/src/agentic/coordination/mod.rs
+++ b/src/crates/core/src/agentic/coordination/mod.rs
@@ -9,4 +9,3 @@ pub use coordinator::*;
pub use state_manager::*;
pub use coordinator::get_global_coordinator;
-
diff --git a/src/crates/core/src/agentic/core/mod.rs b/src/crates/core/src/agentic/core/mod.rs
index 1541ad90..90524d06 100644
--- a/src/crates/core/src/agentic/core/mod.rs
+++ b/src/crates/core/src/agentic/core/mod.rs
@@ -4,14 +4,14 @@
pub mod dialog_turn;
pub mod message;
+pub mod messages_helper;
pub mod model_round;
pub mod session;
pub mod state;
-pub mod messages_helper;
pub use dialog_turn::{DialogTurn, DialogTurnState, TurnStats};
pub use message::{Message, MessageContent, MessageRole, ToolCall, ToolResult};
-pub use model_round::ModelRound;
-pub use session::{Session, SessionConfig, SessionSummary, CompressionState};
pub use messages_helper::MessageHelper;
+pub use model_round::ModelRound;
+pub use session::{CompressionState, Session, SessionConfig, SessionSummary};
pub use state::{ProcessingPhase, SessionState, ToolExecutionState};
diff --git a/src/crates/core/src/agentic/core/session.rs b/src/crates/core/src/agentic/core/session.rs
index 1863a1e3..02db0fdc 100644
--- a/src/crates/core/src/agentic/core/session.rs
+++ b/src/crates/core/src/agentic/core/session.rs
@@ -13,7 +13,11 @@ pub struct Session {
pub agent_type: String,
/// Associated resources
- #[serde(skip_serializing_if = "Option::is_none", alias = "sandbox_session_id", alias = "sandboxSessionId")]
+ #[serde(
+ skip_serializing_if = "Option::is_none",
+ alias = "sandbox_session_id",
+ alias = "sandboxSessionId"
+ )]
pub snapshot_session_id: Option,
/// Dialog turn ID list
diff --git a/src/crates/core/src/agentic/events/mod.rs b/src/crates/core/src/agentic/events/mod.rs
index b55b6439..fdbde912 100644
--- a/src/crates/core/src/agentic/events/mod.rs
+++ b/src/crates/core/src/agentic/events/mod.rs
@@ -1,13 +1,11 @@
//! Event Layer
-//!
+//!
//! Provides event queue, routing and management functionality
-pub mod types;
pub mod queue;
pub mod router;
+pub mod types;
-pub use types::*;
pub use queue::*;
pub use router::*;
-
-
+pub use types::*;
diff --git a/src/crates/core/src/agentic/execution/mod.rs b/src/crates/core/src/agentic/execution/mod.rs
index 0f8a664d..af22b10f 100644
--- a/src/crates/core/src/agentic/execution/mod.rs
+++ b/src/crates/core/src/agentic/execution/mod.rs
@@ -1,14 +1,13 @@
//! Execution Engine Layer
-//!
+//!
//! Responsible for AI interaction and model round control
-pub mod types;
-pub mod stream_processor;
-pub mod round_executor;
pub mod execution_engine;
+pub mod round_executor;
+pub mod stream_processor;
+pub mod types;
pub use execution_engine::*;
pub use round_executor::*;
pub use stream_processor::*;
pub use types::{ExecutionContext, ExecutionResult, FinishReason, RoundContext, RoundResult};
-
diff --git a/src/crates/core/src/agentic/image_analysis/enhancer.rs b/src/crates/core/src/agentic/image_analysis/enhancer.rs
index 767fef3f..36b5915e 100644
--- a/src/crates/core/src/agentic/image_analysis/enhancer.rs
+++ b/src/crates/core/src/agentic/image_analysis/enhancer.rs
@@ -23,12 +23,16 @@ impl MessageEnhancer {
if !image_analyses.is_empty() {
enhanced.push_str("User uploaded ");
enhanced.push_str(&image_analyses.len().to_string());
- enhanced.push_str(" image(s). AI's understanding of the image content is as follows:\n\n");
+ enhanced
+ .push_str(" image(s). AI's understanding of the image content is as follows:\n\n");
for (idx, analysis) in image_analyses.iter().enumerate() {
enhanced.push_str(&format!("[Image {}]\n", idx + 1));
enhanced.push_str(&format!("• Summary: {}\n", analysis.summary));
- enhanced.push_str(&format!("• Detailed description: {}\n", analysis.detailed_description));
+ enhanced.push_str(&format!(
+ "• Detailed description: {}\n",
+ analysis.detailed_description
+ ));
if !analysis.detected_elements.is_empty() {
enhanced.push_str("• Key elements: ");
diff --git a/src/crates/core/src/agentic/image_analysis/mod.rs b/src/crates/core/src/agentic/image_analysis/mod.rs
index 2b02ebf4..38b93ae7 100644
--- a/src/crates/core/src/agentic/image_analysis/mod.rs
+++ b/src/crates/core/src/agentic/image_analysis/mod.rs
@@ -1,12 +1,11 @@
//! Image Analysis Module
-//!
+//!
//! Implements image pre-understanding functionality, converting image content to text descriptions
-pub mod types;
-pub mod processor;
pub mod enhancer;
+pub mod processor;
+pub mod types;
-pub use types::*;
-pub use processor::ImageAnalyzer;
pub use enhancer::MessageEnhancer;
-
+pub use processor::ImageAnalyzer;
+pub use types::*;
diff --git a/src/crates/core/src/agentic/image_analysis/processor.rs b/src/crates/core/src/agentic/image_analysis/processor.rs
index 145b0ae1..b3561ddd 100644
--- a/src/crates/core/src/agentic/image_analysis/processor.rs
+++ b/src/crates/core/src/agentic/image_analysis/processor.rs
@@ -70,7 +70,10 @@ impl ImageAnalyzer {
}
Err(e) => {
error!("Image analysis task failed: {:?}", e);
- return Err(BitFunError::service(format!("Image analysis task failed: {}", e)));
+ return Err(BitFunError::service(format!(
+ "Image analysis task failed: {}",
+ e
+ )));
}
}
}
@@ -174,7 +177,9 @@ impl ImageAnalyzer {
.map_err(|e| BitFunError::io(format!("Invalid workspace path: {}", e)))?;
if !canonical_path.starts_with(&canonical_workspace) {
- return Err(BitFunError::validation("Image path must be within workspace"));
+ return Err(BitFunError::validation(
+ "Image path must be within workspace",
+ ));
}
}
@@ -182,7 +187,9 @@ impl ImageAnalyzer {
.await
.map_err(|e| BitFunError::io(format!("Failed to read image: {}", e)))
} else {
- Err(BitFunError::validation("Image context missing path or data"))
+ Err(BitFunError::validation(
+ "Image context missing path or data",
+ ))
}
}
diff --git a/src/crates/core/src/agentic/persistence/manager.rs b/src/crates/core/src/agentic/persistence/manager.rs
index 57718554..2ca5f5b3 100644
--- a/src/crates/core/src/agentic/persistence/manager.rs
+++ b/src/crates/core/src/agentic/persistence/manager.rs
@@ -65,17 +65,20 @@ impl PersistenceManager {
) -> BitFunResult<()> {
let dir = self.ensure_session_dir(session_id).await?;
let snapshots_dir = dir.join("context_snapshots");
- fs::create_dir_all(&snapshots_dir)
- .await
- .map_err(|e| BitFunError::io(format!("Failed to create context_snapshots directory: {}", e)))?;
+ fs::create_dir_all(&snapshots_dir).await.map_err(|e| {
+ BitFunError::io(format!(
+ "Failed to create context_snapshots directory: {}",
+ e
+ ))
+ })?;
let snapshot_path = self.context_snapshot_path(session_id, turn_index);
let json = serde_json::to_string(messages).map_err(|e| {
BitFunError::serialization(format!("Failed to serialize turn context snapshot: {}", e))
})?;
- fs::write(&snapshot_path, json)
- .await
- .map_err(|e| BitFunError::io(format!("Failed to write turn context snapshot: {}", e)))?;
+ fs::write(&snapshot_path, json).await.map_err(|e| {
+ BitFunError::io(format!("Failed to write turn context snapshot: {}", e))
+ })?;
Ok(())
}
@@ -98,7 +101,10 @@ impl PersistenceManager {
.map_err(|e| BitFunError::io(format!("Failed to read turn context snapshot: {}", e)))?;
let messages: Vec = serde_json::from_str(&content).map_err(|e| {
- BitFunError::Deserialization(format!("Failed to deserialize turn context snapshot: {}", e))
+ BitFunError::Deserialization(format!(
+ "Failed to deserialize turn context snapshot: {}",
+ e
+ ))
})?;
Ok(Some(messages))
}
@@ -112,9 +118,9 @@ impl PersistenceManager {
return Ok(None);
}
- let mut rd = fs::read_dir(&dir)
- .await
- .map_err(|e| BitFunError::io(format!("Failed to read context_snapshots directory: {}", e)))?;
+ let mut rd = fs::read_dir(&dir).await.map_err(|e| {
+ BitFunError::io(format!("Failed to read context_snapshots directory: {}", e))
+ })?;
let mut latest: Option = None;
while let Some(entry) = rd
@@ -159,9 +165,9 @@ impl PersistenceManager {
return Ok(());
}
- let mut rd = fs::read_dir(&dir)
- .await
- .map_err(|e| BitFunError::io(format!("Failed to read context_snapshots directory: {}", e)))?;
+ let mut rd = fs::read_dir(&dir).await.map_err(|e| {
+ BitFunError::io(format!("Failed to read context_snapshots directory: {}", e))
+ })?;
while let Some(entry) = rd
.next_entry()
.await
@@ -195,8 +201,9 @@ impl PersistenceManager {
let dir = self.ensure_session_dir(&session.session_id).await?;
let metadata_path = dir.join("metadata.json");
- let json = serde_json::to_string_pretty(session)
- .map_err(|e| BitFunError::serialization(format!("Failed to serialize session: {}", e)))?;
+ let json = serde_json::to_string_pretty(session).map_err(|e| {
+ BitFunError::serialization(format!("Failed to serialize session: {}", e))
+ })?;
fs::write(&metadata_path, json)
.await
@@ -213,8 +220,9 @@ impl PersistenceManager {
.await
.map_err(|e| BitFunError::io(format!("Failed to read session metadata: {}", e)))?;
- let session: Session = serde_json::from_str(&json)
- .map_err(|e| BitFunError::Deserialization(format!("Failed to deserialize session: {}", e)))?;
+ let session: Session = serde_json::from_str(&json).map_err(|e| {
+ BitFunError::Deserialization(format!("Failed to deserialize session: {}", e))
+ })?;
Ok(session)
}
@@ -243,9 +251,9 @@ impl PersistenceManager {
let dir = self.get_session_dir(session_id);
if dir.exists() {
- fs::remove_dir_all(&dir)
- .await
- .map_err(|e| BitFunError::io(format!("Failed to delete session directory: {}", e)))?;
+ fs::remove_dir_all(&dir).await.map_err(|e| {
+ BitFunError::io(format!("Failed to delete session directory: {}", e))
+ })?;
}
info!("Session deleted: session_id={}", session_id);
@@ -312,8 +320,9 @@ impl PersistenceManager {
let dir = self.ensure_session_dir(session_id).await?;
let messages_path = dir.join("messages.jsonl");
- let json = serde_json::to_string(message)
- .map_err(|e| BitFunError::serialization(format!("Failed to serialize message: {}", e)))?;
+ let json = serde_json::to_string(message).map_err(|e| {
+ BitFunError::serialization(format!("Failed to serialize message: {}", e))
+ })?;
let mut file = fs::OpenOptions::new()
.create(true)
@@ -397,15 +406,18 @@ impl PersistenceManager {
let dir = self.ensure_session_dir(session_id).await?;
let compressed_path = dir.join("compressed_messages.jsonl");
- let json = serde_json::to_string(message)
- .map_err(|e| BitFunError::serialization(format!("Failed to serialize compressed message: {}", e)))?;
+ let json = serde_json::to_string(message).map_err(|e| {
+ BitFunError::serialization(format!("Failed to serialize compressed message: {}", e))
+ })?;
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&compressed_path)
.await
- .map_err(|e| BitFunError::io(format!("Failed to open compressed message file: {}", e)))?;
+ .map_err(|e| {
+ BitFunError::io(format!("Failed to open compressed message file: {}", e))
+ })?;
file.write_all(json.as_bytes())
.await
@@ -433,16 +445,19 @@ impl PersistenceManager {
.truncate(true)
.open(&compressed_path)
.await
- .map_err(|e| BitFunError::io(format!("Failed to open compressed message file: {}", e)))?;
+ .map_err(|e| {
+ BitFunError::io(format!("Failed to open compressed message file: {}", e))
+ })?;
// Write all messages
for message in messages {
- let json = serde_json::to_string(message)
- .map_err(|e| BitFunError::serialization(format!("Failed to serialize compressed message: {}", e)))?;
+ let json = serde_json::to_string(message).map_err(|e| {
+ BitFunError::serialization(format!("Failed to serialize compressed message: {}", e))
+ })?;
- file.write_all(json.as_bytes())
- .await
- .map_err(|e| BitFunError::io(format!("Failed to write compressed message: {}", e)))?;
+ file.write_all(json.as_bytes()).await.map_err(|e| {
+ BitFunError::io(format!("Failed to write compressed message: {}", e))
+ })?;
file.write_all(b"\n")
.await
.map_err(|e| BitFunError::io(format!("Failed to write newline: {}", e)))?;
@@ -470,19 +485,17 @@ impl PersistenceManager {
return Ok(None);
}
- let file = fs::File::open(&compressed_path)
- .await
- .map_err(|e| BitFunError::io(format!("Failed to open compressed message file: {}", e)))?;
+ let file = fs::File::open(&compressed_path).await.map_err(|e| {
+ BitFunError::io(format!("Failed to open compressed message file: {}", e))
+ })?;
let reader = BufReader::new(file);
let mut lines = reader.lines();
let mut messages = Vec::new();
- while let Some(line) = lines
- .next_line()
- .await
- .map_err(|e| BitFunError::io(format!("Failed to read compressed message line: {}", e)))?
- {
+ while let Some(line) = lines.next_line().await.map_err(|e| {
+ BitFunError::io(format!("Failed to read compressed message line: {}", e))
+ })? {
if line.trim().is_empty() {
continue;
}
@@ -514,9 +527,9 @@ impl PersistenceManager {
.join("compressed_messages.jsonl");
if compressed_path.exists() {
- fs::remove_file(&compressed_path)
- .await
- .map_err(|e| BitFunError::io(format!("Failed to delete compressed message file: {}", e)))?;
+ fs::remove_file(&compressed_path).await.map_err(|e| {
+ BitFunError::io(format!("Failed to delete compressed message file: {}", e))
+ })?;
debug!("Compressed history file deleted: session_id={}", session_id);
}
@@ -535,8 +548,9 @@ impl PersistenceManager {
let turn_path = turns_dir.join(format!("{}.json", turn.turn_id));
- let json = serde_json::to_string_pretty(turn)
- .map_err(|e| BitFunError::serialization(format!("Failed to serialize dialog turn: {}", e)))?;
+ let json = serde_json::to_string_pretty(turn).map_err(|e| {
+ BitFunError::serialization(format!("Failed to serialize dialog turn: {}", e))
+ })?;
fs::write(&turn_path, json)
.await
@@ -560,8 +574,9 @@ impl PersistenceManager {
.await
.map_err(|e| BitFunError::io(format!("Failed to read dialog turn: {}", e)))?;
- let turn: DialogTurn = serde_json::from_str(&json)
- .map_err(|e| BitFunError::Deserialization(format!("Failed to deserialize dialog turn: {}", e)))?;
+ let turn: DialogTurn = serde_json::from_str(&json).map_err(|e| {
+ BitFunError::Deserialization(format!("Failed to deserialize dialog turn: {}", e))
+ })?;
Ok(turn)
}
diff --git a/src/crates/core/src/agentic/persistence/mod.rs b/src/crates/core/src/agentic/persistence/mod.rs
index b6ac4cec..e60f7a01 100644
--- a/src/crates/core/src/agentic/persistence/mod.rs
+++ b/src/crates/core/src/agentic/persistence/mod.rs
@@ -1,9 +1,7 @@
//! Persistence layer
-//!
+//!
//! Responsible for persistent storage and loading of data
pub mod manager;
pub use manager::PersistenceManager;
-
-
diff --git a/src/crates/core/src/agentic/session/history_manager.rs b/src/crates/core/src/agentic/session/history_manager.rs
index 64cd9f0d..d69917f2 100644
--- a/src/crates/core/src/agentic/session/history_manager.rs
+++ b/src/crates/core/src/agentic/session/history_manager.rs
@@ -1,12 +1,12 @@
//! Message History Manager
-//!
+//!
//! Manages session message history, supports memory caching and persistence
-use log::debug;
use crate::agentic::core::Message;
use crate::agentic::persistence::PersistenceManager;
use crate::util::errors::BitFunResult;
use dashmap::DashMap;
+use log::debug;
use std::sync::Arc;
/// Message history configuration
@@ -27,10 +27,10 @@ impl Default for HistoryConfig {
pub struct MessageHistoryManager {
/// Message history in memory (by session ID)
histories: Arc>>,
-
+
/// Persistence manager
persistence: Arc,
-
+
/// Configuration
config: HistoryConfig,
}
@@ -43,14 +43,14 @@ impl MessageHistoryManager {
config,
}
}
-
+
/// Create session history
pub async fn create_session(&self, session_id: &str) -> BitFunResult<()> {
self.histories.insert(session_id.to_string(), vec![]);
debug!("Created session history: session_id={}", session_id);
Ok(())
}
-
+
/// Add message
pub async fn add_message(&self, session_id: &str, message: Message) -> BitFunResult<()> {
// 1. Add to memory
@@ -58,53 +58,62 @@ impl MessageHistoryManager {
messages.push(message.clone());
} else {
// Session doesn't exist, create and add
- self.histories.insert(session_id.to_string(), vec![message.clone()]);
+ self.histories
+ .insert(session_id.to_string(), vec![message.clone()]);
}
-
+
// 2. Persist
if self.config.enable_persistence {
- self.persistence.append_message(session_id, &message).await?;
+ self.persistence
+ .append_message(session_id, &message)
+ .await?;
}
-
+
Ok(())
}
-
+
/// Get message history
pub async fn get_messages(&self, session_id: &str) -> BitFunResult> {
// First try to get from memory
if let Some(messages) = self.histories.get(session_id) {
return Ok(messages.clone());
}
-
+
// Load from persistence
if self.config.enable_persistence {
let messages = self.persistence.load_messages(session_id).await?;
-
+
// Cache to memory
if !messages.is_empty() {
- self.histories.insert(session_id.to_string(), messages.clone());
+ self.histories
+ .insert(session_id.to_string(), messages.clone());
}
-
+
Ok(messages)
} else {
Ok(vec![])
}
}
-
+
/// Get recent N messages
- pub async fn get_recent_messages(&self, session_id: &str, count: usize) -> BitFunResult> {
+ pub async fn get_recent_messages(
+ &self,
+ session_id: &str,
+ count: usize,
+ ) -> BitFunResult> {
let messages = self.get_messages(session_id).await?;
let start = messages.len().saturating_sub(count);
Ok(messages[start..].to_vec())
}
-
+
/// Get message count
pub async fn count_messages(&self, session_id: &str) -> usize {
if let Some(messages) = self.histories.get(session_id) {
messages.len()
} else if self.config.enable_persistence {
// Load from persistence
- self.persistence.load_messages(session_id)
+ self.persistence
+ .load_messages(session_id)
.await
.map(|msgs| msgs.len())
.unwrap_or(0)
@@ -112,43 +121,45 @@ impl MessageHistoryManager {
0
}
}
-
+
/// Clear message history
pub async fn clear_messages(&self, session_id: &str) -> BitFunResult<()> {
// Clear memory
if let Some(mut messages) = self.histories.get_mut(session_id) {
messages.clear();
}
-
+
// Clear persistence
if self.config.enable_persistence {
self.persistence.clear_messages(session_id).await?;
}
-
+
debug!("Cleared session message history: session_id={}", session_id);
Ok(())
}
-
+
/// Delete session
pub async fn delete_session(&self, session_id: &str) -> BitFunResult<()> {
// Remove from memory
self.histories.remove(session_id);
-
+
// Delete from persistence
if self.config.enable_persistence {
self.persistence.delete_messages(session_id).await?;
}
-
+
debug!("Deleted session history: session_id={}", session_id);
Ok(())
}
-
+
/// Restore session (load from persistence)
- pub async fn restore_session(&self, session_id: &str, messages: Vec) -> BitFunResult<()> {
+ pub async fn restore_session(
+ &self,
+ session_id: &str,
+ messages: Vec,
+ ) -> BitFunResult<()> {
self.histories.insert(session_id.to_string(), messages);
debug!("Restored session history: session_id={}", session_id);
Ok(())
}
}
-
-
diff --git a/src/crates/core/src/agentic/session/mod.rs b/src/crates/core/src/agentic/session/mod.rs
index 71c19c9d..baac1fed 100644
--- a/src/crates/core/src/agentic/session/mod.rs
+++ b/src/crates/core/src/agentic/session/mod.rs
@@ -1,13 +1,11 @@
//! Session Management Layer
-//!
+//!
//! Provides session lifecycle management, message history, and context management
-pub mod session_manager;
-pub mod history_manager;
pub mod compression_manager;
+pub mod history_manager;
+pub mod session_manager;
-pub use session_manager::*;
-pub use history_manager::*;
pub use compression_manager::*;
-
-
+pub use history_manager::*;
+pub use session_manager::*;
diff --git a/src/crates/core/src/agentic/tools/image_context.rs b/src/crates/core/src/agentic/tools/image_context.rs
index 933c90b5..5320076f 100644
--- a/src/crates/core/src/agentic/tools/image_context.rs
+++ b/src/crates/core/src/agentic/tools/image_context.rs
@@ -1,5 +1,5 @@
//! Image context provider trait
-//!
+//!
//! Through dependency injection mode, tools can access image context without directly depending on specific implementations
use serde::{Deserialize, Serialize};
@@ -20,12 +20,12 @@ pub struct ImageContextData {
}
/// Image context provider trait
-///
+///
/// Types that implement this trait can provide image data access capabilities to tools
pub trait ImageContextProvider: Send + Sync + std::fmt::Debug {
/// Get image context data by image_id
fn get_image(&self, image_id: &str) -> Option;
-
+
/// Optional: delete image context (clean up after use)
fn remove_image(&self, image_id: &str) {
// Default implementation: do nothing
@@ -35,4 +35,3 @@ pub trait ImageContextProvider: Send + Sync + std::fmt::Debug {
/// Optional wrapper type, for convenience
pub type ImageContextProviderRef = Arc;
-
diff --git a/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs b/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs
index e2541e4b..de6e084a 100644
--- a/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs
+++ b/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs
@@ -105,7 +105,8 @@ impl AskUserQuestionTool {
fn format_result_for_assistant(questions: &[Question], answers: &Value) -> String {
// Try flat structure first (frontend sends {"0": "...", "1": [...]}),
// then fall back to nested {"answers": {...}} for backward compatibility
- let answers_obj = answers.as_object()
+ let answers_obj = answers
+ .as_object()
.or_else(|| answers.get("answers").and_then(|v| v.as_object()));
if let Some(answers_map) = answers_obj {
diff --git a/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs b/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs
index e0df0761..55201e7f 100644
--- a/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs
+++ b/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs
@@ -1,13 +1,15 @@
-use log::debug;
+use crate::agentic::tools::framework::{
+ Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult,
+};
+use crate::util::errors::{BitFunError, BitFunResult};
use async_trait::async_trait;
+use log::debug;
use serde_json::{json, Value};
use std::path::Path;
use tokio::fs;
-use crate::agentic::tools::framework::{Tool, ToolUseContext, ToolResult, ValidationResult, ToolRenderOptions};
-use crate::util::errors::{BitFunError, BitFunResult};
/// File deletion tool - provides safe file/directory deletion functionality
-///
+///
/// This tool automatically integrates with the snapshot system, all deletion operations are recorded and support rollback
pub struct DeleteFileTool;
@@ -22,7 +24,7 @@ impl Tool for DeleteFileTool {
fn name(&self) -> &str {
"Delete"
}
-
+
async fn description(&self) -> BitFunResult {
Ok(r#"Deletes a file or directory from the filesystem. This operation is tracked by the snapshot system and can be rolled back if needed.
@@ -73,7 +75,7 @@ Important notes:
- All deletions can be rolled back through the snapshot interface
- The tool will fail gracefully if permissions are insufficient"#.to_string())
}
-
+
fn input_schema(&self) -> Value {
json!({
"type": "object",
@@ -90,20 +92,24 @@ Important notes:
"required": ["path"]
})
}
-
+
fn is_readonly(&self) -> bool {
false
}
-
+
fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool {
false
}
-
+
fn needs_permissions(&self, _input: Option<&Value>) -> bool {
true
}
-
- async fn validate_input(&self, input: &Value, _context: Option<&ToolUseContext>) -> ValidationResult {
+
+ async fn validate_input(
+ &self,
+ input: &Value,
+ _context: Option<&ToolUseContext>,
+ ) -> ValidationResult {
// Validate path parameter
let path_str = match input.get("path").and_then(|v| v.as_str()) {
Some(p) => p,
@@ -116,7 +122,7 @@ Important notes:
};
}
};
-
+
if path_str.is_empty() {
return ValidationResult {
result: false,
@@ -125,9 +131,9 @@ Important notes:
meta: None,
};
}
-
+
let path = Path::new(path_str);
-
+
// Validate if path is absolute
if !path.is_absolute() {
return ValidationResult {
@@ -137,7 +143,7 @@ Important notes:
meta: None,
};
}
-
+
// Validate if path exists
if !path.exists() {
return ValidationResult {
@@ -147,19 +153,20 @@ Important notes:
meta: None,
};
}
-
+
// If directory, check if recursive deletion is needed
if path.is_dir() {
- let recursive = input.get("recursive")
+ let recursive = input
+ .get("recursive")
.and_then(|v| v.as_bool())
.unwrap_or(false);
-
+
// Check if directory is empty
let is_empty = match fs::read_dir(path).await {
Ok(mut entries) => entries.next_entry().await.ok().flatten().is_none(),
Err(_) => false,
};
-
+
if !is_empty && !recursive {
return ValidationResult {
result: false,
@@ -173,7 +180,7 @@ Important notes:
};
}
}
-
+
ValidationResult {
result: true,
message: None,
@@ -181,13 +188,14 @@ Important notes:
meta: None,
}
}
-
+
fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String {
if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
- let recursive = input.get("recursive")
+ let recursive = input
+ .get("recursive")
.and_then(|v| v.as_bool())
.unwrap_or(false);
-
+
if recursive {
format!("Deleting directory and contents: {}", path)
} else {
@@ -197,49 +205,63 @@ Important notes:
"Deleting file or directory".to_string()
}
}
-
+
fn render_result_for_assistant(&self, output: &Value) -> String {
if let Some(path) = output.get("path").and_then(|v| v.as_str()) {
- let is_directory = output.get("is_directory")
+ let is_directory = output
+ .get("is_directory")
.and_then(|v| v.as_bool())
.unwrap_or(false);
-
+
let type_name = if is_directory { "directory" } else { "file" };
-
+
format!("Successfully deleted {} at: {}", type_name, path)
} else {
"Deletion completed".to_string()
}
}
-
- async fn call_impl(&self, input: &Value, _context: &ToolUseContext) -> BitFunResult> {
- let path_str = input.get("path")
+
+ async fn call_impl(
+ &self,
+ input: &Value,
+ _context: &ToolUseContext,
+ ) -> BitFunResult> {
+ let path_str = input
+ .get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| BitFunError::tool("path is required".to_string()))?;
-
- let recursive = input.get("recursive")
+
+ let recursive = input
+ .get("recursive")
.and_then(|v| v.as_bool())
.unwrap_or(false);
-
+
let path = Path::new(path_str);
let is_directory = path.is_dir();
-
- debug!("DeleteFile tool deleting {}: {}", if is_directory { "directory" } else { "file" }, path_str);
-
+
+ debug!(
+ "DeleteFile tool deleting {}: {}",
+ if is_directory { "directory" } else { "file" },
+ path_str
+ );
+
// Execute deletion operation
if is_directory {
if recursive {
- fs::remove_dir_all(path).await
+ fs::remove_dir_all(path)
+ .await
.map_err(|e| BitFunError::tool(format!("Failed to delete directory: {}", e)))?;
} else {
- fs::remove_dir(path).await
+ fs::remove_dir(path)
+ .await
.map_err(|e| BitFunError::tool(format!("Failed to delete directory: {}", e)))?;
}
} else {
- fs::remove_file(path).await
+ fs::remove_file(path)
+ .await
.map_err(|e| BitFunError::tool(format!("Failed to delete file: {}", e)))?;
}
-
+
// Build result
let result_data = json!({
"success": true,
@@ -247,9 +269,9 @@ Important notes:
"is_directory": is_directory,
"recursive": recursive
});
-
+
let result_text = self.render_result_for_assistant(&result_data);
-
+
Ok(vec![ToolResult::Result {
data: result_data,
result_for_assistant: Some(result_text),
diff --git a/src/crates/core/src/agentic/tools/implementations/ide_control_tool.rs b/src/crates/core/src/agentic/tools/implementations/ide_control_tool.rs
index 6fd779b6..3e9b1da9 100644
--- a/src/crates/core/src/agentic/tools/implementations/ide_control_tool.rs
+++ b/src/crates/core/src/agentic/tools/implementations/ide_control_tool.rs
@@ -58,11 +58,17 @@ impl IdeControlTool {
/// Validate if panel type is valid
fn is_valid_panel_type(&self, panel_type: &str) -> bool {
- matches!(panel_type,
- "git-settings" | "git-diff" |
- "config-center" | "planner" |
- "files" | "code-editor" | "markdown-editor" |
- "ai-session" | "mermaid-editor"
+ matches!(
+ panel_type,
+ "git-settings"
+ | "git-diff"
+ | "config-center"
+ | "planner"
+ | "files"
+ | "code-editor"
+ | "markdown-editor"
+ | "ai-session"
+ | "mermaid-editor"
)
}
diff --git a/src/crates/core/src/agentic/tools/implementations/mermaid_interactive_tool.rs b/src/crates/core/src/agentic/tools/implementations/mermaid_interactive_tool.rs
index 2b3c511b..f6b76a49 100644
--- a/src/crates/core/src/agentic/tools/implementations/mermaid_interactive_tool.rs
+++ b/src/crates/core/src/agentic/tools/implementations/mermaid_interactive_tool.rs
@@ -1,14 +1,14 @@
//! Mermaid interactive diagram tool
-//!
+//!
//! Allows Agent to generate Mermaid diagrams with interactive features, supports node click navigation and highlight states
-use log::debug;
-use crate::agentic::tools::framework::{Tool, ToolUseContext, ToolResult, ValidationResult};
+use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext, ValidationResult};
+use crate::infrastructure::events::event_system::{get_global_event_system, BackendEvent};
use crate::util::errors::BitFunResult;
-use crate::infrastructure::events::event_system::{BackendEvent, get_global_event_system};
-use serde_json::{json, Value};
use async_trait::async_trait;
use chrono::Utc;
+use log::debug;
+use serde_json::{json, Value};
/// Mermaid interactive diagram tool
pub struct MermaidInteractiveTool;
@@ -21,21 +21,34 @@ impl MermaidInteractiveTool {
/// Validate if Mermaid code is valid, returns validation result and error message
fn validate_mermaid_code(&self, code: &str) -> (bool, Option) {
let trimmed = code.trim();
-
+
// Check if empty
if trimmed.is_empty() {
return (false, Some("Mermaid code cannot be empty".to_string()));
}
-
+
// Check if starts with valid diagram type
let valid_starters = vec![
- "graph ", "flowchart ", "sequenceDiagram", "classDiagram",
- "stateDiagram", "erDiagram", "gantt", "pie", "journey",
- "timeline", "mindmap", "gitgraph", "C4Context", "C4Container"
+ "graph ",
+ "flowchart ",
+ "sequenceDiagram",
+ "classDiagram",
+ "stateDiagram",
+ "erDiagram",
+ "gantt",
+ "pie",
+ "journey",
+ "timeline",
+ "mindmap",
+ "gitgraph",
+ "C4Context",
+ "C4Container",
];
-
- let starts_with_valid = valid_starters.iter().any(|starter| trimmed.starts_with(starter));
-
+
+ let starts_with_valid = valid_starters
+ .iter()
+ .any(|starter| trimmed.starts_with(starter));
+
if !starts_with_valid {
return (false, Some(format!(
"Mermaid code must start with a valid diagram type. Supported diagram types: graph, flowchart, sequenceDiagram, classDiagram, stateDiagram, erDiagram, gantt, pie, journey, timeline, mindmap, etc.\nCurrent code start: {}",
@@ -46,45 +59,50 @@ impl MermaidInteractiveTool {
}
)));
}
-
+
// Check basic syntax structure
let lines: Vec<&str> = trimmed.lines().collect();
if lines.len() < 2 {
return (false, Some("Mermaid code needs at least 2 lines (diagram type declaration and at least one node/relationship)".to_string()));
}
-
+
// Check if graph/flowchart has node definitions
if trimmed.starts_with("graph ") || trimmed.starts_with("flowchart ") {
// Check if there are arrows or node definitions
- let has_arrow = trimmed.contains("-->") || trimmed.contains("---") || trimmed.contains("==>");
+ let has_arrow =
+ trimmed.contains("-->") || trimmed.contains("---") || trimmed.contains("==>");
let has_node = trimmed.contains('[') || trimmed.contains('(') || trimmed.contains('{');
-
+
if !has_arrow && !has_node {
return (false, Some("Flowchart (graph/flowchart) must contain node definitions and connections. Example: A[Node] --> B[Node]".to_string()));
}
}
-
+
// Check if sequenceDiagram has participants
if trimmed.starts_with("sequenceDiagram") {
- if !trimmed.contains("participant") && !trimmed.contains("->>") && !trimmed.contains("-->>") {
+ if !trimmed.contains("participant")
+ && !trimmed.contains("->>")
+ && !trimmed.contains("-->>")
+ {
return (false, Some("Sequence diagram (sequenceDiagram) must contain participant definitions and interaction arrows. Example: participant A\nA->>B: Message".to_string()));
}
}
-
+
// Check if classDiagram has class definitions
if trimmed.starts_with("classDiagram") {
- if !trimmed.contains("class ") && !trimmed.contains("<|--") && !trimmed.contains("..>") {
+ if !trimmed.contains("class ") && !trimmed.contains("<|--") && !trimmed.contains("..>")
+ {
return (false, Some("Class diagram (classDiagram) must contain class definitions and relationships. Example: class A\nclass B\nA <|-- B".to_string()));
}
}
-
+
// Check if stateDiagram has state definitions
if trimmed.starts_with("stateDiagram") {
if !trimmed.contains("state ") && !trimmed.contains("[*]") && !trimmed.contains("-->") {
return (false, Some("State diagram (stateDiagram) must contain state definitions and transitions. Example: state A\n[*] --> A".to_string()));
}
}
-
+
// Check for unclosed brackets
let open_brackets = trimmed.matches('[').count();
let close_brackets = trimmed.matches(']').count();
@@ -94,7 +112,7 @@ impl MermaidInteractiveTool {
open_brackets, close_brackets
)));
}
-
+
let open_parens = trimmed.matches('(').count();
let close_parens = trimmed.matches(')').count();
if open_parens != close_parens {
@@ -103,7 +121,7 @@ impl MermaidInteractiveTool {
open_parens, close_parens
)));
}
-
+
let open_braces = trimmed.matches('{').count();
let close_braces = trimmed.matches('}').count();
if open_braces != close_braces {
@@ -112,16 +130,19 @@ impl MermaidInteractiveTool {
open_braces, close_braces
)));
}
-
+
// Check for obvious syntax errors (like isolated arrows)
- let lines_with_arrows: Vec<&str> = lines.iter()
+ let lines_with_arrows: Vec<&str> = lines
+ .iter()
.filter(|line| {
let trimmed_line = line.trim();
- trimmed_line.contains("-->") || trimmed_line.contains("---") || trimmed_line.contains("==>")
+ trimmed_line.contains("-->")
+ || trimmed_line.contains("---")
+ || trimmed_line.contains("==>")
})
.copied()
.collect();
-
+
for line in &lines_with_arrows {
let trimmed_line = line.trim();
// Check if there are node identifiers before and after arrows
@@ -139,7 +160,7 @@ impl MermaidInteractiveTool {
}
}
}
-
+
(true, None)
}
@@ -161,26 +182,29 @@ impl MermaidInteractiveTool {
}
// Check required field: file_path is required
- let has_file_path = node_data.get("file_path")
+ let has_file_path = node_data
+ .get("file_path")
.and_then(|v| v.as_str())
.map(|s| !s.is_empty())
.unwrap_or(false);
-
+
if !has_file_path {
return false;
}
// Get node type (defaults to file)
- let node_type = node_data.get("node_type")
+ let node_type = node_data
+ .get("node_type")
.and_then(|v| v.as_str())
.unwrap_or("file");
// For file type, line_number is required
if node_type == "file" {
- let has_line_number = node_data.get("line_number")
+ let has_line_number = node_data
+ .get("line_number")
.and_then(|v| v.as_u64())
.is_some();
-
+
if !has_line_number {
return false;
}
@@ -397,7 +421,11 @@ Mermaid Syntax:
false
}
- async fn validate_input(&self, input: &Value, _context: Option<&ToolUseContext>) -> ValidationResult {
+ async fn validate_input(
+ &self,
+ input: &Value,
+ _context: Option<&ToolUseContext>,
+ ) -> ValidationResult {
// Validate mermaid_code
let mermaid_code = match input.get("mermaid_code").and_then(|v| v.as_str()) {
Some(code) if !code.trim().is_empty() => code,
@@ -418,7 +446,8 @@ Mermaid Syntax:
// Validate Mermaid code format (returns detailed error message)
let (is_valid, error_msg) = self.validate_mermaid_code(mermaid_code);
if !is_valid {
- let error_message = error_msg.unwrap_or_else(|| "Invalid Mermaid diagram syntax".to_string());
+ let error_message =
+ error_msg.unwrap_or_else(|| "Invalid Mermaid diagram syntax".to_string());
return ValidationResult {
result: false,
message: Some(format!(
@@ -458,16 +487,19 @@ Mermaid Syntax:
fn render_result_for_assistant(&self, output: &Value) -> String {
if let Some(success) = output.get("success").and_then(|v| v.as_bool()) {
if success {
- let title = output.get("title")
+ let title = output
+ .get("title")
.and_then(|v| v.as_str())
.unwrap_or("Mermaid diagram");
-
- let node_count = output.get("metadata")
+
+ let node_count = output
+ .get("metadata")
.and_then(|m| m.get("node_count"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
- let interactive_nodes = output.get("metadata")
+ let interactive_nodes = output
+ .get("metadata")
.and_then(|m| m.get("interactive_nodes"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
@@ -485,7 +517,7 @@ Mermaid Syntax:
}
}
}
-
+
if let Some(error) = output.get("error").and_then(|v| v.as_str()) {
return format!("Failed to create Mermaid diagram: {}", error);
}
@@ -493,15 +525,22 @@ Mermaid Syntax:
"Mermaid diagram creation result unknown".to_string()
}
- fn render_tool_use_message(&self, input: &Value, _options: &crate::agentic::tools::framework::ToolRenderOptions) -> String {
- let title = input.get("title")
+ fn render_tool_use_message(
+ &self,
+ input: &Value,
+ _options: &crate::agentic::tools::framework::ToolRenderOptions,
+ ) -> String {
+ let title = input
+ .get("title")
.and_then(|v| v.as_str())
.unwrap_or("Interactive Mermaid Diagram");
- let has_metadata = input.get("node_metadata")
+ let has_metadata = input
+ .get("node_metadata")
.and_then(|v| v.as_object())
.map(|obj| obj.len())
- .unwrap_or(0) > 0;
+ .unwrap_or(0)
+ > 0;
if has_metadata {
format!("Creating interactive diagram: {}", title)
@@ -510,11 +549,16 @@ Mermaid Syntax:
}
}
- async fn call_impl(&self, input: &Value, context: &ToolUseContext) -> BitFunResult> {
- let mermaid_code = input.get("mermaid_code")
+ async fn call_impl(
+ &self,
+ input: &Value,
+ context: &ToolUseContext,
+ ) -> BitFunResult> {
+ let mermaid_code = input
+ .get("mermaid_code")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing mermaid_code field"))?;
-
+
// Validate Mermaid code
let (is_valid, error_msg) = self.validate_mermaid_code(mermaid_code);
if !is_valid {
@@ -538,15 +582,19 @@ Mermaid Syntax:
}]);
}
- let title = input.get("title")
+ let title = input
+ .get("title")
.and_then(|v| v.as_str())
.unwrap_or("Interactive Mermaid Diagram");
- let mode = input.get("mode")
+ let mode = input
+ .get("mode")
.and_then(|v| v.as_str())
.unwrap_or("interactive");
- let session_id = context.session_id.clone()
+ let session_id = context
+ .session_id
+ .clone()
.unwrap_or_else(|| format!("mermaid-{}", Utc::now().timestamp_millis()));
// Build interactive configuration
@@ -566,17 +614,19 @@ Mermaid Syntax:
}
// Calculate statistics
- let node_count = mermaid_code.lines()
+ let node_count = mermaid_code
+ .lines()
.filter(|line| {
let trimmed = line.trim();
- !trimmed.is_empty() &&
- !trimmed.starts_with("%%") &&
- !trimmed.starts_with("style") &&
- !trimmed.starts_with("classDef")
+ !trimmed.is_empty()
+ && !trimmed.starts_with("%%")
+ && !trimmed.starts_with("style")
+ && !trimmed.starts_with("classDef")
})
.count();
- let interactive_nodes = input.get("node_metadata")
+ let interactive_nodes = input
+ .get("node_metadata")
.and_then(|v| v.as_object())
.map(|obj| obj.len())
.unwrap_or(0);
@@ -614,7 +664,7 @@ Mermaid Syntax:
"timestamp": Utc::now().timestamp_millis(),
"session_id": session_id.clone()
}
- })
+ }),
};
debug!("MermaidInteractive tool creating diagram, mode: {}, title: {}, node_count: {}, interactive_nodes: {}",
diff --git a/src/crates/core/src/agentic/tools/implementations/mod.rs b/src/crates/core/src/agentic/tools/implementations/mod.rs
index edab188d..714da846 100644
--- a/src/crates/core/src/agentic/tools/implementations/mod.rs
+++ b/src/crates/core/src/agentic/tools/implementations/mod.rs
@@ -1,49 +1,49 @@
//! Tool implementation module
+pub mod analyze_image_tool;
+pub mod ask_user_question_tool;
+pub mod bash_tool;
+pub mod code_review_tool;
+pub mod create_plan_tool;
+pub mod delete_file_tool;
+pub mod file_edit_tool;
pub mod file_read_tool;
pub mod file_write_tool;
-pub mod file_edit_tool;
-pub mod delete_file_tool;
-pub mod bash_tool;
-pub mod grep_tool;
+pub mod get_file_diff_tool;
+pub mod git_tool;
pub mod glob_tool;
-pub mod web_tools;
-pub mod todo_write_tool;
+pub mod grep_tool;
pub mod ide_control_tool;
-pub mod mermaid_interactive_tool;
-pub mod log_tool;
pub mod linter_tool;
-pub mod analyze_image_tool;
+pub mod log_tool;
+pub mod ls_tool;
+pub mod mermaid_interactive_tool;
pub mod skill_tool;
pub mod skills;
-pub mod ask_user_question_tool;
-pub mod ls_tool;
pub mod task_tool;
-pub mod git_tool;
-pub mod create_plan_tool;
-pub mod get_file_diff_tool;
-pub mod code_review_tool;
+pub mod todo_write_tool;
pub mod util;
+pub mod web_tools;
+pub use analyze_image_tool::AnalyzeImageTool;
+pub use ask_user_question_tool::AskUserQuestionTool;
+pub use bash_tool::BashTool;
+pub use code_review_tool::CodeReviewTool;
+pub use create_plan_tool::CreatePlanTool;
+pub use delete_file_tool::DeleteFileTool;
+pub use file_edit_tool::FileEditTool;
pub use file_read_tool::FileReadTool;
pub use file_write_tool::FileWriteTool;
-pub use file_edit_tool::FileEditTool;
-pub use delete_file_tool::DeleteFileTool;
-pub use bash_tool::BashTool;
-pub use grep_tool::GrepTool;
+pub use get_file_diff_tool::GetFileDiffTool;
+pub use git_tool::GitTool;
pub use glob_tool::GlobTool;
-pub use web_tools::{WebSearchTool, WebFetchTool};
-pub use todo_write_tool::TodoWriteTool;
+pub use grep_tool::GrepTool;
pub use ide_control_tool::IdeControlTool;
-pub use mermaid_interactive_tool::MermaidInteractiveTool;
-pub use log_tool::LogTool;
pub use linter_tool::ReadLintsTool;
-pub use analyze_image_tool::AnalyzeImageTool;
-pub use skill_tool::SkillTool;
-pub use ask_user_question_tool::AskUserQuestionTool;
+pub use log_tool::LogTool;
pub use ls_tool::LSTool;
+pub use mermaid_interactive_tool::MermaidInteractiveTool;
+pub use skill_tool::SkillTool;
pub use task_tool::TaskTool;
-pub use git_tool::GitTool;
-pub use create_plan_tool::CreatePlanTool;
-pub use get_file_diff_tool::GetFileDiffTool;
-pub use code_review_tool::CodeReviewTool;
\ No newline at end of file
+pub use todo_write_tool::TodoWriteTool;
+pub use web_tools::{WebFetchTool, WebSearchTool};
diff --git a/src/crates/core/src/agentic/tools/implementations/skills/mod.rs b/src/crates/core/src/agentic/tools/implementations/skills/mod.rs
index 2e2c1ad4..69e9268a 100644
--- a/src/crates/core/src/agentic/tools/implementations/skills/mod.rs
+++ b/src/crates/core/src/agentic/tools/implementations/skills/mod.rs
@@ -2,9 +2,9 @@
//!
//! Provides Skill registry, loading, and configuration management functionality
+pub mod builtin;
pub mod registry;
pub mod types;
-pub mod builtin;
pub use registry::SkillRegistry;
pub use types::{SkillData, SkillInfo, SkillLocation};
diff --git a/src/crates/core/src/agentic/tools/implementations/skills/registry.rs b/src/crates/core/src/agentic/tools/implementations/skills/registry.rs
index 00823fde..40fd6bf8 100644
--- a/src/crates/core/src/agentic/tools/implementations/skills/registry.rs
+++ b/src/crates/core/src/agentic/tools/implementations/skills/registry.rs
@@ -4,8 +4,8 @@
//! Supports multiple application paths:
//! .bitfun/skills, .claude/skills, .cursor/skills, .codex/skills
-use super::types::{SkillData, SkillInfo, SkillLocation};
use super::builtin::ensure_builtin_skills_installed;
+use super::types::{SkillData, SkillInfo, SkillLocation};
use crate::infrastructure::{get_path_manager_arc, get_workspace_path};
use crate::util::errors::{BitFunError, BitFunResult};
use log::{debug, error};
diff --git a/src/crates/core/src/agentic/tools/implementations/skills/types.rs b/src/crates/core/src/agentic/tools/implementations/skills/types.rs
index 88794252..3d839744 100644
--- a/src/crates/core/src/agentic/tools/implementations/skills/types.rs
+++ b/src/crates/core/src/agentic/tools/implementations/skills/types.rs
@@ -88,13 +88,17 @@ impl SkillData {
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
- .ok_or_else(|| BitFunError::tool("Missing required field 'name' in SKILL.md".to_string()))?;
+ .ok_or_else(|| {
+ BitFunError::tool("Missing required field 'name' in SKILL.md".to_string())
+ })?;
let description = metadata
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
- .ok_or_else(|| BitFunError::tool("Missing required field 'description' in SKILL.md".to_string()))?;
+ .ok_or_else(|| {
+ BitFunError::tool("Missing required field 'description' in SKILL.md".to_string())
+ })?;
// enabled field defaults to true if not present
let enabled = metadata
@@ -102,11 +106,7 @@ impl SkillData {
.and_then(|v| v.as_bool())
.unwrap_or(true);
- let skill_content = if with_content {
- body
- } else {
- String::new()
- };
+ let skill_content = if with_content { body } else { String::new() };
Ok(SkillData {
name,
@@ -119,7 +119,7 @@ impl SkillData {
}
/// Set enabled status and save to SKILL.md file
- ///
+ ///
/// If enabled is true, remove enabled field (use default value)
/// If enabled is false, write enabled: false
pub fn set_enabled_and_save(skill_md_path: &str, enabled: bool) -> BitFunResult<()> {
@@ -127,19 +127,16 @@ impl SkillData {
.map_err(|e| BitFunError::tool(format!("Failed to load SKILL.md: {}", e)))?;
// Get mutable mapping of metadata
- let map = metadata
- .as_mapping_mut()
- .ok_or_else(|| BitFunError::tool("Invalid SKILL.md: metadata is not a mapping".to_string()))?;
+ let map = metadata.as_mapping_mut().ok_or_else(|| {
+ BitFunError::tool("Invalid SKILL.md: metadata is not a mapping".to_string())
+ })?;
if enabled {
// When enabling, remove enabled field (use default value)
map.remove(&Value::String("enabled".to_string()));
} else {
// When disabling, write enabled: false
- map.insert(
- Value::String("enabled".to_string()),
- Value::Bool(false),
- );
+ map.insert(Value::String("enabled".to_string()), Value::Bool(false));
}
FrontMatterMarkdown::save(skill_md_path, &metadata, &body)
@@ -167,4 +164,3 @@ impl SkillData {
)
}
}
-
diff --git a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/mod.rs b/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/mod.rs
index 726fcd7e..a8c766c5 100644
--- a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/mod.rs
+++ b/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/mod.rs
@@ -1,2 +1,2 @@
+pub mod edit_file;
pub mod read_file;
-pub mod edit_file;
\ No newline at end of file
diff --git a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/string.rs b/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/string.rs
index a00b0436..4d3a2f8d 100644
--- a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/string.rs
+++ b/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/string.rs
@@ -9,4 +9,4 @@ pub fn normalize_string(s: &str) -> String {
pub fn truncate_string_by_chars(s: &str, kept_chars: usize) -> String {
let chars: Vec = s.chars().collect();
chars[..kept_chars].into_iter().collect()
-}
\ No newline at end of file
+}
diff --git a/src/crates/core/src/agentic/tools/pipeline/mod.rs b/src/crates/core/src/agentic/tools/pipeline/mod.rs
index c295ddef..92e36c1e 100644
--- a/src/crates/core/src/agentic/tools/pipeline/mod.rs
+++ b/src/crates/core/src/agentic/tools/pipeline/mod.rs
@@ -1,12 +1,11 @@
//! Tool pipeline module
-//!
+//!
//! Provides complete lifecycle management for tool execution
-pub mod types;
pub mod state_manager;
pub mod tool_pipeline;
+pub mod types;
-pub use types::*;
pub use state_manager::*;
pub use tool_pipeline::*;
-
+pub use types::*;
diff --git a/src/crates/core/src/agentic/tools/pipeline/state_manager.rs b/src/crates/core/src/agentic/tools/pipeline/state_manager.rs
index 67d99022..e6b8e691 100644
--- a/src/crates/core/src/agentic/tools/pipeline/state_manager.rs
+++ b/src/crates/core/src/agentic/tools/pipeline/state_manager.rs
@@ -1,19 +1,19 @@
//! Tool state manager
-//!
+//!
//! Manages the status and lifecycle of tool execution tasks
-use log::debug;
use super::types::ToolTask;
use crate::agentic::core::ToolExecutionState;
-use crate::agentic::events::{EventQueue, AgenticEvent, ToolEventData, EventPriority};
+use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue, ToolEventData};
use dashmap::DashMap;
+use log::debug;
use std::sync::Arc;
/// Tool state manager
pub struct ToolStateManager {
/// Tool task status (by tool ID)
tasks: Arc>,
-
+
/// Event queue
event_queue: Arc,
}
@@ -25,52 +25,50 @@ impl ToolStateManager {
event_queue,
}
}
-
+
/// Create task
pub async fn create_task(&self, task: ToolTask) -> String {
let tool_id = task.tool_call.tool_id.clone();
self.tasks.insert(tool_id.clone(), task);
tool_id
}
-
+
/// Update task state
- pub async fn update_state(
- &self,
- tool_id: &str,
- new_state: ToolExecutionState,
- ) {
+ pub async fn update_state(&self, tool_id: &str, new_state: ToolExecutionState) {
if let Some(mut task) = self.tasks.get_mut(tool_id) {
let old_state = task.state.clone();
task.state = new_state.clone();
-
+
// Update timestamp
match &new_state {
ToolExecutionState::Running { .. } | ToolExecutionState::Streaming { .. } => {
task.started_at = Some(std::time::SystemTime::now());
}
- ToolExecutionState::Completed { .. } | ToolExecutionState::Failed { .. } | ToolExecutionState::Cancelled { .. } => {
+ ToolExecutionState::Completed { .. }
+ | ToolExecutionState::Failed { .. }
+ | ToolExecutionState::Cancelled { .. } => {
task.completed_at = Some(std::time::SystemTime::now());
}
_ => {}
}
-
+
debug!(
"Tool state changed: tool_id={}, old_state={:?}, new_state={:?}",
tool_id,
format!("{:?}", old_state).split('{').next().unwrap_or(""),
format!("{:?}", new_state).split('{').next().unwrap_or("")
);
-
+
// Send state change event
self.emit_state_change_event(task.clone()).await;
}
}
-
+
/// Get task
pub fn get_task(&self, tool_id: &str) -> Option {
self.tasks.get(tool_id).map(|t| t.clone())
}
-
+
/// Update task arguments
pub fn update_task_arguments(&self, tool_id: &str, new_arguments: serde_json::Value) {
if let Some(mut task) = self.tasks.get_mut(tool_id) {
@@ -81,7 +79,7 @@ impl ToolStateManager {
task.tool_call.arguments = new_arguments;
}
}
-
+
/// Get all tasks of a session
pub fn get_session_tasks(&self, session_id: &str) -> Vec {
self.tasks
@@ -90,7 +88,7 @@ impl ToolStateManager {
.map(|entry| entry.value().clone())
.collect()
}
-
+
/// Get all tasks of a dialog turn
pub fn get_dialog_turn_tasks(&self, dialog_turn_id: &str) -> Vec {
self.tasks
@@ -99,27 +97,28 @@ impl ToolStateManager {
.map(|entry| entry.value().clone())
.collect()
}
-
+
/// Delete task
pub fn remove_task(&self, tool_id: &str) {
self.tasks.remove(tool_id);
}
-
+
/// Clear all tasks of a session
pub fn clear_session(&self, session_id: &str) {
- let to_remove: Vec<_> = self.tasks
+ let to_remove: Vec<_> = self
+ .tasks
.iter()
.filter(|entry| entry.value().context.session_id == session_id)
.map(|entry| entry.key().clone())
.collect();
-
+
for tool_id in to_remove {
self.tasks.remove(&tool_id);
}
-
+
debug!("Cleared session tool tasks: session_id={}", session_id);
}
-
+
/// Send state change event (full version)
async fn emit_state_change_event(&self, task: ToolTask) {
let tool_event = match &task.state {
@@ -128,51 +127,61 @@ impl ToolStateManager {
tool_name: task.tool_call.tool_name.clone(),
position: *position,
},
-
+
ToolExecutionState::Waiting { dependencies } => ToolEventData::Waiting {
tool_id: task.tool_call.tool_id.clone(),
tool_name: task.tool_call.tool_name.clone(),
dependencies: dependencies.clone(),
},
-
+
ToolExecutionState::Running { .. } => ToolEventData::Started {
tool_id: task.tool_call.tool_id.clone(),
tool_name: task.tool_call.tool_name.clone(),
params: task.tool_call.arguments.clone(),
},
-
- ToolExecutionState::Streaming { chunks_received, .. } => ToolEventData::Streaming {
+
+ ToolExecutionState::Streaming {
+ chunks_received, ..
+ } => ToolEventData::Streaming {
tool_id: task.tool_call.tool_id.clone(),
tool_name: task.tool_call.tool_name.clone(),
chunks_received: *chunks_received,
},
-
- ToolExecutionState::AwaitingConfirmation { params, .. } => ToolEventData::ConfirmationNeeded {
- tool_id: task.tool_call.tool_id.clone(),
- tool_name: task.tool_call.tool_name.clone(),
- params: params.clone(),
- },
-
- ToolExecutionState::Completed { result, duration_ms } => ToolEventData::Completed {
+
+ ToolExecutionState::AwaitingConfirmation { params, .. } => {
+ ToolEventData::ConfirmationNeeded {
+ tool_id: task.tool_call.tool_id.clone(),
+ tool_name: task.tool_call.tool_name.clone(),
+ params: params.clone(),
+ }
+ }
+
+ ToolExecutionState::Completed {
+ result,
+ duration_ms,
+ } => ToolEventData::Completed {
tool_id: task.tool_call.tool_id.clone(),
tool_name: task.tool_call.tool_name.clone(),
result: result.content(),
duration_ms: *duration_ms,
},
-
- ToolExecutionState::Failed { error, is_retryable: _ } => ToolEventData::Failed {
+
+ ToolExecutionState::Failed {
+ error,
+ is_retryable: _,
+ } => ToolEventData::Failed {
tool_id: task.tool_call.tool_id.clone(),
tool_name: task.tool_call.tool_name.clone(),
error: error.clone(),
},
-
+
ToolExecutionState::Cancelled { reason } => ToolEventData::Cancelled {
tool_id: task.tool_call.tool_id.clone(),
tool_name: task.tool_call.tool_name.clone(),
reason: reason.clone(),
},
};
-
+
// Determine priority based on tool event type
let priority = match &task.state {
// Critical state change: High priority (user needs to see immediately)
@@ -191,7 +200,7 @@ impl ToolStateManager {
| ToolExecutionState::Streaming { .. }
=> EventPriority::Normal,
};
-
+
let event_subagent_parent_info = task.context.subagent_parent_info.map(|info| info.into());
let event = AgenticEvent::ToolEvent {
session_id: task.context.session_id,
@@ -199,17 +208,17 @@ impl ToolStateManager {
tool_event,
subagent_parent_info: event_subagent_parent_info,
};
-
+
let _ = self.event_queue.enqueue(event, Some(priority)).await;
}
-
+
/// Get statistics
pub fn get_stats(&self) -> ToolStats {
let tasks: Vec<_> = self.tasks.iter().map(|e| e.value().clone()).collect();
-
+
let mut stats = ToolStats::default();
stats.total = tasks.len();
-
+
for task in tasks {
match task.state {
ToolExecutionState::Queued { .. } => stats.queued += 1,
@@ -222,7 +231,7 @@ impl ToolStateManager {
ToolExecutionState::Cancelled { .. } => stats.cancelled += 1,
}
}
-
+
stats
}
}
diff --git a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs
index 3ea85d7f..84eaea72 100644
--- a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs
+++ b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs
@@ -1,28 +1,30 @@
//! Tool pipeline
-//!
-//! Manages the complete lifecycle of tools:
+//!
+//! Manages the complete lifecycle of tools:
//! confirmation, execution, caching, retries, etc.
-use log::{debug, info, warn, error};
use super::state_manager::ToolStateManager;
use super::types::*;
-use crate::agentic::core::{ToolCall, ToolResult as ModelToolResult, ToolExecutionState};
+use crate::agentic::core::{ToolCall, ToolExecutionState, ToolResult as ModelToolResult};
use crate::agentic::events::types::ToolEventData;
-use crate::agentic::tools::registry::ToolRegistry;
-use crate::agentic::tools::framework::{ToolUseContext, ToolOptions, ToolResult as FrameworkToolResult};
+use crate::agentic::tools::framework::{
+ ToolOptions, ToolResult as FrameworkToolResult, ToolUseContext,
+};
use crate::agentic::tools::image_context::ImageContextProviderRef;
+use crate::agentic::tools::registry::ToolRegistry;
use crate::util::errors::{BitFunError, BitFunResult};
+use dashmap::DashMap;
use futures::future::join_all;
+use log::{debug, error, info, warn};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
-use tokio::time::{timeout, Duration};
use tokio::sync::{oneshot, RwLock as TokioRwLock};
-use dashmap::DashMap;
+use tokio::time::{timeout, Duration};
use tokio_util::sync::CancellationToken;
/// Convert framework::ToolResult to core::ToolResult
-///
+///
/// Ensure always has result_for_assistant, avoid tool message content being empty
fn convert_tool_result(
framework_result: FrameworkToolResult,
@@ -30,13 +32,16 @@ fn convert_tool_result(
tool_name: &str,
) -> ModelToolResult {
match framework_result {
- FrameworkToolResult::Result { data, result_for_assistant } => {
+ FrameworkToolResult::Result {
+ data,
+ result_for_assistant,
+ } => {
// If the tool does not provide result_for_assistant, generate default friendly description
let assistant_text = result_for_assistant.or_else(|| {
// Generate natural language description based on data
generate_default_assistant_text(tool_name, &data)
});
-
+
ModelToolResult {
tool_id: tool_id.to_string(),
tool_name: tool_name.to_string(),
@@ -45,11 +50,11 @@ fn convert_tool_result(
is_error: false,
duration_ms: None,
}
- },
+ }
FrameworkToolResult::Progress { content, .. } => {
// Progress message also generates friendly text
let assistant_text = generate_default_assistant_text(tool_name, &content);
-
+
ModelToolResult {
tool_id: tool_id.to_string(),
tool_name: tool_name.to_string(),
@@ -58,11 +63,11 @@ fn convert_tool_result(
is_error: false,
duration_ms: None,
}
- },
+ }
FrameworkToolResult::StreamChunk { data, .. } => {
// Streaming data block also generates friendly text
let assistant_text = generate_default_assistant_text(tool_name, &data);
-
+
ModelToolResult {
tool_id: tool_id.to_string(),
tool_name: tool_name.to_string(),
@@ -71,7 +76,7 @@ fn convert_tool_result(
is_error: false,
duration_ms: None,
}
- },
+ }
}
}
@@ -79,32 +84,45 @@ fn convert_tool_result(
fn generate_default_assistant_text(tool_name: &str, data: &serde_json::Value) -> Option {
// Check if data is null or empty
if data.is_null() {
- return Some(format!("Tool {} completed, but no result returned.", tool_name));
+ return Some(format!(
+ "Tool {} completed, but no result returned.",
+ tool_name
+ ));
}
-
+
// If it is an empty object or empty array
- if (data.is_object() && data.as_object().map_or(false, |o| o.is_empty())) ||
- (data.is_array() && data.as_array().map_or(false, |a| a.is_empty())) {
- return Some(format!("Tool {} completed, returned empty result.", tool_name));
+ if (data.is_object() && data.as_object().map_or(false, |o| o.is_empty()))
+ || (data.is_array() && data.as_array().map_or(false, |a| a.is_empty()))
+ {
+ return Some(format!(
+ "Tool {} completed, returned empty result.",
+ tool_name
+ ));
}
-
+
// Try to extract common fields to generate description
if let Some(obj) = data.as_object() {
// Check if there is a success field
if let Some(success) = obj.get("success").and_then(|v| v.as_bool()) {
if success {
if let Some(message) = obj.get("message").and_then(|v| v.as_str()) {
- return Some(format!("Tool {} completed successfully: {}", tool_name, message));
+ return Some(format!(
+ "Tool {} completed successfully: {}",
+ tool_name, message
+ ));
}
return Some(format!("Tool {} completed successfully.", tool_name));
} else {
if let Some(error) = obj.get("error").and_then(|v| v.as_str()) {
- return Some(format!("Tool {} completed with error: {}", tool_name, error));
+ return Some(format!(
+ "Tool {} completed with error: {}",
+ tool_name, error
+ ));
}
return Some(format!("Tool {} completed with error.", tool_name));
}
}
-
+
// Check if there is a result/data/content field
for key in &["result", "data", "content", "output"] {
if let Some(value) = obj.get(*key) {
@@ -115,7 +133,7 @@ fn generate_default_assistant_text(tool_name: &str, data: &serde_json::Value) ->
}
}
}
-
+
// If there are multiple fields, provide field list
let field_names: Vec<&str> = obj.keys().take(5).map(|s| s.as_str()).collect();
if !field_names.is_empty() {
@@ -126,30 +144,38 @@ fn generate_default_assistant_text(tool_name: &str, data: &serde_json::Value) ->
));
}
}
-
+
// If it is a string, return directly (but limit length)
if let Some(text) = data.as_str() {
if !text.is_empty() {
if text.len() <= 500 {
return Some(format!("Tool {} completed: {}", tool_name, text));
} else {
- return Some(format!("Tool {} completed, returned {} characters of text result.", tool_name, text.len()));
+ return Some(format!(
+ "Tool {} completed, returned {} characters of text result.",
+ tool_name,
+ text.len()
+ ));
}
}
}
-
+
// If it is a number or boolean
if data.is_number() || data.is_boolean() {
return Some(format!("Tool {} completed, returned: {}", tool_name, data));
}
-
+
// Default: simply describe data type
Some(format!(
"Tool {} completed, returned {} type of result.",
tool_name,
- if data.is_object() { "object" }
- else if data.is_array() { "array" }
- else { "data" }
+ if data.is_object() {
+ "object"
+ } else if data.is_array() {
+ "array"
+ } else {
+ "data"
+ }
))
}
@@ -194,7 +220,7 @@ impl ToolPipeline {
image_context_provider,
}
}
-
+
/// Execute multiple tool calls
pub async fn execute_tools(
&self,
@@ -205,44 +231,54 @@ impl ToolPipeline {
if tool_calls.is_empty() {
return Ok(vec![]);
}
-
+
info!("Executing tools: count={}", tool_calls.len());
-
+
// Check should_end_turn tool count, if more than one, mark as error
let end_turn_tool_ids: Vec = {
- let end_turn_tools: Vec<&ToolCall> = tool_calls.iter()
- .filter(|tc| tc.should_end_turn)
- .collect();
-
+ let end_turn_tools: Vec<&ToolCall> =
+ tool_calls.iter().filter(|tc| tc.should_end_turn).collect();
+
if end_turn_tools.len() > 1 {
warn!(
"Multiple should_end_turn tools detected: count={}, tools={:?}",
end_turn_tools.len(),
- end_turn_tools.iter().map(|tc| &tc.tool_name).collect::>()
+ end_turn_tools
+ .iter()
+ .map(|tc| &tc.tool_name)
+ .collect::>()
);
end_turn_tools.iter().map(|tc| tc.tool_id.clone()).collect()
} else {
vec![]
}
};
-
+
// Separate tools that need to be errors and tools that are normally executed
- let (error_tool_calls, normal_tool_calls): (Vec, Vec) =
- tool_calls.into_iter().partition(|tc| end_turn_tool_ids.contains(&tc.tool_id));
-
+ let (error_tool_calls, normal_tool_calls): (Vec, Vec) = tool_calls
+ .into_iter()
+ .partition(|tc| end_turn_tool_ids.contains(&tc.tool_id));
+
// Check if all tools that are normally executed are concurrency safe
let all_concurrency_safe = {
let registry = self.tool_registry.read().await;
normal_tool_calls.iter().all(|tc| {
- registry.get_tool(&tc.tool_name)
+ registry
+ .get_tool(&tc.tool_name)
.map(|tool| tool.is_concurrency_safe(Some(&tc.arguments)))
.unwrap_or(false) // If the tool does not exist, it is considered unsafe
})
};
-
+
// Generate error results for tools that need to be errors
if !error_tool_calls.is_empty() {
- error!("Multiple should_end_turn tools detected: {:?}", error_tool_calls.iter().map(|tc| tc.tool_name.clone()).collect::>());
+ error!(
+ "Multiple should_end_turn tools detected: {:?}",
+ error_tool_calls
+ .iter()
+ .map(|tc| tc.tool_name.clone())
+ .collect::>()
+ );
}
let mut error_results: Vec = error_tool_calls.into_iter().map(|tc| {
let error_msg = format!("Tool '{}' will end the current dialog turn. Such tools must be called separately.", tc.tool_name);
@@ -264,12 +300,12 @@ impl ToolPipeline {
execution_time_ms: 0,
}
}).collect();
-
+
// If there are no tools that are normally executed, return error results directly
if normal_tool_calls.is_empty() {
return Ok(error_results);
}
-
+
// Create tasks (only for tools that are normally executed)
let mut tasks = Vec::new();
for tool_call in normal_tool_calls {
@@ -277,19 +313,19 @@ impl ToolPipeline {
let tool_id = self.state_manager.create_task(task).await;
tasks.push(tool_id);
}
-
+
// Execute tasks: only when allow_parallel is true and all tools are concurrency safe
let should_parallel = options.allow_parallel && all_concurrency_safe;
if !all_concurrency_safe && options.allow_parallel {
debug!("Non-concurrency-safe tools detected, switching to sequential execution");
}
-
+
let normal_results = if should_parallel {
self.execute_parallel(tasks).await
} else {
self.execute_sequential(tasks).await
};
-
+
match normal_results {
Ok(mut results) => {
// Merge error results and normal execution results
@@ -302,16 +338,19 @@ impl ToolPipeline {
}
}
}
-
+
/// Execute tools in parallel
- async fn execute_parallel(&self, task_ids: Vec) -> BitFunResult> {
+ async fn execute_parallel(
+ &self,
+ task_ids: Vec,
+ ) -> BitFunResult> {
let futures: Vec<_> = task_ids
.iter()
.map(|id| self.execute_single_tool(id.clone()))
.collect();
-
+
let results = join_all(futures).await;
-
+
// Collect results, including failed results
let mut all_results = Vec::new();
for (idx, result) in results.into_iter().enumerate() {
@@ -319,7 +358,7 @@ impl ToolPipeline {
Ok(r) => all_results.push(r),
Err(e) => {
error!("Tool execution failed: error={}", e);
-
+
// Get task information from state manager
if let Some(task) = self.state_manager.get_task(&task_ids[idx]) {
// Create error result to return to model
@@ -344,20 +383,23 @@ impl ToolPipeline {
}
}
}
-
+
Ok(all_results)
}
-
+
/// Execute tools sequentially
- async fn execute_sequential(&self, task_ids: Vec) -> BitFunResult> {
+ async fn execute_sequential(
+ &self,
+ task_ids: Vec,
+ ) -> BitFunResult> {
let mut results = Vec::new();
-
+
for task_id in task_ids {
match self.execute_single_tool(task_id.clone()).await {
Ok(result) => results.push(result),
Err(e) => {
error!("Tool execution failed: error={}", e);
-
+
// Get task information from state manager
if let Some(task) = self.state_manager.get_task(&task_id) {
// Create error result to return to model
@@ -382,26 +424,30 @@ impl ToolPipeline {
}
}
}
-
+
Ok(results)
}
-
+
/// Execute single tool
async fn execute_single_tool(&self, tool_id: String) -> BitFunResult {
let start_time = Instant::now();
-
+
debug!("Starting tool execution: tool_id={}", tool_id);
-
+
// Get task
- let task = self.state_manager
+ let task = self
+ .state_manager
.get_task(&tool_id)
.ok_or_else(|| BitFunError::NotFound(format!("Tool task not found: {}", tool_id)))?;
-
+
let tool_name = task.tool_call.tool_name.clone();
let tool_args = task.tool_call.arguments.clone();
let tool_is_error = task.tool_call.is_error;
-
- debug!("Tool task details: tool_name={}, tool_id={}", tool_name, tool_id);
+
+ debug!(
+ "Tool task details: tool_name={}, tool_id={}",
+ tool_name, tool_id
+ );
if tool_name.is_empty() || tool_is_error {
let error_msg = format!(
@@ -410,60 +456,68 @@ impl ToolPipeline {
Please regenerate the tool call with valid tool name and arguments."
);
self.state_manager
- .update_state(&tool_id, ToolExecutionState::Failed {
- error: error_msg.clone(),
- is_retryable: false,
- })
+ .update_state(
+ &tool_id,
+ ToolExecutionState::Failed {
+ error: error_msg.clone(),
+ is_retryable: false,
+ },
+ )
.await;
return Err(BitFunError::Validation(error_msg));
}
-
+
// Security check: check if the tool is in the allowed list
// If allowed_tools is not empty, only allow execution of tools in the whitelist
- if !task.context.allowed_tools.is_empty()
- && !task.context.allowed_tools.contains(&tool_name)
+ if !task.context.allowed_tools.is_empty()
+ && !task.context.allowed_tools.contains(&tool_name)
{
let error_msg = format!(
"Tool '{}' is not in the allowed list: {:?}",
- tool_name,
- task.context.allowed_tools
+ tool_name, task.context.allowed_tools
);
warn!("Tool not allowed: {}", error_msg);
-
+
// Update state to failed
self.state_manager
- .update_state(&tool_id, ToolExecutionState::Failed {
- error: error_msg.clone(),
- is_retryable: false,
- })
+ .update_state(
+ &tool_id,
+ ToolExecutionState::Failed {
+ error: error_msg.clone(),
+ is_retryable: false,
+ },
+ )
.await;
-
+
return Err(BitFunError::Validation(error_msg));
}
-
+
// Create cancellation token
let cancellation_token = CancellationToken::new();
- self.cancellation_tokens.insert(tool_id.clone(), cancellation_token.clone());
-
+ self.cancellation_tokens
+ .insert(tool_id.clone(), cancellation_token.clone());
+
debug!("Executing tool: tool_name={}", tool_name);
-
+
let tool = {
let registry = self.tool_registry.read().await;
- registry.get_tool(&task.tool_call.tool_name).ok_or_else(|| {
- let error_msg = format!(
- "Tool '{}' is not registered or enabled.",
- task.tool_call.tool_name,
- );
- error!("{}", error_msg);
- BitFunError::tool(error_msg)
- })?
+ registry
+ .get_tool(&task.tool_call.tool_name)
+ .ok_or_else(|| {
+ let error_msg = format!(
+ "Tool '{}' is not registered or enabled.",
+ task.tool_call.tool_name,
+ );
+ error!("{}", error_msg);
+ BitFunError::tool(error_msg)
+ })?
};
let is_streaming = tool.supports_streaming();
- let needs_confirmation = task.options.confirm_before_run
- && tool.needs_permissions(Some(&tool_args));
+ let needs_confirmation =
+ task.options.confirm_before_run && tool.needs_permissions(Some(&tool_args));
if needs_confirmation {
info!("Tool requires confirmation: tool_name={}", tool_name);
@@ -480,17 +534,23 @@ impl ToolPipeline {
self.confirmation_channels.insert(tool_id.clone(), tx);
self.state_manager
- .update_state(&tool_id, ToolExecutionState::AwaitingConfirmation {
- params: tool_args.clone(),
- timeout_at,
- })
+ .update_state(
+ &tool_id,
+ ToolExecutionState::AwaitingConfirmation {
+ params: tool_args.clone(),
+ timeout_at,
+ },
+ )
.await;
debug!("Waiting for confirmation: tool_name={}", tool_name);
let confirmation_result = match task.options.confirmation_timeout_secs {
Some(timeout_secs) => {
- debug!("Waiting for user confirmation with timeout: timeout_secs={}, tool_name={}", timeout_secs, tool_name);
+ debug!(
+ "Waiting for user confirmation with timeout: timeout_secs={}, tool_name={}",
+ timeout_secs, tool_name
+ );
// There is a timeout limit
match timeout(Duration::from_secs(timeout_secs), rx).await {
Ok(result) => Some(result),
@@ -498,7 +558,10 @@ impl ToolPipeline {
}
}
None => {
- debug!("Waiting for user confirmation without timeout: tool_name={}", tool_name);
+ debug!(
+ "Waiting for user confirmation without timeout: tool_name={}",
+ tool_name
+ );
Some(rx.await)
}
};
@@ -509,82 +572,116 @@ impl ToolPipeline {
}
Some(Ok(ConfirmationResponse::Rejected(reason))) => {
self.state_manager
- .update_state(&tool_id, ToolExecutionState::Cancelled {
- reason: format!("User rejected: {}", reason),
- })
+ .update_state(
+ &tool_id,
+ ToolExecutionState::Cancelled {
+ reason: format!("User rejected: {}", reason),
+ },
+ )
.await;
- return Err(BitFunError::Validation(format!("Tool was rejected by user: {}", reason)));
+ return Err(BitFunError::Validation(format!(
+ "Tool was rejected by user: {}",
+ reason
+ )));
}
Some(Err(_)) => {
// Channel closed
self.state_manager
- .update_state(&tool_id, ToolExecutionState::Cancelled {
- reason: "Confirmation channel closed".to_string(),
- })
+ .update_state(
+ &tool_id,
+ ToolExecutionState::Cancelled {
+ reason: "Confirmation channel closed".to_string(),
+ },
+ )
.await;
return Err(BitFunError::service("Confirmation channel closed"));
}
None => {
self.state_manager
- .update_state(&tool_id, ToolExecutionState::Cancelled {
- reason: "Confirmation timeout".to_string(),
- })
+ .update_state(
+ &tool_id,
+ ToolExecutionState::Cancelled {
+ reason: "Confirmation timeout".to_string(),
+ },
+ )
.await;
warn!("Confirmation timeout: {}", tool_name);
- return Err(BitFunError::Timeout(format!("Confirmation timeout: {}", tool_name)));
+ return Err(BitFunError::Timeout(format!(
+ "Confirmation timeout: {}",
+ tool_name
+ )));
}
}
self.confirmation_channels.remove(&tool_id);
}
-
+
if cancellation_token.is_cancelled() {
self.state_manager
- .update_state(&tool_id, ToolExecutionState::Cancelled {
- reason: "Tool was cancelled before execution".to_string(),
- })
+ .update_state(
+ &tool_id,
+ ToolExecutionState::Cancelled {
+ reason: "Tool was cancelled before execution".to_string(),
+ },
+ )
.await;
self.cancellation_tokens.remove(&tool_id);
- return Err(BitFunError::Cancelled("Tool was cancelled before execution".to_string()));
+ return Err(BitFunError::Cancelled(
+ "Tool was cancelled before execution".to_string(),
+ ));
}
-
+
// Set initial state
if is_streaming {
self.state_manager
- .update_state(&tool_id, ToolExecutionState::Streaming {
- started_at: std::time::SystemTime::now(),
- chunks_received: 0,
- })
+ .update_state(
+ &tool_id,
+ ToolExecutionState::Streaming {
+ started_at: std::time::SystemTime::now(),
+ chunks_received: 0,
+ },
+ )
.await;
} else {
self.state_manager
- .update_state(&tool_id, ToolExecutionState::Running {
- started_at: std::time::SystemTime::now(),
- progress: None,
- })
+ .update_state(
+ &tool_id,
+ ToolExecutionState::Running {
+ started_at: std::time::SystemTime::now(),
+ progress: None,
+ },
+ )
.await;
}
-
- let result = self.execute_with_retry(&task, cancellation_token.clone(), tool).await;
-
+
+ let result = self
+ .execute_with_retry(&task, cancellation_token.clone(), tool)
+ .await;
+
self.cancellation_tokens.remove(&tool_id);
-
+
match result {
Ok(tool_result) => {
let duration_ms = start_time.elapsed().as_millis() as u64;
-
+
self.state_manager
- .update_state(&tool_id, ToolExecutionState::Completed {
- result: convert_to_framework_result(&tool_result),
- duration_ms,
- })
+ .update_state(
+ &tool_id,
+ ToolExecutionState::Completed {
+ result: convert_to_framework_result(&tool_result),
+ duration_ms,
+ },
+ )
.await;
-
- info!("Tool completed: tool_name={}, duration_ms={}", tool_name, duration_ms);
-
+
+ info!(
+ "Tool completed: tool_name={}, duration_ms={}",
+ tool_name, duration_ms
+ );
+
Ok(ToolExecutionResult {
tool_id,
tool_name,
@@ -595,21 +692,24 @@ impl ToolPipeline {
Err(e) => {
let error_msg = e.to_string();
let is_retryable = task.options.max_retries > 0;
-
+
self.state_manager
- .update_state(&tool_id, ToolExecutionState::Failed {
- error: error_msg.clone(),
- is_retryable,
- })
+ .update_state(
+ &tool_id,
+ ToolExecutionState::Failed {
+ error: error_msg.clone(),
+ is_retryable,
+ },
+ )
.await;
-
+
error!("Tool failed: tool_name={}, error={}", tool_name, error_msg);
-
+
Err(e)
}
}
}
-
+
/// Execute with retry
async fn execute_with_retry(
&self,
@@ -623,29 +723,36 @@ impl ToolPipeline {
loop {
// Check cancellation token
if cancellation_token.is_cancelled() {
- return Err(BitFunError::Cancelled("Tool execution was cancelled".to_string()));
+ return Err(BitFunError::Cancelled(
+ "Tool execution was cancelled".to_string(),
+ ));
}
attempts += 1;
- let result = self.execute_tool_impl(task, cancellation_token.clone(), tool.clone()).await;
-
+ let result = self
+ .execute_tool_impl(task, cancellation_token.clone(), tool.clone())
+ .await;
+
match result {
Ok(r) => return Ok(r),
Err(e) => {
if attempts >= max_attempts {
return Err(e);
}
-
- debug!("Retrying tool execution: attempt={}/{}, error={}", attempts, max_attempts, e);
-
+
+ debug!(
+ "Retrying tool execution: attempt={}/{}, error={}",
+ attempts, max_attempts, e
+ );
+
// Wait for a period of time and retry
tokio::time::sleep(Duration::from_millis(100 * attempts as u64)).await;
}
}
}
}
-
+
/// Actual execution of tool
async fn execute_tool_impl(
&self,
@@ -655,9 +762,11 @@ impl ToolPipeline {
) -> BitFunResult {
// Check cancellation token
if cancellation_token.is_cancelled() {
- return Err(BitFunError::Cancelled("Tool execution was cancelled".to_string()));
+ return Err(BitFunError::Cancelled(
+ "Tool execution was cancelled".to_string(),
+ ));
}
-
+
// Build tool context (pass all resource IDs)
let tool_context = ToolUseContext {
tool_call_id: Some(task.tool_call.tool_id.clone()),
@@ -682,7 +791,7 @@ impl ToolPipeline {
is_custom_command: None,
custom_data: Some({
let mut map = HashMap::new();
-
+
if let Some(snapshot_id) = task
.context
.context_vars
@@ -699,7 +808,7 @@ impl ToolPipeline {
map.insert("turn_index".to_string(), serde_json::json!(n));
}
}
-
+
map
}),
}),
@@ -708,31 +817,41 @@ impl ToolPipeline {
subagent_parent_info: task.context.subagent_parent_info.clone(),
cancellation_token: Some(cancellation_token),
};
-
+
let execution_future = tool.call(&task.tool_call.arguments, &tool_context);
-
+
let tool_results = match task.options.timeout_secs {
Some(timeout_secs) => {
let timeout_duration = Duration::from_secs(timeout_secs);
let result = timeout(timeout_duration, execution_future)
.await
- .map_err(|_| BitFunError::Timeout(format!("Tool execution timeout: {}", task.tool_call.tool_name)))?;
+ .map_err(|_| {
+ BitFunError::Timeout(format!(
+ "Tool execution timeout: {}",
+ task.tool_call.tool_name
+ ))
+ })?;
result?
}
- None => {
- execution_future.await?
- }
+ None => execution_future.await?,
};
-
+
if tool.supports_streaming() && tool_results.len() > 1 {
self.handle_streaming_results(task, &tool_results).await?;
}
-
- tool_results.into_iter().last()
+
+ tool_results
+ .into_iter()
+ .last()
.map(|r| convert_tool_result(r, &task.tool_call.tool_id, &task.tool_call.tool_name))
- .ok_or_else(|| BitFunError::Tool(format!("Tool did not return result: {}", task.tool_call.tool_name)))
+ .ok_or_else(|| {
+ BitFunError::Tool(format!(
+ "Tool did not return result: {}",
+ task.tool_call.tool_name
+ ))
+ })
}
-
+
/// Handle streaming results
async fn handle_streaming_results(
&self,
@@ -740,19 +859,27 @@ impl ToolPipeline {
results: &[FrameworkToolResult],
) -> BitFunResult<()> {
let mut chunks_received = 0;
-
+
for result in results {
- if let FrameworkToolResult::StreamChunk { data, chunk_index: _, is_final: _ } = result {
+ if let FrameworkToolResult::StreamChunk {
+ data,
+ chunk_index: _,
+ is_final: _,
+ } = result
+ {
chunks_received += 1;
-
+
// Update state
self.state_manager
- .update_state(&task.tool_call.tool_id, ToolExecutionState::Streaming {
- started_at: std::time::SystemTime::now(),
- chunks_received,
- })
+ .update_state(
+ &task.tool_call.tool_id,
+ ToolExecutionState::Streaming {
+ started_at: std::time::SystemTime::now(),
+ chunks_received,
+ },
+ )
.await;
-
+
// Send StreamChunk event
let _event_data = ToolEventData::StreamChunk {
tool_id: task.tool_call.tool_id.clone(),
@@ -761,10 +888,10 @@ impl ToolPipeline {
};
}
}
-
+
Ok(())
}
-
+
/// Cancel tool execution
pub async fn cancel_tool(&self, tool_id: &str, reason: String) -> BitFunResult<()> {
// 1. Trigger cancellation token
@@ -772,66 +899,93 @@ impl ToolPipeline {
token.cancel();
debug!("Cancellation token triggered: tool_id={}", tool_id);
} else {
- debug!("Cancellation token not found (tool may have completed): tool_id={}", tool_id);
+ debug!(
+ "Cancellation token not found (tool may have completed): tool_id={}",
+ tool_id
+ );
}
-
+
// 2. Clean up confirmation channel (if waiting for confirmation)
if let Some((_, _tx)) = self.confirmation_channels.remove(tool_id) {
// Channel will be automatically closed, causing await rx to return Err
debug!("Cleared confirmation channel: tool_id={}", tool_id);
}
-
+
// 3. Update state to cancelled
self.state_manager
- .update_state(tool_id, ToolExecutionState::Cancelled {
- reason: reason.clone(),
- })
+ .update_state(
+ tool_id,
+ ToolExecutionState::Cancelled {
+ reason: reason.clone(),
+ },
+ )
.await;
-
- info!("Tool execution cancelled: tool_id={}, reason={}", tool_id, reason);
+
+ info!(
+ "Tool execution cancelled: tool_id={}, reason={}",
+ tool_id, reason
+ );
Ok(())
}
-
+
/// Cancel all tools for a dialog turn
pub async fn cancel_dialog_turn_tools(&self, dialog_turn_id: &str) -> BitFunResult<()> {
- info!("Cancelling all tools for dialog turn: dialog_turn_id={}", dialog_turn_id);
-
+ info!(
+ "Cancelling all tools for dialog turn: dialog_turn_id={}",
+ dialog_turn_id
+ );
+
let tasks = self.state_manager.get_dialog_turn_tasks(dialog_turn_id);
debug!("Found {} tool tasks for dialog turn", tasks.len());
-
+
let mut cancelled_count = 0;
let mut skipped_count = 0;
-
+
for task in tasks {
// Only cancel tasks in cancellable states
let can_cancel = matches!(
task.state,
ToolExecutionState::Queued { .. }
- | ToolExecutionState::Waiting { .. }
- | ToolExecutionState::Running { .. }
- | ToolExecutionState::AwaitingConfirmation { .. }
+ | ToolExecutionState::Waiting { .. }
+ | ToolExecutionState::Running { .. }
+ | ToolExecutionState::AwaitingConfirmation { .. }
);
-
+
if can_cancel {
- debug!("Cancelling tool: tool_id={}, state={:?}", task.tool_call.tool_id, task.state);
- self.cancel_tool(&task.tool_call.tool_id, "Dialog turn cancelled".to_string()).await?;
+ debug!(
+ "Cancelling tool: tool_id={}, state={:?}",
+ task.tool_call.tool_id, task.state
+ );
+ self.cancel_tool(&task.tool_call.tool_id, "Dialog turn cancelled".to_string())
+ .await?;
cancelled_count += 1;
} else {
- debug!("Skipping tool (state not cancellable): tool_id={}, state={:?}", task.tool_call.tool_id, task.state);
+ debug!(
+ "Skipping tool (state not cancellable): tool_id={}, state={:?}",
+ task.tool_call.tool_id, task.state
+ );
skipped_count += 1;
}
}
-
- info!("Tool cancellation completed: cancelled={}, skipped={}", cancelled_count, skipped_count);
+
+ info!(
+ "Tool cancellation completed: cancelled={}, skipped={}",
+ cancelled_count, skipped_count
+ );
Ok(())
}
-
+
/// Confirm tool execution
- pub async fn confirm_tool(&self, tool_id: &str, updated_input: Option) -> BitFunResult<()> {
- let task = self.state_manager
+ pub async fn confirm_tool(
+ &self,
+ tool_id: &str,
+ updated_input: Option,
+ ) -> BitFunResult<()> {
+ let task = self
+ .state_manager
.get_task(tool_id)
.ok_or_else(|| BitFunError::NotFound(format!("Tool task not found: {}", tool_id)))?;
-
+
// Check if the state is waiting for confirmation
if !matches!(task.state, ToolExecutionState::AwaitingConfirmation { .. }) {
return Err(BitFunError::Validation(format!(
@@ -839,29 +993,33 @@ impl ToolPipeline {
task.state
)));
}
-
+
// If the user modified the parameters, update the task parameters first
if let Some(new_args) = updated_input {
debug!("User updated tool arguments: tool_id={}", tool_id);
self.state_manager.update_task_arguments(tool_id, new_args);
}
-
+
// Get sender from map and send confirmation response
if let Some((_, tx)) = self.confirmation_channels.remove(tool_id) {
let _ = tx.send(ConfirmationResponse::Confirmed);
info!("User confirmed tool execution: tool_id={}", tool_id);
Ok(())
} else {
- Err(BitFunError::NotFound(format!("Confirmation channel not found: {}", tool_id)))
+ Err(BitFunError::NotFound(format!(
+ "Confirmation channel not found: {}",
+ tool_id
+ )))
}
}
-
+
/// Reject tool execution
pub async fn reject_tool(&self, tool_id: &str, reason: String) -> BitFunResult<()> {
- let task = self.state_manager
+ let task = self
+ .state_manager
.get_task(tool_id)
.ok_or_else(|| BitFunError::NotFound(format!("Tool task not found: {}", tool_id)))?;
-
+
// Check if the state is waiting for confirmation
if !matches!(task.state, ToolExecutionState::AwaitingConfirmation { .. }) {
return Err(BitFunError::Validation(format!(
@@ -869,22 +1027,27 @@ impl ToolPipeline {
task.state
)));
}
-
+
// Get sender from map and send rejection response
if let Some((_, tx)) = self.confirmation_channels.remove(tool_id) {
let _ = tx.send(ConfirmationResponse::Rejected(reason.clone()));
- info!("User rejected tool execution: tool_id={}, reason={}", tool_id, reason);
+ info!(
+ "User rejected tool execution: tool_id={}, reason={}",
+ tool_id, reason
+ );
Ok(())
} else {
// If the channel does not exist, mark it as cancelled directly
self.state_manager
- .update_state(tool_id, ToolExecutionState::Cancelled {
- reason: format!("User rejected: {}", reason),
- })
+ .update_state(
+ tool_id,
+ ToolExecutionState::Cancelled {
+ reason: format!("User rejected: {}", reason),
+ },
+ )
.await;
-
+
Ok(())
}
}
}
-
diff --git a/src/crates/core/src/agentic/tools/pipeline/types.rs b/src/crates/core/src/agentic/tools/pipeline/types.rs
index 9c75bad0..2594b38e 100644
--- a/src/crates/core/src/agentic/tools/pipeline/types.rs
+++ b/src/crates/core/src/agentic/tools/pipeline/types.rs
@@ -73,7 +73,11 @@ pub struct ToolTask {
}
impl ToolTask {
- pub fn new(tool_call: ToolCall, context: ToolExecutionContext, options: ToolExecutionOptions) -> Self {
+ pub fn new(
+ tool_call: ToolCall,
+ context: ToolExecutionContext,
+ options: ToolExecutionOptions,
+ ) -> Self {
Self {
tool_call,
context,
@@ -94,4 +98,3 @@ pub struct ToolExecutionResult {
pub result: crate::agentic::core::ToolResult,
pub execution_time_ms: u64,
}
-
diff --git a/src/crates/core/src/agentic/tools/registry.rs b/src/crates/core/src/agentic/tools/registry.rs
index 6b081acf..85dfd2f2 100644
--- a/src/crates/core/src/agentic/tools/registry.rs
+++ b/src/crates/core/src/agentic/tools/registry.rs
@@ -160,11 +160,11 @@ impl ToolRegistry {
}
/// Get all tools
-/// - Snapshot initialized:
+/// - Snapshot initialized:
/// return tools only in the snapshot manager (wrapped file tools + built-in non-file tools)
/// **not containing** dynamically registered MCP tools.
-/// - Snapshot not initialized:
-/// return all tools in the global registry,
+/// - Snapshot not initialized:
+/// return all tools in the global registry,
/// **containing** MCP tools.
/// If you need **always include** MCP tools, use [get_all_registered_tools]
pub async fn get_all_tools() -> Vec> {
@@ -221,7 +221,7 @@ pub fn get_global_tool_registry() -> Arc> {
}
/// Get all registered tools (**always include** dynamically registered MCP tools)
-/// - Snapshot initialized:
+/// - Snapshot initialized:
/// return wrapped file tools + other tools in the global registry (containing MCP tools)
/// - Snapshot not initialized: return all tools in the global registry.
pub async fn get_all_registered_tools() -> Vec> {
diff --git a/src/crates/core/src/agentic/util/mod.rs b/src/crates/core/src/agentic/util/mod.rs
index 21877382..ccd0765a 100644
--- a/src/crates/core/src/agentic/util/mod.rs
+++ b/src/crates/core/src/agentic/util/mod.rs
@@ -1,3 +1,3 @@
pub mod list_files;
-pub use list_files::get_formatted_files_list;
\ No newline at end of file
+pub use list_files::get_formatted_files_list;
diff --git a/src/crates/core/src/function_agents/git-func-agent/ai_service.rs b/src/crates/core/src/function_agents/git-func-agent/ai_service.rs
index 005f2154..f2d18259 100644
--- a/src/crates/core/src/function_agents/git-func-agent/ai_service.rs
+++ b/src/crates/core/src/function_agents/git-func-agent/ai_service.rs
@@ -1,18 +1,17 @@
+use super::types::{
+ AICommitAnalysis, AgentError, AgentResult, CommitFormat, CommitMessageOptions, CommitType,
+ Language, ProjectContext,
+};
+use crate::infrastructure::ai::AIClient;
+use crate::util::types::Message;
/**
* AI service layer
*
* Handles AI client interaction and provides intelligent analysis for commit message generation
*/
-
use log::{debug, error, warn};
-use super::types::{
- AgentError, AgentResult, AICommitAnalysis, CommitFormat,
- CommitMessageOptions, CommitType, Language, ProjectContext,
-};
-use crate::infrastructure::ai::AIClient;
-use crate::util::types::Message;
-use std::sync::Arc;
use serde_json::Value;
+use std::sync::Arc;
/// Prompt template constants (embedded at compile time)
const COMMIT_MESSAGE_PROMPT: &str = include_str!("prompts/commit_message.md");
@@ -24,21 +23,22 @@ pub struct AIAnalysisService {
impl AIAnalysisService {
pub async fn new_with_agent_config(
factory: std::sync::Arc,
- agent_name: &str
+ agent_name: &str,
) -> AgentResult {
let ai_client = match factory.get_client_by_func_agent(agent_name).await {
Ok(client) => client,
Err(e) => {
error!("Failed to get AI client: {}", e);
- return Err(AgentError::internal_error(format!("Failed to get AI client: {}", e)));
+ return Err(AgentError::internal_error(format!(
+ "Failed to get AI client: {}",
+ e
+ )));
}
};
-
- Ok(Self {
- ai_client,
- })
+
+ Ok(Self { ai_client })
}
-
+
pub async fn generate_commit_message_ai(
&self,
diff_content: &str,
@@ -48,42 +48,44 @@ impl AIAnalysisService {
if diff_content.is_empty() {
return Err(AgentError::invalid_input("Code changes are empty"));
}
-
+
let processed_diff = self.truncate_diff_if_needed(diff_content, 50000);
-
- let prompt = self.build_commit_prompt(
- &processed_diff,
- project_context,
- options,
- );
-
+
+ let prompt = self.build_commit_prompt(&processed_diff, project_context, options);
+
let ai_response = self.call_ai(&prompt).await?;
-
+
self.parse_commit_response(&ai_response)
}
-
+
async fn call_ai(&self, prompt: &str) -> AgentResult {
debug!("Sending request to AI: prompt_length={}", prompt.len());
-
+
let messages = vec![Message::user(prompt.to_string())];
- let response = self.ai_client
+ let response = self
+ .ai_client
.send_message(messages, None)
.await
.map_err(|e| {
error!("AI call failed: {}", e);
AgentError::internal_error(format!("AI call failed: {}", e))
})?;
-
- debug!("AI response received: response_length={}", response.text.len());
-
+
+ debug!(
+ "AI response received: response_length={}",
+ response.text.len()
+ );
+
if response.text.is_empty() {
error!("AI response is empty");
- Err(AgentError::internal_error("AI response is empty".to_string()))
+ Err(AgentError::internal_error(
+ "AI response is empty".to_string(),
+ ))
} else {
Ok(response.text)
}
}
-
+
fn build_commit_prompt(
&self,
diff_content: &str,
@@ -94,14 +96,14 @@ impl AIAnalysisService {
Language::Chinese => "Chinese",
Language::English => "English",
};
-
+
let format_desc = match options.format {
CommitFormat::Conventional => "Conventional Commits",
CommitFormat::Angular => "Angular Style",
CommitFormat::Simple => "Simple Format",
CommitFormat::Custom => "Custom Format",
};
-
+
COMMIT_MESSAGE_PROMPT
.replace("{project_type}", &project_context.project_type)
.replace("{tech_stack}", &project_context.tech_stack.join(", "))
@@ -110,13 +112,14 @@ impl AIAnalysisService {
.replace("{diff_content}", diff_content)
.replace("{max_title_length}", &options.max_title_length.to_string())
}
-
+
fn parse_commit_response(&self, response: &str) -> AgentResult {
let json_str = self.extract_json_from_response(response)?;
-
- let value: Value = serde_json::from_str(&json_str)
- .map_err(|e| AgentError::analysis_error(format!("Failed to parse AI response: {}", e)))?;
-
+
+ let value: Value = serde_json::from_str(&json_str).map_err(|e| {
+ AgentError::analysis_error(format!("Failed to parse AI response: {}", e))
+ })?;
+
Ok(AICommitAnalysis {
commit_type: self.parse_commit_type(value["type"].as_str().unwrap_or("chore"))?,
scope: value["scope"].as_str().map(|s| s.to_string()),
@@ -130,51 +133,55 @@ impl AIAnalysisService {
.as_str()
.unwrap_or("AI analysis")
.to_string(),
- confidence: value["confidence"]
- .as_f64()
- .unwrap_or(0.8) as f32,
+ confidence: value["confidence"].as_f64().unwrap_or(0.8) as f32,
})
}
-
+
fn extract_json_from_response(&self, response: &str) -> AgentResult {
let trimmed = response.trim();
-
+
if trimmed.starts_with('{') {
return Ok(trimmed.to_string());
}
-
+
if let Some(start) = trimmed.find("```json") {
- let json_start = start + 7;
+ let json_start = start + 7;
if let Some(end_offset) = trimmed[json_start..].find("```") {
let json_end = json_start + end_offset;
let json_str = trimmed[json_start..json_end].trim();
return Ok(json_str.to_string());
}
}
-
+
if let Some(start) = trimmed.find('{') {
if let Some(end) = trimmed.rfind('}') {
let json_str = &trimmed[start..=end];
return Ok(json_str.to_string());
}
}
-
- Err(AgentError::analysis_error("Cannot extract JSON from response"))
+
+ Err(AgentError::analysis_error(
+ "Cannot extract JSON from response",
+ ))
}
-
+
fn truncate_diff_if_needed(&self, diff: &str, max_chars: usize) -> String {
if diff.len() <= max_chars {
return diff.to_string();
}
-
- warn!("Diff too large ({} chars), truncating to {} chars", diff.len(), max_chars);
-
+
+ warn!(
+ "Diff too large ({} chars), truncating to {} chars",
+ diff.len(),
+ max_chars
+ );
+
let mut truncated = diff.chars().take(max_chars - 100).collect::();
truncated.push_str("\n\n... [content truncated] ...");
-
+
truncated
}
-
+
fn parse_commit_type(&self, s: &str) -> AgentResult {
match s.to_lowercase().as_str() {
"feat" | "feature" => Ok(CommitType::Feat),
diff --git a/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs b/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs
index a06e63d1..0123b178 100644
--- a/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs
+++ b/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs
@@ -1,15 +1,14 @@
+use super::ai_service::AIAnalysisService;
+use super::context_analyzer::ContextAnalyzer;
+use super::types::*;
+use crate::infrastructure::ai::AIClientFactory;
+use crate::service::git::{GitDiffParams, GitService};
/**
* Git Function Agent - commit message generator
*
* Uses AI to deeply analyze code changes and generate compliant commit messages
*/
-
use log::{debug, info};
-use super::types::*;
-use super::ai_service::AIAnalysisService;
-use super::context_analyzer::ContextAnalyzer;
-use crate::service::git::{GitService, GitDiffParams};
-use crate::infrastructure::ai::AIClientFactory;
use std::path::Path;
use std::sync::Arc;
@@ -21,48 +20,64 @@ impl CommitGenerator {
options: CommitMessageOptions,
factory: Arc,
) -> AgentResult {
- info!("Generating commit message (AI-driven): repo_path={:?}", repo_path);
-
+ info!(
+ "Generating commit message (AI-driven): repo_path={:?}",
+ repo_path
+ );
+
let status = GitService::get_status(repo_path)
.await
.map_err(|e| AgentError::git_error(format!("Failed to get Git status: {}", e)))?;
-
+
let changed_files: Vec = status.staged.iter().map(|f| f.path.clone()).collect();
-
+
if changed_files.is_empty() {
- return Err(AgentError::invalid_input("Staging area is empty, please stage files first"));
+ return Err(AgentError::invalid_input(
+ "Staging area is empty, please stage files first",
+ ));
}
-
- debug!("Staged files: count={}, files={:?}", changed_files.len(), changed_files);
-
+
+ debug!(
+ "Staged files: count={}, files={:?}",
+ changed_files.len(),
+ changed_files
+ );
+
let diff_content = Self::get_full_diff(repo_path).await?;
-
+
if diff_content.trim().is_empty() {
return Err(AgentError::invalid_input("Diff content is empty"));
}
-
+
let project_context = ContextAnalyzer::analyze_project_context(repo_path)
.await
.unwrap_or_default(); // Fallback to default on failure
-
- debug!("Project context: type={}, tech_stack={:?}", project_context.project_type, project_context.tech_stack);
-
- let ai_service = AIAnalysisService::new_with_agent_config(factory, "git-func-agent").await?;
-
+
+ debug!(
+ "Project context: type={}, tech_stack={:?}",
+ project_context.project_type, project_context.tech_stack
+ );
+
+ let ai_service =
+ AIAnalysisService::new_with_agent_config(factory, "git-func-agent").await?;
+
let ai_analysis = ai_service
.generate_commit_message_ai(&diff_content, &project_context, &options)
.await?;
-
- debug!("AI analysis completed: commit_type={:?}, confidence={}", ai_analysis.commit_type, ai_analysis.confidence);
-
+
+ debug!(
+ "AI analysis completed: commit_type={:?}, confidence={}",
+ ai_analysis.commit_type, ai_analysis.confidence
+ );
+
let changes_summary = Self::build_changes_summary(&status, &changed_files);
-
+
let full_message = Self::assemble_full_message(
&ai_analysis.title,
&ai_analysis.body,
&ai_analysis.breaking_changes,
);
-
+
Ok(CommitMessage {
title: ai_analysis.title,
body: ai_analysis.body,
@@ -74,7 +89,7 @@ impl CommitGenerator {
changes_summary,
})
}
-
+
async fn get_full_diff(repo_path: &Path) -> AgentResult {
let diff_params = GitDiffParams {
staged: Some(true),
@@ -82,24 +97,24 @@ impl CommitGenerator {
files: None,
..Default::default()
};
-
+
let diff = GitService::get_diff(repo_path, &diff_params)
.await
.map_err(|e| AgentError::git_error(format!("Failed to get diff: {}", e)))?;
-
+
debug!("Got staged diff: length={} chars", diff.len());
Ok(diff)
}
-
+
fn build_changes_summary(
status: &crate::service::git::GitStatus,
changed_files: &[String],
) -> ChangesSummary {
- let total_additions = status.staged.iter().map(|_| 10u32).sum::() +
- status.unstaged.iter().map(|_| 10u32).sum::();
- let total_deletions = status.staged.iter().map(|_| 5u32).sum::() +
- status.unstaged.iter().map(|_| 5u32).sum::();
-
+ let total_additions = status.staged.iter().map(|_| 10u32).sum::()
+ + status.unstaged.iter().map(|_| 10u32).sum::();
+ let total_deletions = status.staged.iter().map(|_| 5u32).sum::()
+ + status.unstaged.iter().map(|_| 5u32).sum::();
+
let file_changes: Vec = changed_files
.iter()
.map(|path| {
@@ -113,7 +128,7 @@ impl CommitGenerator {
}
})
.collect();
-
+
let affected_modules: Vec = changed_files
.iter()
.filter_map(|path| super::utils::extract_module_name(path))
@@ -121,9 +136,9 @@ impl CommitGenerator {
.into_iter()
.take(3)
.collect();
-
+
let change_patterns = super::utils::detect_change_patterns(&file_changes);
-
+
ChangesSummary {
total_additions,
total_deletions,
@@ -133,28 +148,28 @@ impl CommitGenerator {
change_patterns,
}
}
-
+
fn assemble_full_message(
title: &str,
body: &Option,
footer: &Option,
) -> String {
let mut parts = vec![title.to_string()];
-
+
if let Some(body_text) = body {
if !body_text.is_empty() {
parts.push(String::new());
parts.push(body_text.clone());
}
}
-
+
if let Some(footer_text) = footer {
if !footer_text.is_empty() {
parts.push(String::new());
parts.push(footer_text.clone());
}
}
-
+
parts.join("\n")
}
}
diff --git a/src/crates/core/src/function_agents/git-func-agent/context_analyzer.rs b/src/crates/core/src/function_agents/git-func-agent/context_analyzer.rs
index dc4f46a8..d30de87d 100644
--- a/src/crates/core/src/function_agents/git-func-agent/context_analyzer.rs
+++ b/src/crates/core/src/function_agents/git-func-agent/context_analyzer.rs
@@ -1,28 +1,27 @@
+use super::types::*;
/**
* Context analyzer
*
* Provides project context for AI to better understand code changes
*/
-
use log::debug;
-use super::types::*;
-use std::path::Path;
use std::fs;
+use std::path::Path;
pub struct ContextAnalyzer;
impl ContextAnalyzer {
pub async fn analyze_project_context(repo_path: &Path) -> AgentResult {
debug!("Analyzing project context: repo_path={:?}", repo_path);
-
+
let project_type = Self::detect_project_type(repo_path)?;
-
+
let tech_stack = Self::detect_tech_stack(repo_path)?;
-
+
let project_docs = Self::read_project_docs(repo_path);
-
+
let code_standards = Self::detect_code_standards(repo_path);
-
+
Ok(ProjectContext {
project_type,
tech_stack,
@@ -30,22 +29,22 @@ impl ContextAnalyzer {
code_standards,
})
}
-
+
fn detect_project_type(repo_path: &Path) -> AgentResult {
if repo_path.join("Cargo.toml").exists() {
if repo_path.join("src-tauri").exists() {
return Ok("tauri-app".to_string());
}
-
+
if let Ok(content) = fs::read_to_string(repo_path.join("Cargo.toml")) {
if content.contains("[lib]") {
return Ok("rust-library".to_string());
}
}
-
+
return Ok("rust-application".to_string());
}
-
+
if repo_path.join("package.json").exists() {
if let Ok(content) = fs::read_to_string(repo_path.join("package.json")) {
if content.contains("\"react\"") {
@@ -60,32 +59,33 @@ impl ContextAnalyzer {
}
return Ok("nodejs-app".to_string());
}
-
+
if repo_path.join("go.mod").exists() {
return Ok("go-application".to_string());
}
-
- if repo_path.join("requirements.txt").exists() || repo_path.join("pyproject.toml").exists() {
+
+ if repo_path.join("requirements.txt").exists() || repo_path.join("pyproject.toml").exists()
+ {
return Ok("python-application".to_string());
}
-
+
if repo_path.join("pom.xml").exists() {
return Ok("java-maven-app".to_string());
}
-
+
if repo_path.join("build.gradle").exists() {
return Ok("java-gradle-app".to_string());
}
-
+
Ok("unknown".to_string())
}
-
+
fn detect_tech_stack(repo_path: &Path) -> AgentResult> {
let mut stack = Vec::new();
-
+
if repo_path.join("Cargo.toml").exists() {
stack.push("Rust".to_string());
-
+
if let Ok(content) = fs::read_to_string(repo_path.join("Cargo.toml")) {
if content.contains("tokio") {
stack.push("Tokio".to_string());
@@ -101,7 +101,7 @@ impl ContextAnalyzer {
}
}
}
-
+
if repo_path.join("package.json").exists() {
if let Ok(content) = fs::read_to_string(repo_path.join("package.json")) {
if content.contains("\"typescript\"") {
@@ -109,7 +109,7 @@ impl ContextAnalyzer {
} else {
stack.push("JavaScript".to_string());
}
-
+
if content.contains("\"react\"") {
stack.push("React".to_string());
}
@@ -124,19 +124,20 @@ impl ContextAnalyzer {
}
}
}
-
+
if repo_path.join("go.mod").exists() {
stack.push("Go".to_string());
}
-
- if repo_path.join("requirements.txt").exists() || repo_path.join("pyproject.toml").exists() {
+
+ if repo_path.join("requirements.txt").exists() || repo_path.join("pyproject.toml").exists()
+ {
stack.push("Python".to_string());
}
-
+
if repo_path.join("pom.xml").exists() || repo_path.join("build.gradle").exists() {
stack.push("Java".to_string());
}
-
+
if let Ok(entries) = fs::read_dir(repo_path) {
for entry in entries.flatten() {
let path = entry.path();
@@ -156,17 +157,17 @@ impl ContextAnalyzer {
}
}
}
-
+
if stack.is_empty() {
stack.push("Unknown".to_string());
}
-
+
Ok(stack)
}
-
+
fn read_project_docs(repo_path: &Path) -> Option {
let readme_paths = ["README.md", "README", "README.txt", "readme.md"];
-
+
for readme_name in &readme_paths {
let readme_path = repo_path.join(readme_name);
if readme_path.exists() {
@@ -176,40 +177,41 @@ impl ContextAnalyzer {
}
}
}
-
+
None
}
-
+
fn detect_code_standards(repo_path: &Path) -> Option {
let mut standards = Vec::new();
-
+
if repo_path.join("rustfmt.toml").exists() || repo_path.join(".rustfmt.toml").exists() {
standards.push("rustfmt");
}
if repo_path.join("clippy.toml").exists() {
standards.push("clippy");
}
-
- if repo_path.join(".eslintrc.js").exists() ||
- repo_path.join(".eslintrc.json").exists() ||
- repo_path.join("eslint.config.js").exists() {
+
+ if repo_path.join(".eslintrc.js").exists()
+ || repo_path.join(".eslintrc.json").exists()
+ || repo_path.join("eslint.config.js").exists()
+ {
standards.push("ESLint");
}
if repo_path.join(".prettierrc").exists() || repo_path.join("prettier.config.js").exists() {
standards.push("Prettier");
}
-
+
if repo_path.join(".flake8").exists() {
standards.push("flake8");
}
if repo_path.join(".pylintrc").exists() {
standards.push("pylint");
}
-
+
if repo_path.join(".editorconfig").exists() {
standards.push("EditorConfig");
}
-
+
if standards.is_empty() {
None
} else {
diff --git a/src/crates/core/src/function_agents/git-func-agent/mod.rs b/src/crates/core/src/function_agents/git-func-agent/mod.rs
index d7e74eb1..cf39872e 100644
--- a/src/crates/core/src/function_agents/git-func-agent/mod.rs
+++ b/src/crates/core/src/function_agents/git-func-agent/mod.rs
@@ -1,20 +1,19 @@
+pub mod ai_service;
+pub mod commit_generator;
+pub mod context_analyzer;
/**
* Git Function Agent - module entry
*
* Provides Git-related intelligent functions:
* - Automatic commit message generation
*/
-
pub mod types;
pub mod utils;
-pub mod ai_service;
-pub mod context_analyzer;
-pub mod commit_generator;
-pub use types::*;
pub use ai_service::AIAnalysisService;
-pub use context_analyzer::ContextAnalyzer;
pub use commit_generator::CommitGenerator;
+pub use context_analyzer::ContextAnalyzer;
+pub use types::*;
use crate::infrastructure::ai::AIClientFactory;
use std::path::Path;
@@ -29,7 +28,7 @@ impl GitFunctionAgent {
pub fn new(factory: Arc) -> Self {
Self { factory }
}
-
+
pub async fn generate_commit_message(
&self,
repo_path: &Path,
@@ -37,9 +36,10 @@ impl GitFunctionAgent {
) -> AgentResult {
CommitGenerator::generate_commit_message(repo_path, options, self.factory.clone()).await
}
-
+
/// Quickly generate commit message (use default options)
pub async fn quick_commit_message(&self, repo_path: &Path) -> AgentResult {
- self.generate_commit_message(repo_path, CommitMessageOptions::default()).await
+ self.generate_commit_message(repo_path, CommitMessageOptions::default())
+ .await
}
}
diff --git a/src/crates/core/src/function_agents/git-func-agent/types.rs b/src/crates/core/src/function_agents/git-func-agent/types.rs
index ec0a937c..70c0b03f 100644
--- a/src/crates/core/src/function_agents/git-func-agent/types.rs
+++ b/src/crates/core/src/function_agents/git-func-agent/types.rs
@@ -3,7 +3,6 @@
*
* Defines data structures for commit message generation
*/
-
use serde::{Deserialize, Serialize};
use std::fmt;
@@ -12,16 +11,16 @@ use std::fmt;
pub struct CommitMessageOptions {
#[serde(default = "default_commit_format")]
pub format: CommitFormat,
-
+
#[serde(default = "default_true")]
pub include_files: bool,
-
+
#[serde(default = "default_max_length")]
pub max_title_length: usize,
-
+
#[serde(default = "default_true")]
pub include_body: bool,
-
+
#[serde(default = "default_language")]
pub language: Language,
}
@@ -77,21 +76,21 @@ pub enum Language {
pub struct CommitMessage {
/// Title (50-72 chars)
pub title: String,
-
+
pub body: Option,
-
+
/// Footer info (breaking changes, etc.)
pub footer: Option,
-
+
pub full_message: String,
-
+
pub commit_type: CommitType,
-
+
pub scope: Option,
-
+
/// Confidence (0.0-1.0)
pub confidence: f32,
-
+
pub changes_summary: ChangesSummary,
}
@@ -141,15 +140,15 @@ impl fmt::Display for CommitType {
#[serde(rename_all = "camelCase")]
pub struct ChangesSummary {
pub total_additions: u32,
-
+
pub total_deletions: u32,
-
+
pub files_changed: u32,
-
+
pub file_changes: Vec,
-
+
pub affected_modules: Vec,
-
+
pub change_patterns: Vec,
}
@@ -157,13 +156,13 @@ pub struct ChangesSummary {
#[serde(rename_all = "camelCase")]
pub struct FileChange {
pub path: String,
-
+
pub change_type: FileChangeType,
-
+
pub additions: u32,
-
+
pub deletions: u32,
-
+
pub file_type: String,
}
@@ -216,21 +215,21 @@ impl AgentError {
error_type: AgentErrorType::GitError,
}
}
-
+
pub fn analysis_error(msg: impl Into) -> Self {
Self {
message: msg.into(),
error_type: AgentErrorType::AnalysisError,
}
}
-
+
pub fn invalid_input(msg: impl Into) -> Self {
Self {
message: msg.into(),
error_type: AgentErrorType::InvalidInput,
}
}
-
+
pub fn internal_error(msg: impl Into) -> Self {
Self {
message: msg.into(),
@@ -245,11 +244,11 @@ pub type AgentResult = Result;
pub struct ProjectContext {
/// Project type (e.g., web-app, library, cli-tool, etc.)
pub project_type: String,
-
+
pub tech_stack: Vec,
-
+
pub project_docs: Option,
-
+
pub code_standards: Option,
}
@@ -267,16 +266,16 @@ impl Default for ProjectContext {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AICommitAnalysis {
pub commit_type: CommitType,
-
+
pub scope: Option,
-
+
pub title: String,
-
+
pub body: Option,
-
+
pub breaking_changes: Option,
-
+
pub reasoning: String,
-
+
pub confidence: f32,
}
diff --git a/src/crates/core/src/function_agents/git-func-agent/utils.rs b/src/crates/core/src/function_agents/git-func-agent/utils.rs
index d04d805f..dd191e42 100644
--- a/src/crates/core/src/function_agents/git-func-agent/utils.rs
+++ b/src/crates/core/src/function_agents/git-func-agent/utils.rs
@@ -3,7 +3,6 @@
*
* Provides various helper utilities
*/
-
use super::types::*;
use std::path::Path;
@@ -17,56 +16,71 @@ pub fn infer_file_type(path: &str) -> String {
pub fn extract_module_name(path: &str) -> Option {
let path = Path::new(path);
-
+
if let Some(parent) = path.parent() {
if let Some(dir_name) = parent.file_name() {
return Some(dir_name.to_string_lossy().to_string());
}
}
-
+
path.file_stem()
.map(|name| name.to_string_lossy().to_string())
}
pub fn is_config_file(path: &str) -> bool {
let config_patterns = [
- ".json", ".yaml", ".yml", ".toml", ".xml", ".ini", ".conf",
- "config", "package.json", "cargo.toml", "tsconfig",
+ ".json",
+ ".yaml",
+ ".yml",
+ ".toml",
+ ".xml",
+ ".ini",
+ ".conf",
+ "config",
+ "package.json",
+ "cargo.toml",
+ "tsconfig",
];
-
+
let path_lower = path.to_lowercase();
- config_patterns.iter().any(|pattern| path_lower.contains(pattern))
+ config_patterns
+ .iter()
+ .any(|pattern| path_lower.contains(pattern))
}
pub fn is_doc_file(path: &str) -> bool {
let doc_patterns = [".md", ".txt", ".rst", "readme", "changelog", "license"];
-
+
let path_lower = path.to_lowercase();
- doc_patterns.iter().any(|pattern| path_lower.contains(pattern))
+ doc_patterns
+ .iter()
+ .any(|pattern| path_lower.contains(pattern))
}
pub fn is_test_file(path: &str) -> bool {
let test_patterns = ["test", "spec", "__tests__", ".test.", ".spec."];
-
+
let path_lower = path.to_lowercase();
- test_patterns.iter().any(|pattern| path_lower.contains(pattern))
+ test_patterns
+ .iter()
+ .any(|pattern| path_lower.contains(pattern))
}
pub fn detect_change_patterns(file_changes: &[FileChange]) -> Vec {
let mut patterns = Vec::new();
-
+
let mut has_code_changes = false;
let mut has_test_changes = false;
let mut has_doc_changes = false;
let mut has_config_changes = false;
let mut has_new_files = false;
-
+
for change in file_changes {
match change.change_type {
FileChangeType::Added => has_new_files = true,
_ => {}
}
-
+
if is_test_file(&change.path) {
has_test_changes = true;
} else if is_doc_file(&change.path) {
@@ -77,43 +91,44 @@ pub fn detect_change_patterns(file_changes: &[FileChange]) -> Vec
has_code_changes = true;
}
}
-
+
if has_new_files && has_code_changes {
patterns.push(ChangePattern::FeatureAddition);
}
-
+
if has_code_changes && !has_new_files {
patterns.push(ChangePattern::BugFix);
}
-
+
if has_test_changes {
patterns.push(ChangePattern::TestUpdate);
}
-
+
if has_doc_changes {
patterns.push(ChangePattern::DocumentationUpdate);
}
-
+
if has_config_changes {
- if file_changes.iter().any(|f|
- f.path.contains("package.json") ||
- f.path.contains("cargo.toml") ||
- f.path.contains("requirements.txt")
- ) {
+ if file_changes.iter().any(|f| {
+ f.path.contains("package.json")
+ || f.path.contains("cargo.toml")
+ || f.path.contains("requirements.txt")
+ }) {
patterns.push(ChangePattern::DependencyUpdate);
} else {
patterns.push(ChangePattern::ConfigChange);
}
}
-
+
// Large code changes with few files may indicate refactoring
- let total_lines = file_changes.iter()
+ let total_lines = file_changes
+ .iter()
.map(|f| f.additions + f.deletions)
.sum::();
-
+
if has_code_changes && total_lines > 200 && file_changes.len() < 5 {
patterns.push(ChangePattern::Refactoring);
}
-
+
patterns
}
diff --git a/src/crates/core/src/function_agents/mod.rs b/src/crates/core/src/function_agents/mod.rs
index dce5cd56..3e90239c 100644
--- a/src/crates/core/src/function_agents/mod.rs
+++ b/src/crates/core/src/function_agents/mod.rs
@@ -13,19 +13,9 @@ pub mod startchat_func_agent;
pub use git_func_agent::GitFunctionAgent;
pub use startchat_func_agent::StartchatFunctionAgent;
-pub use git_func_agent::{
- CommitMessage,
- CommitMessageOptions,
- CommitFormat,
- CommitType,
-};
+pub use git_func_agent::{CommitFormat, CommitMessage, CommitMessageOptions, CommitType};
pub use startchat_func_agent::{
- WorkStateAnalysis,
- WorkStateOptions,
- GreetingMessage,
- CurrentWorkState,
- GitWorkState,
- PredictedAction,
- QuickAction,
+ CurrentWorkState, GitWorkState, GreetingMessage, PredictedAction, QuickAction,
+ WorkStateAnalysis, WorkStateOptions,
};
diff --git a/src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs b/src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs
index f5c73fa0..57fca80a 100644
--- a/src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs
+++ b/src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs
@@ -1,13 +1,12 @@
+use super::types::*;
+use crate::infrastructure::ai::AIClient;
+use crate::util::types::Message;
/**
* AI analysis service
*
* Provides AI-driven work state analysis for the Startchat function agent
*/
-
-use log::{debug, warn, error};
-use super::types::*;
-use crate::infrastructure::ai::AIClient;
-use crate::util::types::Message;
+use log::{debug, error, warn};
use std::sync::Arc;
/// Prompt template constants (embedded at compile time)
@@ -20,21 +19,22 @@ pub struct AIWorkStateService {
impl AIWorkStateService {
pub async fn new_with_agent_config(
factory: Arc,
- agent_name: &str
+ agent_name: &str,
) -> AgentResult {
let ai_client = match factory.get_client_by_func_agent(agent_name).await {
Ok(client) => client,
Err(e) => {
error!("Failed to get AI client: {}", e);
- return Err(AgentError::internal_error(format!("Failed to get AI client: {}", e)));
+ return Err(AgentError::internal_error(format!(
+ "Failed to get AI client: {}",
+ e
+ )));
}
};
-
- Ok(Self {
- ai_client,
- })
+
+ Ok(Self { ai_client })
}
-
+
pub async fn generate_complete_analysis(
&self,
git_state: &Option,
@@ -42,36 +42,45 @@ impl AIWorkStateService {
language: &Language,
) -> AgentResult {
let prompt = self.build_complete_analysis_prompt(git_state, git_diff, language);
-
- debug!("Calling AI to generate complete analysis: prompt_length={}", prompt.len());
-
+
+ debug!(
+ "Calling AI to generate complete analysis: prompt_length={}",
+ prompt.len()
+ );
+
let response = self.call_ai(&prompt).await?;
-
+
self.parse_complete_analysis(&response)
}
-
+
async fn call_ai(&self, prompt: &str) -> AgentResult {
debug!("Sending request to AI: prompt_length={}", prompt.len());
-
+
let messages = vec![Message::user(prompt.to_string())];
- let response = self.ai_client
+ let response = self
+ .ai_client
.send_message(messages, None)
.await
.map_err(|e| {
error!("AI call failed: {}", e);
AgentError::internal_error(format!("AI call failed: {}", e))
})?;
-
- debug!("AI response received: response_length={}", response.text.len());
-
+
+ debug!(
+ "AI response received: response_length={}",
+ response.text.len()
+ );
+
if response.text.is_empty() {
error!("AI response is empty");
- Err(AgentError::internal_error("AI response is empty".to_string()))
+ Err(AgentError::internal_error(
+ "AI response is empty".to_string(),
+ ))
} else {
Ok(response.text)
}
}
-
+
fn build_complete_analysis_prompt(
&self,
git_state: &Option,
@@ -83,14 +92,14 @@ impl AIWorkStateService {
Language::Chinese => "Please respond in Chinese.",
Language::English => "Please respond in English.",
};
-
+
// Build Git state section
let git_state_section = if let Some(git) = git_state {
let mut section = format!(
"## Git Status\n\n- Current branch: {}\n- Unstaged files: {}\n- Staged files: {}\n- Unpushed commits: {}\n",
git.current_branch, git.unstaged_files, git.staged_files, git.unpushed_commits
);
-
+
if !git.modified_files.is_empty() {
section.push_str("\nModified files:\n");
for file in git.modified_files.iter().take(10) {
@@ -101,12 +110,13 @@ impl AIWorkStateService {
} else {
String::new()
};
-
+
// Build Git diff section
let git_diff_section = if !git_diff.is_empty() {
let max_diff_length = 8000;
if git_diff.len() > max_diff_length {
- let truncated_diff = git_diff.char_indices()
+ let truncated_diff = git_diff
+ .char_indices()
.take_while(|(idx, _)| *idx < max_diff_length)
.map(|(_, c)| c)
.collect::();
@@ -120,14 +130,14 @@ impl AIWorkStateService {
} else {
String::new()
};
-
+
// Use template replacement
WORK_STATE_ANALYSIS_PROMPT
.replace("{lang_instruction}", lang_instruction)
.replace("{git_state_section}", &git_state_section)
.replace("{git_diff_section}", &git_diff_section)
}
-
+
fn parse_complete_analysis(&self, response: &str) -> AgentResult {
let json_str = if let Some(start) = response.find('{') {
if let Some(end) = response.rfind('}') {
@@ -138,30 +148,36 @@ impl AIWorkStateService {
} else {
response
};
-
+
debug!("Parsing JSON response: length={}", json_str.len());
-
- let parsed: serde_json::Value = serde_json::from_str(json_str)
- .map_err(|e| {
- error!("Failed to parse complete analysis response: {}, response: {}", e, response);
- AgentError::internal_error(format!("Failed to parse complete analysis response: {}", e))
- })?;
-
+
+ let parsed: serde_json::Value = serde_json::from_str(json_str).map_err(|e| {
+ error!(
+ "Failed to parse complete analysis response: {}, response: {}",
+ e, response
+ );
+ AgentError::internal_error(format!("Failed to parse complete analysis response: {}", e))
+ })?;
+
let summary = parsed["summary"]
.as_str()
.unwrap_or("You were working on development, with multiple files modified.")
.to_string();
-
+
let ongoing_work = Vec::new();
-
- let mut predicted_actions = if let Some(actions_array) = parsed["predicted_actions"].as_array() {
- self.parse_predicted_actions_from_value(actions_array)?
- } else {
- Vec::new()
- };
-
+
+ let mut predicted_actions =
+ if let Some(actions_array) = parsed["predicted_actions"].as_array() {
+ self.parse_predicted_actions_from_value(actions_array)?
+ } else {
+ Vec::new()
+ };
+
if predicted_actions.len() < 3 {
- warn!("AI generated insufficient predicted actions ({}), adding defaults", predicted_actions.len());
+ warn!(
+ "AI generated insufficient predicted actions ({}), adding defaults",
+ predicted_actions.len()
+ );
while predicted_actions.len() < 3 {
predicted_actions.push(PredictedAction {
description: "Continue current development".to_string(),
@@ -171,26 +187,39 @@ impl AIWorkStateService {
});
}
} else if predicted_actions.len() > 3 {
- warn!("AI generated too many predicted actions ({}), truncating to 3", predicted_actions.len());
+ warn!(
+ "AI generated too many predicted actions ({}), truncating to 3",
+ predicted_actions.len()
+ );
predicted_actions.truncate(3);
}
-
+
let mut quick_actions = if let Some(actions_array) = parsed["quick_actions"].as_array() {
self.parse_quick_actions_from_value(actions_array)?
} else {
Vec::new()
};
-
+
if quick_actions.len() < 6 {
// Don't fill defaults here, frontend has its own defaultActions with i18n support
- warn!("AI generated insufficient quick actions ({}), frontend will use defaults", quick_actions.len());
+ warn!(
+ "AI generated insufficient quick actions ({}), frontend will use defaults",
+ quick_actions.len()
+ );
} else if quick_actions.len() > 6 {
- warn!("AI generated too many quick actions ({}), truncating to 6", quick_actions.len());
+ warn!(
+ "AI generated too many quick actions ({}), truncating to 6",
+ quick_actions.len()
+ );
quick_actions.truncate(6);
}
-
- debug!("Parsing completed: predicted_actions={}, quick_actions={}", predicted_actions.len(), quick_actions.len());
-
+
+ debug!(
+ "Parsing completed: predicted_actions={}, quick_actions={}",
+ predicted_actions.len(),
+ quick_actions.len()
+ );
+
Ok(AIGeneratedAnalysis {
summary,
ongoing_work,
@@ -198,35 +227,31 @@ impl AIWorkStateService {
quick_actions,
})
}
-
- fn parse_predicted_actions_from_value(&self, actions_array: &[serde_json::Value]) -> AgentResult> {
+
+ fn parse_predicted_actions_from_value(
+ &self,
+ actions_array: &[serde_json::Value],
+ ) -> AgentResult> {
let mut actions = Vec::new();
-
+
for action_value in actions_array {
let description = action_value["description"]
.as_str()
.unwrap_or("Continue current work")
.to_string();
-
- let priority_str = action_value["priority"]
- .as_str()
- .unwrap_or("Medium");
-
+
+ let priority_str = action_value["priority"].as_str().unwrap_or("Medium");
+
let priority = match priority_str {
"High" => ActionPriority::High,
"Low" => ActionPriority::Low,
_ => ActionPriority::Medium,
};
-
- let icon = action_value["icon"]
- .as_str()
- .unwrap_or("")
- .to_string();
-
- let is_reminder = action_value["is_reminder"]
- .as_bool()
- .unwrap_or(false);
-
+
+ let icon = action_value["icon"].as_str().unwrap_or("").to_string();
+
+ let is_reminder = action_value["is_reminder"].as_bool().unwrap_or(false);
+
actions.push(PredictedAction {
description,
priority,
@@ -234,33 +259,28 @@ impl AIWorkStateService {
is_reminder,
});
}
-
+
Ok(actions)
}
-
- fn parse_quick_actions_from_value(&self, actions_array: &[serde_json::Value]) -> AgentResult> {
+
+ fn parse_quick_actions_from_value(
+ &self,
+ actions_array: &[serde_json::Value],
+ ) -> AgentResult> {
let mut quick_actions = Vec::new();
-
+
for action_value in actions_array {
let title = action_value["title"]
.as_str()
.unwrap_or("Quick Action")
.to_string();
-
- let command = action_value["command"]
- .as_str()
- .unwrap_or("")
- .to_string();
-
- let icon = action_value["icon"]
- .as_str()
- .unwrap_or("")
- .to_string();
-
- let action_type_str = action_value["action_type"]
- .as_str()
- .unwrap_or("Custom");
-
+
+ let command = action_value["command"].as_str().unwrap_or("").to_string();
+
+ let icon = action_value["icon"].as_str().unwrap_or("").to_string();
+
+ let action_type_str = action_value["action_type"].as_str().unwrap_or("Custom");
+
let action_type = match action_type_str {
"Continue" => QuickActionType::Continue,
"ViewStatus" => QuickActionType::ViewStatus,
@@ -268,7 +288,7 @@ impl AIWorkStateService {
"Visualize" => QuickActionType::Visualize,
_ => QuickActionType::Custom,
};
-
+
quick_actions.push(QuickAction {
title,
command,
@@ -276,7 +296,7 @@ impl AIWorkStateService {
action_type,
});
}
-
+
Ok(quick_actions)
}
}
diff --git a/src/crates/core/src/function_agents/startchat-func-agent/mod.rs b/src/crates/core/src/function_agents/startchat-func-agent/mod.rs
index 904ae3ea..9c1dec30 100644
--- a/src/crates/core/src/function_agents/startchat-func-agent/mod.rs
+++ b/src/crates/core/src/function_agents/startchat-func-agent/mod.rs
@@ -1,20 +1,19 @@
+pub mod ai_service;
/**
* Startchat Function Agent - module entry
*
* Provides work state analysis and greeting generation on session start
*/
-
pub mod types;
pub mod work_state_analyzer;
-pub mod ai_service;
+pub use ai_service::AIWorkStateService;
pub use types::*;
pub use work_state_analyzer::WorkStateAnalyzer;
-pub use ai_service::AIWorkStateService;
+use crate::infrastructure::ai::AIClientFactory;
use std::path::Path;
use std::sync::Arc;
-use crate::infrastructure::ai::AIClientFactory;
/// Combines work state analysis and greeting generation
pub struct StartchatFunctionAgent {
@@ -25,7 +24,7 @@ impl StartchatFunctionAgent {
pub fn new(factory: Arc) -> Self {
Self { factory }
}
-
+
/// Analyze work state and generate greeting
pub async fn analyze_work_state(
&self,
@@ -34,16 +33,20 @@ impl StartchatFunctionAgent {
) -> AgentResult {
WorkStateAnalyzer::analyze_work_state(self.factory.clone(), repo_path, options).await
}
-
+
/// Quickly analyze work state (use default options with specified language)
- pub async fn quick_analyze(&self, repo_path: &Path, language: Language) -> AgentResult {
+ pub async fn quick_analyze(
+ &self,
+ repo_path: &Path,
+ language: Language,
+ ) -> AgentResult {
let options = WorkStateOptions {
language,
..WorkStateOptions::default()
};
self.analyze_work_state(repo_path, options).await
}
-
+
/// Generate greeting only (do not analyze Git status)
pub async fn generate_greeting_only(&self, repo_path: &Path) -> AgentResult {
let options = WorkStateOptions {
@@ -52,8 +55,7 @@ impl StartchatFunctionAgent {
include_quick_actions: false,
language: Language::Chinese,
};
-
+
self.analyze_work_state(repo_path, options).await
}
}
-
diff --git a/src/crates/core/src/function_agents/startchat-func-agent/types.rs b/src/crates/core/src/function_agents/startchat-func-agent/types.rs
index 8babe95f..a18aa96e 100644
--- a/src/crates/core/src/function_agents/startchat-func-agent/types.rs
+++ b/src/crates/core/src/function_agents/startchat-func-agent/types.rs
@@ -3,7 +3,6 @@
*
* Defines data structures for work state analysis and greeting info at session start
*/
-
use serde::{Deserialize, Serialize};
use std::fmt;
@@ -12,13 +11,13 @@ use std::fmt;
pub struct WorkStateOptions {
#[serde(default = "default_true")]
pub analyze_git: bool,
-
+
#[serde(default = "default_true")]
pub predict_next_actions: bool,
-
+
#[serde(default = "default_true")]
pub include_quick_actions: bool,
-
+
#[serde(default = "default_language")]
pub language: Language,
}
@@ -52,13 +51,13 @@ pub enum Language {
#[serde(rename_all = "camelCase")]
pub struct WorkStateAnalysis {
pub greeting: GreetingMessage,
-
+
pub current_state: CurrentWorkState,
-
+
pub predicted_actions: Vec,
-
+
pub quick_actions: Vec,
-
+
pub analyzed_at: String,
}
@@ -66,9 +65,9 @@ pub struct WorkStateAnalysis {
#[serde(rename_all = "camelCase")]
pub struct GreetingMessage {
pub title: String,
-
+
pub subtitle: String,
-
+
pub tagline: Option,
}
@@ -76,11 +75,11 @@ pub struct GreetingMessage {
#[serde(rename_all = "camelCase")]
pub struct CurrentWorkState {
pub summary: String,
-
+
pub git_state: Option,
-
+
pub ongoing_work: Vec,
-
+
pub time_info: TimeInfo,
}
@@ -88,15 +87,15 @@ pub struct CurrentWorkState {
#[serde(rename_all = "camelCase")]
pub struct GitWorkState {
pub current_branch: String,
-
+
pub unstaged_files: u32,
-
+
pub staged_files: u32,
-
+
pub unpushed_commits: u32,
-
+
pub ahead_behind: Option,
-
+
/// List of modified files (show at most the first few)
pub modified_files: Vec,
}
@@ -105,7 +104,7 @@ pub struct GitWorkState {
#[serde(rename_all = "camelCase")]
pub struct AheadBehind {
pub ahead: u32,
-
+
pub behind: u32,
}
@@ -113,9 +112,9 @@ pub struct AheadBehind {
#[serde(rename_all = "camelCase")]
pub struct FileModification {
pub path: String,
-
+
pub change_type: FileChangeType,
-
+
pub module: Option,
}
@@ -145,13 +144,13 @@ impl fmt::Display for FileChangeType {
#[serde(rename_all = "camelCase")]
pub struct WorkItem {
pub title: String,
-
+
pub description: String,
-
+
pub related_files: Vec,
-
+
pub category: WorkCategory,
-
+
pub icon: String,
}
@@ -188,10 +187,10 @@ impl fmt::Display for WorkCategory {
pub struct TimeInfo {
/// Minutes since last commit
pub minutes_since_last_commit: Option,
-
+
/// Last commit time description (e.g., "2 hours ago")
pub last_commit_time_desc: Option,
-
+
/// Current time of day (morning/afternoon/evening)
pub time_of_day: TimeOfDay,
}
@@ -220,11 +219,11 @@ impl fmt::Display for TimeOfDay {
#[serde(rename_all = "camelCase")]
pub struct PredictedAction {
pub description: String,
-
+
pub priority: ActionPriority,
-
+
pub icon: String,
-
+
pub is_reminder: bool,
}
@@ -250,12 +249,12 @@ impl fmt::Display for ActionPriority {
#[serde(rename_all = "camelCase")]
pub struct QuickAction {
pub title: String,
-
+
/// Action command (natural language)
pub command: String,
-
+
pub icon: String,
-
+
pub action_type: QuickActionType,
}
@@ -272,11 +271,11 @@ pub enum QuickActionType {
#[serde(rename_all = "camelCase")]
pub struct AIGeneratedAnalysis {
pub summary: String,
-
+
pub ongoing_work: Vec,
-
+
pub predicted_actions: Vec,
-
+
pub quick_actions: Vec,
}
@@ -309,21 +308,21 @@ impl AgentError {
error_type: AgentErrorType::GitError,
}
}
-
+
pub fn analysis_error(msg: impl Into) -> Self {
Self {
message: msg.into(),
error_type: AgentErrorType::AnalysisError,
}
}
-
+
pub fn invalid_input(msg: impl Into) -> Self {
Self {
message: msg.into(),
error_type: AgentErrorType::InvalidInput,
}
}
-
+
pub fn internal_error(msg: impl Into) -> Self {
Self {
message: msg.into(),
@@ -333,4 +332,3 @@ impl AgentError {
}
pub type AgentResult = Result;
-
diff --git a/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs b/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs
index ca536c60..67f37155 100644
--- a/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs
+++ b/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs
@@ -1,15 +1,14 @@
+use super::types::*;
+use crate::infrastructure::ai::AIClientFactory;
+use chrono::{Local, Timelike};
/**
* Work state analyzer
*
* Analyzes the user's current work state, including Git status and file changes
*/
-
use log::{debug, info};
-use super::types::*;
use std::path::Path;
use std::sync::Arc;
-use chrono::{Local, Timelike};
-use crate::infrastructure::ai::AIClientFactory;
pub struct WorkStateAnalyzer;
@@ -20,46 +19,51 @@ impl WorkStateAnalyzer {
options: WorkStateOptions,
) -> AgentResult {
info!("Analyzing work state: repo_path={:?}", repo_path);
-
+
let greeting = Self::generate_greeting(&options);
-
+
let git_state = if options.analyze_git {
Self::analyze_git_state(repo_path).await.ok()
} else {
None
};
-
- let git_diff = if git_state.as_ref().map_or(false, |g| g.unstaged_files > 0 || g.staged_files > 0) {
+
+ let git_diff = if git_state
+ .as_ref()
+ .map_or(false, |g| g.unstaged_files > 0 || g.staged_files > 0)
+ {
Self::get_git_diff(repo_path).await.unwrap_or_default()
} else {
String::new()
};
-
+
let time_info = Self::get_time_info(repo_path).await;
-
- let ai_analysis = Self::generate_complete_analysis_with_ai(factory, &git_state, &git_diff, &options).await?;
-
+
+ let ai_analysis =
+ Self::generate_complete_analysis_with_ai(factory, &git_state, &git_diff, &options)
+ .await?;
+
debug!("AI complete analysis generation succeeded");
let summary = ai_analysis.summary;
let ongoing_work = ai_analysis.ongoing_work;
- let predicted_actions = if options.predict_next_actions {
- ai_analysis.predicted_actions
- } else {
- Vec::new()
+ let predicted_actions = if options.predict_next_actions {
+ ai_analysis.predicted_actions
+ } else {
+ Vec::new()
};
- let quick_actions = if options.include_quick_actions {
- ai_analysis.quick_actions
- } else {
- Vec::new()
+ let quick_actions = if options.include_quick_actions {
+ ai_analysis.quick_actions
+ } else {
+ Vec::new()
};
-
+
let current_state = CurrentWorkState {
summary,
git_state,
ongoing_work,
time_info,
};
-
+
Ok(WorkStateAnalysis {
greeting,
current_state,
@@ -68,7 +72,7 @@ impl WorkStateAnalyzer {
analyzed_at: Local::now().to_rfc3339(),
})
}
-
+
fn generate_greeting(_options: &WorkStateOptions) -> GreetingMessage {
// Frontend uses its own static greeting from i18n.
GreetingMessage {
@@ -77,38 +81,38 @@ impl WorkStateAnalyzer {
tagline: None,
}
}
-
+
async fn get_git_diff(repo_path: &Path) -> AgentResult {
debug!("Getting Git diff");
-
+
let unstaged_output = crate::util::process_manager::create_command("git")
.arg("diff")
.arg("HEAD")
.current_dir(repo_path)
.output()
.map_err(|e| AgentError::git_error(format!("Failed to get git diff: {}", e)))?;
-
+
let mut diff = String::from_utf8_lossy(&unstaged_output.stdout).to_string();
-
+
let staged_output = crate::util::process_manager::create_command("git")
.arg("diff")
.arg("--cached")
.current_dir(repo_path)
.output()
.map_err(|e| AgentError::git_error(format!("Failed to get staged diff: {}", e)))?;
-
+
let staged_diff = String::from_utf8_lossy(&staged_output.stdout);
-
+
if !staged_diff.is_empty() {
diff.push_str("\n\n=== Staged Changes ===\n\n");
diff.push_str(&staged_diff);
}
-
+
debug!("Git diff retrieved: length={} chars", diff.len());
-
+
Ok(diff)
}
-
+
async fn generate_complete_analysis_with_ai(
factory: Arc,
git_state: &Option,
@@ -116,41 +120,44 @@ impl WorkStateAnalyzer {
options: &WorkStateOptions,
) -> AgentResult {
use super::ai_service::AIWorkStateService;
-
+
debug!("Starting AI complete analysis generation");
-
- let ai_service = AIWorkStateService::new_with_agent_config(factory, "startchat-func-agent").await?;
- ai_service.generate_complete_analysis(git_state, git_diff, &options.language).await
+
+ let ai_service =
+ AIWorkStateService::new_with_agent_config(factory, "startchat-func-agent").await?;
+ ai_service
+ .generate_complete_analysis(git_state, git_diff, &options.language)
+ .await
}
-
+
async fn analyze_git_state(repo_path: &Path) -> AgentResult {
let current_branch = Self::get_current_branch(repo_path)?;
-
+
let status_output = crate::util::process_manager::create_command("git")
.arg("status")
.arg("--porcelain")
.current_dir(repo_path)
.output()
.map_err(|e| AgentError::git_error(format!("Failed to get git status: {}", e)))?;
-
+
let status_str = String::from_utf8_lossy(&status_output.stdout);
-
+
let mut unstaged_files = 0;
let mut staged_files = 0;
let mut modified_files = Vec::new();
-
+
for line in status_str.lines() {
if line.is_empty() {
continue;
}
-
+
let status_code = &line[0..2];
let file_path = if line.len() > 3 {
line[3..].trim().to_string()
} else {
continue;
};
-
+
let (change_type, is_staged) = match status_code {
"A " => (FileChangeType::Added, true),
" M" => (FileChangeType::Modified, false),
@@ -162,13 +169,13 @@ impl WorkStateAnalyzer {
"R " => (FileChangeType::Renamed, true),
_ => (FileChangeType::Modified, false),
};
-
+
if is_staged {
staged_files += 1;
} else {
unstaged_files += 1;
}
-
+
if modified_files.len() < 10 {
modified_files.push(FileModification {
path: file_path.clone(),
@@ -177,10 +184,10 @@ impl WorkStateAnalyzer {
});
}
}
-
+
let unpushed_commits = Self::get_unpushed_commits(repo_path)?;
let ahead_behind = Self::get_ahead_behind(repo_path).ok();
-
+
Ok(GitWorkState {
current_branch,
unstaged_files,
@@ -190,7 +197,7 @@ impl WorkStateAnalyzer {
modified_files,
})
}
-
+
fn get_current_branch(repo_path: &Path) -> AgentResult {
let output = crate::util::process_manager::create_command("git")
.arg("branch")
@@ -198,10 +205,10 @@ impl WorkStateAnalyzer {
.current_dir(repo_path)
.output()
.map_err(|e| AgentError::git_error(format!("Failed to get current branch: {}", e)))?;
-
+
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
-
+
fn get_unpushed_commits(repo_path: &Path) -> AgentResult {
let output = crate::util::process_manager::create_command("git")
.arg("log")
@@ -209,19 +216,17 @@ impl WorkStateAnalyzer {
.arg("--oneline")
.current_dir(repo_path)
.output();
-
+
if let Ok(output) = output {
if output.status.success() {
- let count = String::from_utf8_lossy(&output.stdout)
- .lines()
- .count() as u32;
+ let count = String::from_utf8_lossy(&output.stdout).lines().count() as u32;
return Ok(count);
}
}
-
+
Ok(0)
}
-
+
fn get_ahead_behind(repo_path: &Path) -> AgentResult {
let output = crate::util::process_manager::create_command("git")
.arg("rev-list")
@@ -231,14 +236,14 @@ impl WorkStateAnalyzer {
.current_dir(repo_path)
.output()
.map_err(|e| AgentError::git_error(format!("Failed to get ahead/behind: {}", e)))?;
-
+
if !output.status.success() {
return Err(AgentError::git_error("No upstream branch configured"));
}
-
+
let result = String::from_utf8_lossy(&output.stdout);
let parts: Vec<&str> = result.trim().split_whitespace().collect();
-
+
if parts.len() >= 2 {
let ahead = parts[0].parse().unwrap_or(0);
let behind = parts[1].parse().unwrap_or(0);
@@ -247,17 +252,17 @@ impl WorkStateAnalyzer {
Err(AgentError::git_error("Failed to parse ahead/behind info"))
}
}
-
+
fn extract_module(file_path: &str) -> Option {
let path = Path::new(file_path);
-
+
if let Some(component) = path.components().next() {
return Some(component.as_os_str().to_string_lossy().to_string());
}
-
+
None
}
-
+
async fn get_time_info(repo_path: &Path) -> TimeInfo {
let hour = Local::now().hour();
let time_of_day = match hour {
@@ -266,14 +271,14 @@ impl WorkStateAnalyzer {
18..=22 => TimeOfDay::Evening,
_ => TimeOfDay::Night,
};
-
+
let output = crate::util::process_manager::create_command("git")
.arg("log")
.arg("-1")
.arg("--format=%ct")
.current_dir(repo_path)
.output();
-
+
let (minutes_since_last_commit, last_commit_time_desc) = if let Ok(output) = output {
if output.status.success() {
let timestamp_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
@@ -281,7 +286,7 @@ impl WorkStateAnalyzer {
let now = Local::now().timestamp();
let diff_seconds = now - timestamp;
let minutes = (diff_seconds / 60) as u64;
-
+
// Don't format time description here, let frontend handle i18n
(Some(minutes), None)
} else {
@@ -293,7 +298,7 @@ impl WorkStateAnalyzer {
} else {
(None, None)
};
-
+
TimeInfo {
minutes_since_last_commit,
last_commit_time_desc,
@@ -301,4 +306,3 @@ impl WorkStateAnalyzer {
}
}
}
-
diff --git a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/mod.rs b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/mod.rs
index a3f2f220..567001f6 100644
--- a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/mod.rs
+++ b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/mod.rs
@@ -1,5 +1,5 @@
-mod openai;
mod anthropic;
+mod openai;
+pub use anthropic::handle_anthropic_stream;
pub use openai::handle_openai_stream;
-pub use anthropic::handle_anthropic_stream;
\ No newline at end of file
diff --git a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/mod.rs b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/mod.rs
index 0463a261..596bfd7f 100644
--- a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/mod.rs
+++ b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/mod.rs
@@ -1,3 +1,3 @@
-pub mod unified;
+pub mod anthropic;
pub mod openai;
-pub mod anthropic;
\ No newline at end of file
+pub mod unified;
diff --git a/src/crates/core/src/infrastructure/ai/mod.rs b/src/crates/core/src/infrastructure/ai/mod.rs
index 544e738e..ae9e7015 100644
--- a/src/crates/core/src/infrastructure/ai/mod.rs
+++ b/src/crates/core/src/infrastructure/ai/mod.rs
@@ -9,4 +9,6 @@ pub mod providers;
pub use ai_stream_handlers;
pub use client::{AIClient, StreamResponse};
-pub use client_factory::{AIClientFactory, get_global_ai_client_factory, initialize_global_ai_client_factory};
+pub use client_factory::{
+ get_global_ai_client_factory, initialize_global_ai_client_factory, AIClientFactory,
+};
diff --git a/src/crates/core/src/infrastructure/ai/providers/anthropic/message_converter.rs b/src/crates/core/src/infrastructure/ai/providers/anthropic/message_converter.rs
index 70ebb7da..e8405c24 100644
--- a/src/crates/core/src/infrastructure/ai/providers/anthropic/message_converter.rs
+++ b/src/crates/core/src/infrastructure/ai/providers/anthropic/message_converter.rs
@@ -2,8 +2,8 @@
//!
//! Converts the unified message format to Anthropic Claude API format
-use log::warn;
use crate::util::types::{Message, ToolDefinition};
+use log::warn;
use serde_json::{json, Value};
pub struct AnthropicMessageConverter;
@@ -42,24 +42,24 @@ impl AnthropicMessageConverter {
// Anthropic requires user/assistant messages to alternate
let merged_messages = Self::merge_consecutive_messages(anthropic_messages);
-
+
(system_message, merged_messages)
}
-
+
/// Merge consecutive same-role messages to keep user/assistant alternating
fn merge_consecutive_messages(messages: Vec) -> Vec {
let mut merged: Vec = Vec::new();
-
+
for msg in messages {
let role = msg.get("role").and_then(|r| r.as_str()).unwrap_or("");
-
+
if let Some(last) = merged.last_mut() {
let last_role = last.get("role").and_then(|r| r.as_str()).unwrap_or("");
-
+
if last_role == role && role == "user" {
let current_content = msg.get("content");
let last_content = last.get_mut("content");
-
+
match (last_content, current_content) {
(Some(Value::Array(last_arr)), Some(Value::Array(curr_arr))) => {
last_arr.extend(curr_arr.clone());
@@ -100,16 +100,16 @@ impl AnthropicMessageConverter {
}
}
}
-
+
merged.push(msg);
}
-
+
merged
}
fn convert_user_message(msg: Message) -> Value {
let content = msg.content.unwrap_or_default();
-
+
if let Ok(parsed) = serde_json::from_str::(&content) {
if parsed.is_array() {
return json!({
@@ -118,7 +118,7 @@ impl AnthropicMessageConverter {
});
}
}
-
+
json!({
"role": "user",
"content": content
@@ -135,14 +135,14 @@ impl AnthropicMessageConverter {
"type": "thinking",
"thinking": thinking
});
-
+
// Append only when signature exists, to support APIs that do not require it.
if let Some(ref sig) = msg.thinking_signature {
if !sig.is_empty() {
thinking_block["signature"] = json!(sig);
}
}
-
+
content.push(thinking_block);
}
}
diff --git a/src/crates/core/src/infrastructure/ai/providers/anthropic/mod.rs b/src/crates/core/src/infrastructure/ai/providers/anthropic/mod.rs
index c1684ff5..e01d6710 100644
--- a/src/crates/core/src/infrastructure/ai/providers/anthropic/mod.rs
+++ b/src/crates/core/src/infrastructure/ai/providers/anthropic/mod.rs
@@ -5,4 +5,3 @@
pub mod message_converter;
pub use message_converter::AnthropicMessageConverter;
-
diff --git a/src/crates/core/src/infrastructure/ai/providers/mod.rs b/src/crates/core/src/infrastructure/ai/providers/mod.rs
index 61ce45c6..4e9f0764 100644
--- a/src/crates/core/src/infrastructure/ai/providers/mod.rs
+++ b/src/crates/core/src/infrastructure/ai/providers/mod.rs
@@ -2,8 +2,7 @@
//!
//! Provides a unified interface for different AI providers
-pub mod openai;
pub mod anthropic;
+pub mod openai;
pub use anthropic::AnthropicMessageConverter;
-
diff --git a/src/crates/core/src/infrastructure/ai/providers/openai/message_converter.rs b/src/crates/core/src/infrastructure/ai/providers/openai/message_converter.rs
index 7c04e443..04390c2a 100644
--- a/src/crates/core/src/infrastructure/ai/providers/openai/message_converter.rs
+++ b/src/crates/core/src/infrastructure/ai/providers/openai/message_converter.rs
@@ -1,14 +1,15 @@
//! OpenAI message format converter
-use log::{warn, error};
use crate::util::types::{Message, ToolDefinition};
+use log::{error, warn};
use serde_json::{json, Value};
pub struct OpenAIMessageConverter;
impl OpenAIMessageConverter {
pub fn convert_messages(messages: Vec) -> Vec {
- messages.into_iter()
+ messages
+ .into_iter()
.map(Self::convert_single_message)
.collect()
}
@@ -28,15 +29,12 @@ impl OpenAIMessageConverter {
} else if msg.role == "tool" {
openai_msg["content"] = Value::String("Tool execution completed".to_string());
warn!(
- "[OpenAI] Tool response content is empty: name={:?}",
+ "[OpenAI] Tool response content is empty: name={:?}",
msg.name
);
} else {
openai_msg["content"] = Value::String(" ".to_string());
- warn!(
- "[OpenAI] Message content is empty: role={}",
- msg.role
- );
+ warn!("[OpenAI] Message content is empty: role={}", msg.role);
}
} else {
if let Ok(parsed) = serde_json::from_str::(&content) {
@@ -55,9 +53,9 @@ impl OpenAIMessageConverter {
openai_msg["content"] = Value::String(" ".to_string());
} else if msg.role == "tool" {
openai_msg["content"] = Value::String("Tool execution completed".to_string());
-
+
warn!(
- "[OpenAI] Tool response message content is empty, set to default: name={:?}",
+ "[OpenAI] Tool response message content is empty, set to default: name={:?}",
msg.name
);
} else {
@@ -66,7 +64,7 @@ impl OpenAIMessageConverter {
msg.role,
has_tool_calls
);
-
+
openai_msg["content"] = Value::String(" ".to_string());
}
}
@@ -124,4 +122,3 @@ impl OpenAIMessageConverter {
})
}
}
-
diff --git a/src/crates/core/src/infrastructure/ai/providers/openai/mod.rs b/src/crates/core/src/infrastructure/ai/providers/openai/mod.rs
index 3b1f965c..44ad1060 100644
--- a/src/crates/core/src/infrastructure/ai/providers/openai/mod.rs
+++ b/src/crates/core/src/infrastructure/ai/providers/openai/mod.rs
@@ -3,4 +3,3 @@
pub mod message_converter;
pub use message_converter::OpenAIMessageConverter;
-
diff --git a/src/crates/core/src/infrastructure/debug_log/http_server.rs b/src/crates/core/src/infrastructure/debug_log/http_server.rs
index 5c5b2f26..e894e408 100644
--- a/src/crates/core/src/infrastructure/debug_log/http_server.rs
+++ b/src/crates/core/src/infrastructure/debug_log/http_server.rs
@@ -3,22 +3,25 @@
//! HTTP server that receives debug logs from web applications.
//! This is platform-agnostic and can be started by any application (desktop, CLI, etc.).
-use log::{trace, debug, info, warn, error};
-use std::net::SocketAddr;
-use std::path::PathBuf;
-use std::sync::Arc;
-use std::sync::OnceLock;
use axum::{
extract::{Path, State},
http::StatusCode,
routing::{get, post},
Json, Router,
};
+use log::{debug, error, info, trace, warn};
+use std::net::SocketAddr;
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::sync::OnceLock;
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use tower_http::cors::{Any, CorsLayer};
-use super::types::{IngestServerConfig, IngestServerState, IngestLogRequest, IngestResponse, handle_ingest, DEFAULT_INGEST_PORT};
+use super::types::{
+ handle_ingest, IngestLogRequest, IngestResponse, IngestServerConfig, IngestServerState,
+ DEFAULT_INGEST_PORT,
+};
static GLOBAL_INGEST_MANAGER: OnceLock> = OnceLock::new();
@@ -36,32 +39,35 @@ impl IngestServerManager {
actual_port: Arc::new(RwLock::new(DEFAULT_INGEST_PORT)),
}
}
-
+
pub fn global() -> &'static Arc {
GLOBAL_INGEST_MANAGER.get_or_init(|| Arc::new(IngestServerManager::new()))
}
-
+
pub async fn start(&self, config: Option) -> anyhow::Result<()> {
self.stop().await;
-
+
let cfg = config.unwrap_or_default();
let base_port = cfg.port;
-
+
let mut listener: Option = None;
let mut actual_port = base_port;
-
+
for offset in 0..10u16 {
let port = base_port + offset;
if let Some(l) = try_bind_port(port).await {
listener = Some(l);
actual_port = port;
if offset > 0 {
- info!("Default port {} is occupied, using port {} instead", base_port, port);
+ info!(
+ "Default port {} is occupied, using port {} instead",
+ base_port, port
+ );
}
break;
}
}
-
+
let listener = match listener {
Some(l) => l,
None => {
@@ -70,38 +76,38 @@ impl IngestServerManager {
return Ok(());
}
};
-
+
let mut updated_cfg = cfg;
updated_cfg.port = actual_port;
-
+
let state = IngestServerState::new(updated_cfg);
let cancel_token = CancellationToken::new();
-
+
*self.state.write().await = Some(state.clone());
*self.cancel_token.write().await = Some(cancel_token.clone());
*self.actual_port.write().await = actual_port;
-
+
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
-
+
let app = Router::new()
.route("/health", get(health_handler))
.route("/ingest/:session_id", post(ingest_handler))
.layer(cors)
.with_state(state.clone());
-
+
*state.is_running.write().await = true;
-
+
let addr = listener.local_addr()?;
info!("Debug Log Ingest Server started on http://{}", addr);
info!("Debug logs will be written to: /.bitfun/debug.log");
-
+
let state_clone = state.clone();
tokio::spawn(async move {
let server = axum::serve(listener, app);
-
+
tokio::select! {
result = server => {
if let Err(e) = result {
@@ -112,13 +118,13 @@ impl IngestServerManager {
info!("Debug Log Ingest Server shutting down");
}
}
-
+
*state_clone.is_running.write().await = false;
});
-
+
Ok(())
}
-
+
pub async fn stop(&self) {
if let Some(token) = self.cancel_token.write().await.take() {
token.cancel();
@@ -127,20 +133,22 @@ impl IngestServerManager {
}
*self.state.write().await = None;
}
-
+
pub async fn restart(&self, config: IngestServerConfig) -> anyhow::Result<()> {
- debug!("Restarting Debug Log Ingest Server with new config (port: {}, log_path: {:?})",
- config.port, config.log_config.log_path);
+ debug!(
+ "Restarting Debug Log Ingest Server with new config (port: {}, log_path: {:?})",
+ config.port, config.log_config.log_path
+ );
self.stop().await;
self.start(Some(config)).await
}
-
+
pub async fn update_log_path(&self, log_path: PathBuf) {
if let Some(state) = self.state.read().await.as_ref() {
state.update_log_path(log_path).await;
}
}
-
+
pub async fn update_port(&self, new_port: u16, log_path: PathBuf) -> anyhow::Result<()> {
let current_port = *self.actual_port.read().await;
if current_port != new_port {
@@ -151,11 +159,11 @@ impl IngestServerManager {
Ok(())
}
}
-
+
pub async fn get_actual_port(&self) -> u16 {
*self.actual_port.read().await
}
-
+
pub async fn is_running(&self) -> bool {
if let Some(state) = self.state.read().await.as_ref() {
*state.is_running.read().await
@@ -186,30 +194,30 @@ async fn ingest_handler(
if request.session_id.is_none() {
request.session_id = Some(session_id);
}
-
+
let config = state.config.read().await;
let log_config = config.log_config.clone();
drop(config);
-
- match handle_ingest(request.clone(), &log_config).await {
- Ok(response) => {
- trace!(
- "Debug log received: [{}] {} | hypothesis: {:?}",
- request.location,
- request.message,
- request.hypothesis_id
- );
- Ok(Json(response))
- }
- Err(e) => {
- warn!("Failed to ingest log: {}", e);
- Err((
- StatusCode::INTERNAL_SERVER_ERROR,
- Json(IngestResponse {
- success: false,
- error: Some(e.to_string()),
- }),
- ))
- }
+
+ match handle_ingest(request.clone(), &log_config).await {
+ Ok(response) => {
+ trace!(
+ "Debug log received: [{}] {} | hypothesis: {:?}",
+ request.location,
+ request.message,
+ request.hypothesis_id
+ );
+ Ok(Json(response))
}
+ Err(e) => {
+ warn!("Failed to ingest log: {}", e);
+ Err((
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(IngestResponse {
+ success: false,
+ error: Some(e.to_string()),
+ }),
+ ))
+ }
+ }
}
diff --git a/src/crates/core/src/infrastructure/debug_log/mod.rs b/src/crates/core/src/infrastructure/debug_log/mod.rs
index 86d2b9c4..efea6544 100644
--- a/src/crates/core/src/infrastructure/debug_log/mod.rs
+++ b/src/crates/core/src/infrastructure/debug_log/mod.rs
@@ -5,12 +5,12 @@
//! - `types` - Types and handlers for the HTTP ingest server (Config, State, Request, Response)
//! - `http_server` - The actual HTTP server implementation (axum-based)
-pub mod types;
pub mod http_server;
+pub mod types;
pub use types::{
- IngestServerConfig, IngestServerState, IngestLogRequest, IngestResponse,
- handle_ingest, DEFAULT_INGEST_PORT,
+ handle_ingest, IngestLogRequest, IngestResponse, IngestServerConfig, IngestServerState,
+ DEFAULT_INGEST_PORT,
};
pub use http_server::IngestServerManager;
@@ -39,9 +39,8 @@ static DEFAULT_LOG_PATH: Lazy = Lazy::new(|| {
.join("debug.log")
});
-static DEFAULT_INGEST_URL: Lazy