chore: import upstream snapshot with attribution
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1 @@
|
||||
3.12
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 @ Strukto.AI
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,185 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/strukto-ai/mirage/main/assets/mirage-og-light@2x.png" alt="Mirage: A Unified Virtual File System for AI Agents" width="900">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://docs.mirage.strukto.ai" alt="Documentation">
|
||||
<img src="https://img.shields.io/badge/mirage-docs-0C0C0C?labelColor=FAFAFA" /></a>
|
||||
<a href="https://www.strukto.ai" alt="Website">
|
||||
<img src="https://img.shields.io/badge/made by-strukto.ai-0C0C0C?labelColor=FAFAFA" /></a>
|
||||
<a href="https://github.com/strukto-ai/mirage/blob/main/LICENSE" alt="License">
|
||||
<img src="https://img.shields.io/github/license/strukto-ai/mirage?color=0C0C0C&labelColor=FAFAFA" /></a>
|
||||
<a href="https://discord.gg/u8BPQ65KsS" alt="Discord">
|
||||
<img src="https://img.shields.io/badge/discord-join-0C0C0C?labelColor=FAFAFA&logo=discord&logoColor=0C0C0C" /></a>
|
||||
<br/>
|
||||
<a href="https://docs.mirage.strukto.ai/python/quickstart" alt="Python docs">
|
||||
<img src="https://img.shields.io/badge/python-docs-0C0C0C?labelColor=FAFAFA&logo=python&logoColor=0C0C0C" alt="Python docs"></a>
|
||||
<a href="https://pypi.org/project/mirage-ai/" alt="PyPI Version">
|
||||
<img src="https://img.shields.io/pypi/v/mirage-ai.svg?color=0C0C0C&labelColor=FAFAFA"/></a>
|
||||
<br/>
|
||||
<a href="https://docs.mirage.strukto.ai/typescript/quickstart" alt="TypeScript docs">
|
||||
<img src="https://img.shields.io/badge/typescript-docs-0C0C0C?labelColor=FAFAFA&logo=typescript&logoColor=0C0C0C" alt="TypeScript docs"></a>
|
||||
<a href="https://www.npmjs.com/package/@struktoai/mirage-node" alt="NPM Version">
|
||||
<img src="https://img.shields.io/npm/v/@struktoai/mirage-node.svg?color=0C0C0C&labelColor=FAFAFA"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/strukto-ai/mirage#readme"><img alt="README in English" src="https://img.shields.io/badge/English-d9d9d9"></a>
|
||||
<a href="https://github.com/strukto-ai/mirage/blob/main/readme/README.zh-CN.md"><img alt="简体中文 README" src="https://img.shields.io/badge/简体中文-d9d9d9"></a>
|
||||
<a href="https://github.com/strukto-ai/mirage/blob/main/readme/README.zh-TW.md"><img alt="繁體中文 README" src="https://img.shields.io/badge/繁體中文-d9d9d9"></a>
|
||||
<a href="https://github.com/strukto-ai/mirage/blob/main/readme/README.fr.md"><img alt="README en Français" src="https://img.shields.io/badge/Français-d9d9d9"></a>
|
||||
<a href="https://github.com/strukto-ai/mirage/blob/main/readme/README.vi.md"><img alt="README Tiếng Việt" src="https://img.shields.io/badge/Ti%E1%BA%BFng%20Vi%E1%BB%87t-d9d9d9"></a>
|
||||
<a href="https://github.com/strukto-ai/mirage/blob/main/readme/README.ko.md"><img alt="README 한국어" src="https://img.shields.io/badge/%ED%95%9C%EA%B5%AD%EC%96%B4-d9d9d9"></a>
|
||||
</p>
|
||||
|
||||
Mirage is **a Unified Virtual File System for AI Agents**: it mounts services and data sources like S3, Google Drive, Slack, Gmail, and Redis side-by-side as one filesystem. Any LLM that already knows bash can read, grep, and pipe across every backend out of the box, with zero new vocabulary.
|
||||
|
||||
```ts
|
||||
const ws = new Workspace({
|
||||
'/data': new RAMResource(),
|
||||
'/s3': new S3Resource({ bucket: 'logs' }),
|
||||
'/slack': new SlackResource({ token: process.env.SLACK_BOT_TOKEN! }),
|
||||
})
|
||||
|
||||
await ws.execute('grep -r alert /slack/channels/general__C04QX/ | wc -l')
|
||||
await ws.execute('cp /s3/report.csv /data/local.csv')
|
||||
await ws.execute('wc -l $(find /s3/data -name "*.jsonl")')
|
||||
|
||||
// Commands are extensible: register new commands, or override one per
|
||||
// resource + filetype, e.g. `cat` on S3 Parquet renders rows as JSON.
|
||||
ws.command('summarize', ...)
|
||||
ws.command('cat', { resource: 's3', filetype: 'parquet' }, ...)
|
||||
|
||||
await ws.execute('summarize /data/local.csv')
|
||||
await ws.execute('cat /s3/events/2026-05-06.parquet | jq .user')
|
||||
```
|
||||
|
||||
## About
|
||||
|
||||
- **One interface instead of N SDKs and M MCPs.** Every service speaks the same filesystem semantics, and pipelines compose across services as naturally as on a local disk.
|
||||
- **Around 50 built-in backends:** RAM, Disk, Redis, S3 / R2 / OCI / Supabase / GCS, Gmail / GDrive / GDocs / GSheets / GSlides, GitHub / Linear / Notion / Trello, Slack / Discord / Email, MongoDB / Postgres / LanceDB / Qdrant, SSH, and more, mounted side-by-side under a single root.
|
||||
- **Portable workspaces:** clone, snapshot, and version a workspace; agent runs move between machines without restarting or reconfiguring the system.
|
||||
- **Embeddable:** the Python and TypeScript SDKs run in-process inside FastAPI, Express, browser apps, or any async runtime; no separate process required.
|
||||
- **Agent integrations:** OpenAI Agents SDK, Vercel AI SDK, LangChain, Pydantic AI, CAMEL, and OpenHands via the SDKs; coding agents like Claude Code and Codex via the lightweight CLI + daemon.
|
||||
|
||||
## Architecture
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/strukto-ai/mirage/main/assets/mirage-arch-dark.svg">
|
||||
<img src="https://raw.githubusercontent.com/strukto-ai/mirage/main/assets/mirage-arch-light.svg" alt="Mirage architecture: AI Agent and Application → Mirage Bash and VFS → Dispatcher & Cache → Infrastructure and Remote" width="720">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
## Installation
|
||||
|
||||
- **Python** ≥ 3.11 for the `mirage-ai` package and the `mirage` CLI
|
||||
- **Node.js** ≥ 20 for the TypeScript SDK
|
||||
- **macOS** or **Linux** (FUSE-based mounts require platform support)
|
||||
|
||||
### Python
|
||||
|
||||
```bash
|
||||
uv add mirage-ai # installs the `mirage` library and the `mirage` CLI binary
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
|
||||
```bash
|
||||
npm install @struktoai/mirage-node # Node.js servers and CLIs
|
||||
npm install @struktoai/mirage-browser # browser / edge runtimes
|
||||
npm install @struktoai/mirage-agents # OpenAI / Vercel AI / LangChain / Mastra adapters
|
||||
```
|
||||
|
||||
Both runtime packages pull in `@struktoai/mirage-core` automatically.
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
curl -fsSL https://strukto.ai/mirage/install.sh | sh
|
||||
# or
|
||||
npm install -g @struktoai/mirage-cli
|
||||
# or
|
||||
uvx mirage-ai
|
||||
# or
|
||||
npx @struktoai/mirage-cli
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
from mirage import Workspace
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.resource.s3 import S3Config, S3Resource
|
||||
|
||||
ws = Workspace({
|
||||
"/data": RAMResource(),
|
||||
"/s3": S3Resource(S3Config(bucket="my-bucket")),
|
||||
})
|
||||
|
||||
await ws.execute("cp /s3/report.csv /data/report.csv")
|
||||
await ws.execute("grep alert /s3/data/log.jsonl | wc -l")
|
||||
|
||||
await ws.snapshot("demo.tar")
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
|
||||
```ts
|
||||
import { Workspace, RAMResource, S3Resource } from '@struktoai/mirage-node'
|
||||
|
||||
const ws = new Workspace({
|
||||
'/data': new RAMResource(),
|
||||
'/s3': new S3Resource({ bucket: 'my-bucket' }),
|
||||
})
|
||||
|
||||
await ws.execute('cp /s3/report.csv /data/report.csv')
|
||||
await ws.execute('grep alert /s3/data/log.jsonl | wc -l')
|
||||
|
||||
await ws.snapshot('demo.tar')
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
mirage workspace create ws.yaml --id demo
|
||||
mirage execute --workspace_id demo --command "cp /s3/report.csv /data/report.csv"
|
||||
mirage provision --workspace_id demo --command "cat /s3/data/large.jsonl"
|
||||
mirage workspace snapshot demo demo.tar
|
||||
mirage workspace load demo.tar --id demo-restored
|
||||
```
|
||||
|
||||
## Agent Frameworks
|
||||
|
||||
Mirage plugs into agent frameworks as a sandbox or tool layer. POSIX operations such as `read` can also be customized per resource and filetype, e.g. reading a PDF returns parsed pages instead of raw bytes.
|
||||
|
||||
| | Integrations |
|
||||
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Python | [OpenAI Agents SDK](https://docs.mirage.strukto.ai/python/agents/openai-agents), [LangChain](https://docs.mirage.strukto.ai/python/agents/langchain), [Pydantic AI](https://docs.mirage.strukto.ai/python/agents/pydantic-ai), [CAMEL](https://docs.mirage.strukto.ai/python/agents/camel), [OpenHands](https://docs.mirage.strukto.ai/python/agents/openhands), [Agno](https://docs.mirage.strukto.ai/python/agents/agno) |
|
||||
| TypeScript | [Vercel AI SDK](https://docs.mirage.strukto.ai/typescript/agents/vercel), [OpenAI Agents SDK](https://docs.mirage.strukto.ai/typescript/agents/openai), [LangChain](https://docs.mirage.strukto.ai/typescript/agents/langchain), [Mastra](https://docs.mirage.strukto.ai/typescript/agents/mastra) |
|
||||
| Coding agents | [Claude Code](https://docs.mirage.strukto.ai/python/agents/claude-code), [Codex](https://docs.mirage.strukto.ai/python/agents/codex), [OpenCode](https://docs.mirage.strukto.ai/typescript/agents/opencode), [Pi](https://docs.mirage.strukto.ai/typescript/agents/pi) |
|
||||
|
||||
## Cache
|
||||
|
||||
Every `Workspace` has a two-layer cache so repeated work against remote backends hits local state instead of the network:
|
||||
|
||||
- **Index cache:** listings and metadata. The first directory walk hits the API; later ones serve from the index until the TTL expires (default 10 minutes).
|
||||
- **File cache:** object bytes. The first read streams from origin; later pipelines read from cache (default 512 MB).
|
||||
|
||||
Both layers default to in-process RAM with zero setup. A Redis store shares cache state across workers, processes, and machines:
|
||||
|
||||
```ts
|
||||
import { RedisFileCacheStore, S3Resource, Workspace } from '@struktoai/mirage-node'
|
||||
|
||||
const ws = new Workspace(
|
||||
{ '/s3': new S3Resource({ bucket: 'my-bucket' }) },
|
||||
{
|
||||
cache: new RedisFileCacheStore({ url: 'redis://localhost:6379/0', cacheLimit: '8GB' }),
|
||||
index: { type: 'redis', url: 'redis://localhost:6379/0', ttl: 600 },
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
See the [cache docs](https://docs.mirage.strukto.ai/home/cache) for the full miss/hit lifecycle.
|
||||
@@ -0,0 +1,39 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
# isort: skip_file
|
||||
from mirage.resource.disk import DiskResource
|
||||
from mirage.resource.ram import RAMResource
|
||||
from mirage.commands.registry import command
|
||||
from mirage.types import FileStat, MountMode
|
||||
from mirage.workspace import (ExecutionNode, Workspace, WorkspaceRunner)
|
||||
from mirage.workspace.fuse import FuseManager
|
||||
from mirage.workspace.mount.spec import Mount
|
||||
from mirage.types import ConsistencyPolicy
|
||||
|
||||
__all__ = [
|
||||
"Workspace",
|
||||
"WorkspaceRunner",
|
||||
"RAMResource",
|
||||
"DiskResource",
|
||||
"ConsistencyPolicy",
|
||||
"ExecutionNode",
|
||||
"FileStat",
|
||||
"FuseManager",
|
||||
"Mount",
|
||||
"MountMode",
|
||||
"command",
|
||||
]
|
||||
|
||||
__version__ = "0.0.3"
|
||||
@@ -0,0 +1,26 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.accessor.base import Accessor, NOOPAccessor
|
||||
from mirage.accessor.disk import DiskAccessor
|
||||
from mirage.accessor.ram import RAMAccessor
|
||||
from mirage.accessor.s3 import S3Accessor
|
||||
|
||||
__all__ = [
|
||||
"Accessor",
|
||||
"NOOPAccessor",
|
||||
"DiskAccessor",
|
||||
"RAMAccessor",
|
||||
"S3Accessor",
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import opendal
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.resource.secrets import reveal_secret
|
||||
|
||||
HF_RESOURCES = ["hf_buckets", "hf_datasets", "hf_models", "hf_spaces"]
|
||||
|
||||
|
||||
class _HfAccessor(Accessor):
|
||||
REPO_TYPE: str = ""
|
||||
RESOURCE_NAME: str = ""
|
||||
|
||||
def __init__(self, config) -> None:
|
||||
self.config = config
|
||||
|
||||
def operator(self):
|
||||
kwargs = {
|
||||
"repo_type": self.REPO_TYPE,
|
||||
"repo_id": self._repo_id(),
|
||||
}
|
||||
token = reveal_secret(self.config.token)
|
||||
if token:
|
||||
kwargs["token"] = token
|
||||
endpoint = getattr(self.config, "endpoint", None)
|
||||
if endpoint:
|
||||
kwargs["endpoint"] = endpoint
|
||||
root = self._root()
|
||||
if root:
|
||||
kwargs["root"] = root
|
||||
revision = getattr(self.config, "revision", None)
|
||||
if revision:
|
||||
kwargs["revision"] = revision
|
||||
return opendal.AsyncOperator("hf", **kwargs)
|
||||
|
||||
def _repo_id(self) -> str:
|
||||
return getattr(self.config, "repo_id", None) or self.config.bucket
|
||||
|
||||
def _root(self) -> str | None:
|
||||
kp = getattr(self.config, "key_prefix", None)
|
||||
if not kp:
|
||||
return None
|
||||
return "/" + kp.strip("/") + "/"
|
||||
@@ -0,0 +1,23 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
|
||||
class Accessor:
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return None
|
||||
|
||||
|
||||
class NOOPAccessor(Accessor):
|
||||
pass
|
||||
@@ -0,0 +1,29 @@
|
||||
from typing import Any
|
||||
|
||||
import chromadb
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.resource.chroma.config import ChromaConfig
|
||||
|
||||
|
||||
class ChromaAccessor(Accessor):
|
||||
|
||||
def __init__(self, config: ChromaConfig) -> None:
|
||||
self.config = config
|
||||
self._client: Any | None = None
|
||||
self._collection: Any | None = None
|
||||
|
||||
async def get_client(self) -> Any:
|
||||
if self._client is None:
|
||||
self._client = await chromadb.AsyncHttpClient(
|
||||
host=self.config.host,
|
||||
port=self.config.port,
|
||||
ssl=self.config.ssl)
|
||||
return self._client
|
||||
|
||||
async def get_collection(self) -> Any:
|
||||
if self._collection is None:
|
||||
client = await self.get_client()
|
||||
self._collection = await client.get_collection(
|
||||
self.config.collection_name)
|
||||
return self._collection
|
||||
@@ -0,0 +1,61 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from typing import Any
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.resource.databricks_volume.config import DatabricksVolumeConfig
|
||||
|
||||
try:
|
||||
from databricks.sdk import WorkspaceClient
|
||||
from databricks.sdk.config import Config as WorkspaceConfig
|
||||
except ImportError:
|
||||
WorkspaceConfig = None
|
||||
WorkspaceClient = None
|
||||
|
||||
|
||||
class DatabricksVolumeAccessor(Accessor):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: DatabricksVolumeConfig,
|
||||
client: Any | None = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self._client = client
|
||||
|
||||
@property
|
||||
def client(self) -> Any:
|
||||
if self._client is None:
|
||||
if WorkspaceClient is None or WorkspaceConfig is None:
|
||||
raise ImportError("DatabricksVolumeResource requires the "
|
||||
"'databricks' extra. Install with: "
|
||||
"pip install mirage-ai[databricks]")
|
||||
kwargs = {
|
||||
"host": self.config.host,
|
||||
"token": self.config.token,
|
||||
"profile": self.config.profile,
|
||||
"auth_type": "pat" if self.config.token is not None else None,
|
||||
"http_timeout_seconds": self.config.timeout,
|
||||
}
|
||||
sdk_config = WorkspaceConfig(**{
|
||||
k: v
|
||||
for k, v in kwargs.items() if v is not None
|
||||
})
|
||||
self._client = WorkspaceClient(config=sdk_config)
|
||||
return self._client
|
||||
|
||||
@property
|
||||
def files(self) -> Any:
|
||||
return self.client.files
|
||||
@@ -0,0 +1,8 @@
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.resource.dify.config import DifyConfig
|
||||
|
||||
|
||||
class DifyAccessor(Accessor):
|
||||
|
||||
def __init__(self, config: DifyConfig) -> None:
|
||||
self.config = config
|
||||
@@ -0,0 +1,22 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.resource.discord.config import DiscordConfig
|
||||
|
||||
|
||||
class DiscordAccessor(Accessor):
|
||||
|
||||
def __init__(self, config: DiscordConfig) -> None:
|
||||
self.config = config
|
||||
@@ -0,0 +1,23 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
|
||||
|
||||
class DiskAccessor(Accessor):
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.root = root
|
||||
@@ -0,0 +1,52 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import aioimaplib
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.resource.email.config import EmailConfig
|
||||
from mirage.resource.secrets import reveal_secret
|
||||
|
||||
|
||||
class EmailAccessor(Accessor):
|
||||
|
||||
def __init__(self, config: EmailConfig) -> None:
|
||||
self.config = config
|
||||
self._imap: aioimaplib.IMAP4_SSL | None = None
|
||||
|
||||
async def get_imap(self) -> aioimaplib.IMAP4_SSL:
|
||||
if self._imap is None or self._imap.protocol is None:
|
||||
if self.config.use_ssl:
|
||||
self._imap = aioimaplib.IMAP4_SSL(
|
||||
host=self.config.imap_host,
|
||||
port=self.config.imap_port,
|
||||
)
|
||||
else:
|
||||
self._imap = aioimaplib.IMAP4(
|
||||
host=self.config.imap_host,
|
||||
port=self.config.imap_port,
|
||||
)
|
||||
await self._imap.wait_hello_from_server()
|
||||
response = await self._imap.login(
|
||||
self.config.username, reveal_secret(self.config.password))
|
||||
if response.result != "OK":
|
||||
detail = " ".join(
|
||||
line.decode(errors="replace") if isinstance(
|
||||
line, (bytes, bytearray)) else str(line)
|
||||
for line in (response.lines or []))
|
||||
self._imap = None
|
||||
raise ConnectionError(
|
||||
f"IMAP login failed for {self.config.username} on "
|
||||
f"{self.config.imap_host}: {detail or response.result}")
|
||||
return self._imap
|
||||
@@ -0,0 +1,23 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.core.google._client import TokenManager
|
||||
|
||||
|
||||
class GDocsAccessor(Accessor):
|
||||
|
||||
def __init__(self, config, token_manager: TokenManager) -> None:
|
||||
self.config = config
|
||||
self.token_manager = token_manager
|
||||
@@ -0,0 +1,23 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.core.google._client import TokenManager
|
||||
|
||||
|
||||
class GDriveAccessor(Accessor):
|
||||
|
||||
def __init__(self, config, token_manager: TokenManager) -> None:
|
||||
self.config = config
|
||||
self.token_manager = token_manager
|
||||
@@ -0,0 +1,32 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
|
||||
|
||||
class GitHubAccessor(Accessor):
|
||||
|
||||
def __init__(self,
|
||||
config,
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
default_branch,
|
||||
truncated=False):
|
||||
self.config = config
|
||||
self.owner = owner
|
||||
self.repo = repo
|
||||
self.ref = ref
|
||||
self.default_branch = default_branch
|
||||
self.truncated = truncated
|
||||
@@ -0,0 +1,22 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.resource.github_ci.config import GitHubCIConfig
|
||||
|
||||
|
||||
class GitHubCIAccessor(Accessor):
|
||||
|
||||
def __init__(self, config: GitHubCIConfig) -> None:
|
||||
self.config = config
|
||||
@@ -0,0 +1,23 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.core.google._client import TokenManager
|
||||
|
||||
|
||||
class GmailAccessor(Accessor):
|
||||
|
||||
def __init__(self, config, token_manager: TokenManager) -> None:
|
||||
self.config = config
|
||||
self.token_manager = token_manager
|
||||
@@ -0,0 +1,23 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.core.google._client import TokenManager
|
||||
|
||||
|
||||
class GSheetsAccessor(Accessor):
|
||||
|
||||
def __init__(self, config, token_manager: TokenManager) -> None:
|
||||
self.config = config
|
||||
self.token_manager = token_manager
|
||||
@@ -0,0 +1,23 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.core.google._client import TokenManager
|
||||
|
||||
|
||||
class GSlidesAccessor(Accessor):
|
||||
|
||||
def __init__(self, config, token_manager: TokenManager) -> None:
|
||||
self.config = config
|
||||
self.token_manager = token_manager
|
||||
@@ -0,0 +1,59 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, SecretStr, field_validator
|
||||
|
||||
from mirage.accessor._hf import _HfAccessor
|
||||
from mirage.utils import key_prefix as kp
|
||||
|
||||
|
||||
class HfBucketsConfig(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
bucket: str
|
||||
token: SecretStr | None = None
|
||||
endpoint: str = "https://huggingface.co"
|
||||
timeout: int = 30
|
||||
key_prefix: str | None = None
|
||||
|
||||
@field_validator("bucket")
|
||||
@classmethod
|
||||
def _validate_bucket(cls, v: str) -> str:
|
||||
parts = v.split("/")
|
||||
if len(parts) != 2 or not parts[0] or not parts[1]:
|
||||
raise ValueError(
|
||||
f"bucket must be in 'namespace/name' form; got {v!r}")
|
||||
return v
|
||||
|
||||
@field_validator("key_prefix")
|
||||
@classmethod
|
||||
def _normalize_key_prefix(cls, v: str | None) -> str | None:
|
||||
return kp.normalize(v) or None
|
||||
|
||||
@property
|
||||
def namespace(self) -> str:
|
||||
return self.bucket.split("/", 1)[0]
|
||||
|
||||
@property
|
||||
def bucket_name(self) -> str:
|
||||
return self.bucket.split("/", 1)[1]
|
||||
|
||||
|
||||
class HfBucketsAccessor(_HfAccessor):
|
||||
REPO_TYPE = "bucket"
|
||||
RESOURCE_NAME = "hf_buckets"
|
||||
|
||||
@property
|
||||
def bucket_uri(self) -> str:
|
||||
return f"hf://buckets/{self.config.bucket}"
|
||||
@@ -0,0 +1,60 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, SecretStr, field_validator
|
||||
|
||||
from mirage.accessor._hf import _HfAccessor
|
||||
from mirage.utils import key_prefix as kp
|
||||
|
||||
|
||||
class HfDatasetsConfig(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
repo_id: str
|
||||
token: SecretStr | None = None
|
||||
endpoint: str = "https://huggingface.co"
|
||||
timeout: int = 30
|
||||
key_prefix: str | None = None
|
||||
revision: str | None = None
|
||||
|
||||
@field_validator("repo_id")
|
||||
@classmethod
|
||||
def _validate_repo_id(cls, v: str) -> str:
|
||||
parts = v.split("/")
|
||||
if len(parts) != 2 or not parts[0] or not parts[1]:
|
||||
raise ValueError(
|
||||
f"repo_id must be in 'namespace/name' form; got {v!r}")
|
||||
return v
|
||||
|
||||
@field_validator("key_prefix")
|
||||
@classmethod
|
||||
def _normalize_key_prefix(cls, v: str | None) -> str | None:
|
||||
return kp.normalize(v) or None
|
||||
|
||||
@property
|
||||
def namespace(self) -> str:
|
||||
return self.repo_id.split("/", 1)[0]
|
||||
|
||||
@property
|
||||
def repo_name(self) -> str:
|
||||
return self.repo_id.split("/", 1)[1]
|
||||
|
||||
|
||||
class HfDatasetsAccessor(_HfAccessor):
|
||||
REPO_TYPE = "dataset"
|
||||
RESOURCE_NAME = "hf_datasets"
|
||||
|
||||
@property
|
||||
def bucket_uri(self) -> str:
|
||||
return f"hf://datasets/{self.config.repo_id}"
|
||||
@@ -0,0 +1,60 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, SecretStr, field_validator
|
||||
|
||||
from mirage.accessor._hf import _HfAccessor
|
||||
from mirage.utils import key_prefix as kp
|
||||
|
||||
|
||||
class HfModelsConfig(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
repo_id: str
|
||||
token: SecretStr | None = None
|
||||
endpoint: str = "https://huggingface.co"
|
||||
timeout: int = 30
|
||||
key_prefix: str | None = None
|
||||
revision: str | None = None
|
||||
|
||||
@field_validator("repo_id")
|
||||
@classmethod
|
||||
def _validate_repo_id(cls, v: str) -> str:
|
||||
parts = v.split("/")
|
||||
if len(parts) != 2 or not parts[0] or not parts[1]:
|
||||
raise ValueError(
|
||||
f"repo_id must be in 'namespace/name' form; got {v!r}")
|
||||
return v
|
||||
|
||||
@field_validator("key_prefix")
|
||||
@classmethod
|
||||
def _normalize_key_prefix(cls, v: str | None) -> str | None:
|
||||
return kp.normalize(v) or None
|
||||
|
||||
@property
|
||||
def namespace(self) -> str:
|
||||
return self.repo_id.split("/", 1)[0]
|
||||
|
||||
@property
|
||||
def repo_name(self) -> str:
|
||||
return self.repo_id.split("/", 1)[1]
|
||||
|
||||
|
||||
class HfModelsAccessor(_HfAccessor):
|
||||
REPO_TYPE = "model"
|
||||
RESOURCE_NAME = "hf_models"
|
||||
|
||||
@property
|
||||
def bucket_uri(self) -> str:
|
||||
return f"hf://models/{self.config.repo_id}"
|
||||
@@ -0,0 +1,60 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, SecretStr, field_validator
|
||||
|
||||
from mirage.accessor._hf import _HfAccessor
|
||||
from mirage.utils import key_prefix as kp
|
||||
|
||||
|
||||
class HfSpacesConfig(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
repo_id: str
|
||||
token: SecretStr | None = None
|
||||
endpoint: str = "https://huggingface.co"
|
||||
timeout: int = 30
|
||||
key_prefix: str | None = None
|
||||
revision: str | None = None
|
||||
|
||||
@field_validator("repo_id")
|
||||
@classmethod
|
||||
def _validate_repo_id(cls, v: str) -> str:
|
||||
parts = v.split("/")
|
||||
if len(parts) != 2 or not parts[0] or not parts[1]:
|
||||
raise ValueError(
|
||||
f"repo_id must be in 'namespace/name' form; got {v!r}")
|
||||
return v
|
||||
|
||||
@field_validator("key_prefix")
|
||||
@classmethod
|
||||
def _normalize_key_prefix(cls, v: str | None) -> str | None:
|
||||
return kp.normalize(v) or None
|
||||
|
||||
@property
|
||||
def namespace(self) -> str:
|
||||
return self.repo_id.split("/", 1)[0]
|
||||
|
||||
@property
|
||||
def repo_name(self) -> str:
|
||||
return self.repo_id.split("/", 1)[1]
|
||||
|
||||
|
||||
class HfSpacesAccessor(_HfAccessor):
|
||||
REPO_TYPE = "space"
|
||||
RESOURCE_NAME = "hf_spaces"
|
||||
|
||||
@property
|
||||
def bucket_uri(self) -> str:
|
||||
return f"hf://spaces/{self.config.repo_id}"
|
||||
@@ -0,0 +1,26 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
|
||||
|
||||
class HistoryAccessor(Accessor):
|
||||
"""Accessor over the hidden recorder for the /.bash_history view.
|
||||
|
||||
Args:
|
||||
observer (Observer): The workspace's hidden recorder.
|
||||
"""
|
||||
|
||||
def __init__(self, observer) -> None:
|
||||
self.observer = observer
|
||||
@@ -0,0 +1,71 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import lancedb
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.resource.lancedb.config import LanceDBConfig
|
||||
from mirage.resource.secrets import reveal_secret
|
||||
|
||||
|
||||
class LanceDBAccessor(Accessor):
|
||||
|
||||
def __init__(self, config: LanceDBConfig) -> None:
|
||||
self.config = config
|
||||
self._dbs: dict[int, Any] = {}
|
||||
self._tables: dict[tuple[int, str], Any] = {}
|
||||
self._search_cache: dict[tuple[str, str, int], list[dict]] = {}
|
||||
|
||||
def _loop_key(self) -> int:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return 0
|
||||
return id(loop)
|
||||
|
||||
async def db(self) -> Any:
|
||||
key = self._loop_key()
|
||||
db = self._dbs.get(key)
|
||||
if db is None:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if self.config.api_key is not None:
|
||||
kwargs["api_key"] = reveal_secret(self.config.api_key)
|
||||
if self.config.storage_options:
|
||||
kwargs["storage_options"] = self.config.storage_options
|
||||
if self.config.uri.startswith("db://"):
|
||||
kwargs["region"] = self.config.region
|
||||
if self.config.host_override:
|
||||
kwargs["host_override"] = self.config.host_override
|
||||
db = await lancedb.connect_async(self.config.uri, **kwargs)
|
||||
self._dbs[key] = db
|
||||
return db
|
||||
|
||||
async def table(self, name: str) -> Any:
|
||||
key = (self._loop_key(), name)
|
||||
tbl = self._tables.get(key)
|
||||
if tbl is None:
|
||||
db = await self.db()
|
||||
tbl = await db.open_table(name)
|
||||
self._tables[key] = tbl
|
||||
return tbl
|
||||
|
||||
def cached_search(self, key: tuple[str, str, int]) -> list[dict] | None:
|
||||
return self._search_cache.get(key)
|
||||
|
||||
def store_search(self, key: tuple[str, str, int],
|
||||
rows: list[dict]) -> None:
|
||||
self._search_cache[key] = rows
|
||||
@@ -0,0 +1,32 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from langfuse import Langfuse
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.resource.langfuse.config import LangfuseConfig
|
||||
from mirage.resource.secrets import reveal_secret
|
||||
|
||||
|
||||
class LangfuseAccessor(Accessor):
|
||||
|
||||
def __init__(self, config: LangfuseConfig) -> None:
|
||||
self.config = config
|
||||
self.client = Langfuse(
|
||||
public_key=config.public_key,
|
||||
secret_key=reveal_secret(config.secret_key),
|
||||
host=config.host,
|
||||
timeout=30,
|
||||
)
|
||||
self.api = self.client.async_api
|
||||
@@ -0,0 +1,22 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.resource.linear.config import LinearConfig
|
||||
|
||||
|
||||
class LinearAccessor(Accessor):
|
||||
|
||||
def __init__(self, config: LinearConfig) -> None:
|
||||
self.config = config
|
||||
@@ -0,0 +1,80 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
from importlib.metadata import version as get_version
|
||||
from typing import Any
|
||||
|
||||
from pymongo import AsyncMongoClient
|
||||
from pymongo.driver_info import DriverInfo
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.resource.mongodb.config import MongoDBConfig
|
||||
from mirage.resource.secrets import reveal_secret
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
_VERSION = get_version("mirage-ai")
|
||||
except PackageNotFoundError:
|
||||
logger.error("mirage-ai package metadata not found; "
|
||||
"MongoDB driver handshake will omit the version")
|
||||
_VERSION = None
|
||||
|
||||
_DRIVER_INFO = DriverInfo(name="Mirage", version=_VERSION)
|
||||
|
||||
|
||||
class MongoDBAccessor(Accessor):
|
||||
|
||||
def __init__(self,
|
||||
config: MongoDBConfig,
|
||||
listing_cache_ttl: float = 5.0) -> None:
|
||||
self.config = config
|
||||
self.listing_cache_ttl = listing_cache_ttl
|
||||
self._clients: dict[int, AsyncMongoClient] = {}
|
||||
self._cache: dict[str, tuple[float, Any]] = {}
|
||||
|
||||
@property
|
||||
def client(self) -> AsyncMongoClient:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return self._for_loop(None)
|
||||
return self._for_loop(loop)
|
||||
|
||||
def _for_loop(self,
|
||||
loop: asyncio.AbstractEventLoop | None) -> AsyncMongoClient:
|
||||
key = id(loop) if loop is not None else 0
|
||||
client = self._clients.get(key)
|
||||
if client is None:
|
||||
client = AsyncMongoClient(reveal_secret(self.config.uri),
|
||||
driver=_DRIVER_INFO)
|
||||
self._clients[key] = client
|
||||
return client
|
||||
|
||||
async def cached_list(self, key: str,
|
||||
fetch: Callable[[], Awaitable[Any]]) -> Any:
|
||||
if self.listing_cache_ttl <= 0:
|
||||
return await fetch()
|
||||
now = time.monotonic()
|
||||
hit = self._cache.get(key)
|
||||
if hit is not None and hit[0] > now:
|
||||
return hit[1]
|
||||
value = await fetch()
|
||||
self._cache[key] = (now + self.listing_cache_ttl, value)
|
||||
return value
|
||||
@@ -0,0 +1,21 @@
|
||||
import opendal
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.resource.secrets import reveal_secret
|
||||
|
||||
|
||||
class NextcloudAccessor(Accessor):
|
||||
|
||||
def __init__(self, config) -> None:
|
||||
self.config = config
|
||||
|
||||
def operator(self):
|
||||
config = self.config
|
||||
kwargs = {"endpoint": config.url}
|
||||
username = reveal_secret(config.username)
|
||||
if username:
|
||||
kwargs["username"] = username
|
||||
password = reveal_secret(config.password)
|
||||
if password:
|
||||
kwargs["password"] = password
|
||||
return opendal.AsyncOperator("webdav", **kwargs)
|
||||
@@ -0,0 +1,22 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.resource.notion.config import NotionConfig
|
||||
|
||||
|
||||
class NotionAccessor(Accessor):
|
||||
|
||||
def __init__(self, config: NotionConfig) -> None:
|
||||
self.config = config
|
||||
@@ -0,0 +1,36 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, SecretStr
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
|
||||
|
||||
class OneDriveConfig(BaseModel):
|
||||
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
|
||||
|
||||
access_token: SecretStr | Callable[[], str | SecretStr]
|
||||
drive_id: str | None = None
|
||||
site_id: str | None = None
|
||||
key_prefix: str | None = None
|
||||
timeout: int = 30
|
||||
max_retries: int = 5
|
||||
|
||||
|
||||
class OneDriveAccessor(Accessor):
|
||||
|
||||
def __init__(self, config: OneDriveConfig) -> None:
|
||||
self.config = config
|
||||
@@ -0,0 +1,41 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncpg
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.resource.postgres.config import PostgresConfig
|
||||
from mirage.resource.secrets import reveal_secret
|
||||
|
||||
|
||||
class PostgresAccessor(Accessor):
|
||||
|
||||
def __init__(self, config: PostgresConfig) -> None:
|
||||
self.config = config
|
||||
self._pool: asyncpg.Pool | None = None
|
||||
|
||||
async def pool(self) -> asyncpg.Pool:
|
||||
if self._pool is None:
|
||||
self._pool = await asyncpg.create_pool(
|
||||
reveal_secret(self.config.dsn),
|
||||
server_settings={"default_transaction_read_only": "on"},
|
||||
min_size=1,
|
||||
max_size=4,
|
||||
)
|
||||
return self._pool
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._pool is not None:
|
||||
await self._pool.close()
|
||||
self._pool = None
|
||||
@@ -0,0 +1,65 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.resource.qdrant.config import QdrantConfig
|
||||
from mirage.resource.secrets import reveal_secret
|
||||
|
||||
|
||||
class QdrantAccessor(Accessor):
|
||||
|
||||
def __init__(self, config: QdrantConfig) -> None:
|
||||
self.config = config
|
||||
self._clients: dict[int, Any] = {}
|
||||
self._search_cache: dict[tuple[str, str, int], list[dict]] = {}
|
||||
self._indexes_ensured: set[str] = set()
|
||||
|
||||
def _loop_key(self) -> int:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return 0
|
||||
return id(loop)
|
||||
|
||||
async def client(self) -> Any:
|
||||
key = self._loop_key()
|
||||
client = self._clients.get(key)
|
||||
if client is None:
|
||||
api_key = (reveal_secret(self.config.api_key)
|
||||
if self.config.api_key is not None else None)
|
||||
kwargs: dict = {
|
||||
"api_key": api_key,
|
||||
"cloud_inference": self.config.cloud_inference
|
||||
}
|
||||
if self.config.url:
|
||||
kwargs["url"] = self.config.url
|
||||
else:
|
||||
kwargs["host"] = self.config.host
|
||||
kwargs["port"] = self.config.port
|
||||
kwargs["https"] = self.config.https
|
||||
client = AsyncQdrantClient(**kwargs)
|
||||
self._clients[key] = client
|
||||
return client
|
||||
|
||||
def cached_search(self, key: tuple[str, str, int]) -> list[dict] | None:
|
||||
return self._search_cache.get(key)
|
||||
|
||||
def store_search(self, key: tuple[str, str, int],
|
||||
rows: list[dict]) -> None:
|
||||
self._search_cache[key] = rows
|
||||
@@ -0,0 +1,22 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.resource.ram.store import RAMStore
|
||||
|
||||
|
||||
class RAMAccessor(Accessor):
|
||||
|
||||
def __init__(self, store: RAMStore) -> None:
|
||||
self.store = store
|
||||
@@ -0,0 +1,22 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.resource.redis.store import RedisStore
|
||||
|
||||
|
||||
class RedisAccessor(Accessor):
|
||||
|
||||
def __init__(self, store: RedisStore) -> None:
|
||||
self.store = store
|
||||
@@ -0,0 +1,45 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, SecretStr, field_validator
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.utils import key_prefix as kp
|
||||
|
||||
|
||||
class S3Config(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
bucket: str
|
||||
region: str | None = None
|
||||
endpoint_url: str | None = None
|
||||
aws_access_key_id: SecretStr | None = None
|
||||
aws_secret_access_key: SecretStr | None = None
|
||||
aws_session_token: SecretStr | None = None
|
||||
aws_profile: str | None = None
|
||||
path_style: bool = False
|
||||
timeout: int = 30
|
||||
proxy: str | None = None
|
||||
key_prefix: str | None = None
|
||||
|
||||
@field_validator("key_prefix")
|
||||
@classmethod
|
||||
def _normalize_key_prefix(cls, v: str | None) -> str | None:
|
||||
return kp.normalize(v) or None
|
||||
|
||||
|
||||
class S3Accessor(Accessor):
|
||||
|
||||
def __init__(self, config: S3Config) -> None:
|
||||
self.config = config
|
||||
@@ -0,0 +1,12 @@
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.core.msgraph.config import MsGraphConfig
|
||||
|
||||
|
||||
class SharePointConfig(MsGraphConfig):
|
||||
site_filter: str | None = None
|
||||
|
||||
|
||||
class SharePointAccessor(Accessor):
|
||||
|
||||
def __init__(self, config: SharePointConfig) -> None:
|
||||
self.config = config
|
||||
@@ -0,0 +1,22 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.resource.slack.config import SlackConfig
|
||||
|
||||
|
||||
class SlackAccessor(Accessor):
|
||||
|
||||
def __init__(self, config: SlackConfig) -> None:
|
||||
self.config = config
|
||||
@@ -0,0 +1,43 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncssh
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.core.ssh._client import _connect_kwargs
|
||||
|
||||
|
||||
class SSHAccessor(Accessor):
|
||||
|
||||
def __init__(self, config) -> None:
|
||||
self.config = config
|
||||
self._conn: asyncssh.SSHClientConnection | None = None
|
||||
self._sftp: asyncssh.SFTPClient | None = None
|
||||
|
||||
@property
|
||||
def root(self) -> str:
|
||||
return self.config.root
|
||||
|
||||
async def sftp(self) -> asyncssh.SFTPClient:
|
||||
if self._sftp is not None:
|
||||
return self._sftp
|
||||
self._conn = await asyncssh.connect(**_connect_kwargs(self.config))
|
||||
self._sftp = await self._conn.start_sftp_client()
|
||||
return self._sftp
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._conn is not None:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
self._sftp = None
|
||||
@@ -0,0 +1,22 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.resource.trello.config import TrelloConfig
|
||||
|
||||
|
||||
class TrelloAccessor(Accessor):
|
||||
|
||||
def __init__(self, config: TrelloConfig) -> None:
|
||||
self.config = config
|
||||
@@ -0,0 +1,13 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
@@ -0,0 +1,22 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.agents.agno.prompt import MIRAGE_SYSTEM_PROMPT, build_system_prompt
|
||||
from mirage.agents.agno.toolkit import MirageToolkit
|
||||
|
||||
__all__ = [
|
||||
"MirageToolkit",
|
||||
"MIRAGE_SYSTEM_PROMPT",
|
||||
"build_system_prompt",
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.agents.prompts import MIRAGE_SYSTEM_PROMPT, build_system_prompt
|
||||
|
||||
__all__ = ["MIRAGE_SYSTEM_PROMPT", "build_system_prompt"]
|
||||
@@ -0,0 +1,133 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import shlex
|
||||
|
||||
try:
|
||||
from agno.tools import Toolkit
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"`agno` not installed. Install with: pip install 'mirage-ai[agno]'"
|
||||
) from exc
|
||||
|
||||
from mirage.agents.io_text import io_to_str
|
||||
from mirage.bridge.sync import run_async_from_sync
|
||||
from mirage.workspace.workspace import Workspace
|
||||
|
||||
|
||||
class MirageToolkit(Toolkit):
|
||||
"""Agno toolkit backed by a Mirage Workspace.
|
||||
|
||||
Exposes shell-style filesystem access (execute, read, write, ls, grep)
|
||||
as sync and async tool pairs for Agno agents.
|
||||
|
||||
Args:
|
||||
workspace (Workspace): The workspace to operate on.
|
||||
"""
|
||||
|
||||
def __init__(self, workspace: Workspace, **kwargs) -> None:
|
||||
self._ws = workspace
|
||||
tools = [self.execute, self.read, self.write, self.ls, self.grep]
|
||||
async_tools = [
|
||||
(self.aexecute, "execute"),
|
||||
(self.aread, "read"),
|
||||
(self.awrite, "write"),
|
||||
(self.als, "ls"),
|
||||
(self.agrep, "grep"),
|
||||
]
|
||||
super().__init__(name="mirage",
|
||||
tools=tools,
|
||||
async_tools=async_tools,
|
||||
**kwargs)
|
||||
|
||||
def _run(self, coro):
|
||||
return run_async_from_sync(coro)
|
||||
|
||||
# -- execute ---------------------------------------------------------
|
||||
|
||||
def execute(self, command: str) -> str:
|
||||
"""Run a shell-style command on the mounted filesystem.
|
||||
|
||||
Supports cat, grep, find, head, pipe, and any other Unix command.
|
||||
|
||||
Args:
|
||||
command (str): The shell command to execute.
|
||||
"""
|
||||
return self._run(self.aexecute(command))
|
||||
|
||||
async def aexecute(self, command: str) -> str:
|
||||
io = await self._ws.execute(command)
|
||||
return io_to_str(io)
|
||||
|
||||
# -- read --------------------------------------------------------------
|
||||
|
||||
def read(self, path: str) -> str:
|
||||
"""Read the full contents of a file at the given path.
|
||||
|
||||
Args:
|
||||
path (str): Absolute path to the file on the mounted filesystem.
|
||||
"""
|
||||
return self._run(self.aread(path))
|
||||
|
||||
async def aread(self, path: str) -> str:
|
||||
io = await self._ws.execute(f"cat {shlex.quote(path)}")
|
||||
return io_to_str(io)
|
||||
|
||||
# -- write -------------------------------------------------------------
|
||||
|
||||
def write(self, path: str, content: str) -> str:
|
||||
"""Write content to a file, creating it if it does not exist.
|
||||
|
||||
Args:
|
||||
path (str): Absolute path to the file on the mounted filesystem.
|
||||
content (str): The content to write to the file.
|
||||
"""
|
||||
return self._run(self.awrite(path, content))
|
||||
|
||||
async def awrite(self, path: str, content: str) -> str:
|
||||
io = await self._ws.execute(f"tee {shlex.quote(path)}",
|
||||
stdin=content.encode("utf-8"))
|
||||
return io_to_str(io)
|
||||
|
||||
# -- ls ------------------------------------------------------------------
|
||||
|
||||
def ls(self, path: str = "/") -> str:
|
||||
"""List the files and directories at the given path.
|
||||
|
||||
Args:
|
||||
path (str): Absolute directory path to list. Defaults to root.
|
||||
"""
|
||||
return self._run(self.als(path))
|
||||
|
||||
async def als(self, path: str = "/") -> str:
|
||||
io = await self._ws.execute(f"ls {shlex.quote(path)}")
|
||||
return io_to_str(io)
|
||||
|
||||
# -- grep ---------------------------------------------------------------
|
||||
|
||||
def grep(self, pattern: str, path: str) -> str:
|
||||
"""Search for a pattern in files at the given path.
|
||||
|
||||
Supports regex patterns.
|
||||
|
||||
Args:
|
||||
pattern (str): The string or regex pattern to search for.
|
||||
path (str): The file or directory path to search within.
|
||||
"""
|
||||
return self._run(self.agrep(pattern, path))
|
||||
|
||||
async def agrep(self, pattern: str, path: str) -> str:
|
||||
io = await self._ws.execute(
|
||||
f"grep -r {shlex.quote(pattern)} {shlex.quote(path)}")
|
||||
return io_to_str(io)
|
||||
@@ -0,0 +1,18 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.agents.camel.file import MirageFileToolkit
|
||||
from mirage.agents.camel.terminal import MirageTerminalToolkit
|
||||
|
||||
__all__ = ["MirageFileToolkit", "MirageTerminalToolkit"]
|
||||
@@ -0,0 +1,56 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from collections.abc import Coroutine
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AsyncRunner:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _ensure_loop(self) -> asyncio.AbstractEventLoop:
|
||||
with self._lock:
|
||||
if self._loop is not None and not self._loop.is_closed():
|
||||
return self._loop
|
||||
self._loop = asyncio.new_event_loop()
|
||||
self._thread = threading.Thread(
|
||||
target=self._loop.run_forever,
|
||||
name="mirage-camel-runner",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
return self._loop
|
||||
|
||||
def run(self, coro: Coroutine[Any, Any, Any]) -> Any:
|
||||
loop = self._ensure_loop()
|
||||
future = asyncio.run_coroutine_threadsafe(coro, loop)
|
||||
return future.result()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
if self._loop is None:
|
||||
return
|
||||
loop = self._loop
|
||||
self._loop = None
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=2.0)
|
||||
self._thread = None
|
||||
loop.close()
|
||||
@@ -0,0 +1,256 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import shlex
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from camel.toolkits import FileToolkit
|
||||
|
||||
from mirage.agents.camel._async import AsyncRunner
|
||||
from mirage.workspace.workspace import Workspace
|
||||
|
||||
|
||||
class MirageFileToolkit(FileToolkit):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace: Workspace,
|
||||
working_directory: str = "/",
|
||||
timeout: float | None = None,
|
||||
default_encoding: str = "utf-8",
|
||||
backup_enabled: bool = False,
|
||||
) -> None:
|
||||
self._ws = workspace
|
||||
self._runner = AsyncRunner()
|
||||
self._mirage_root = working_directory
|
||||
self._tmpdir = tempfile.TemporaryDirectory(prefix="mirage-camel-")
|
||||
super().__init__(
|
||||
working_directory=self._tmpdir.name,
|
||||
timeout=timeout,
|
||||
default_encoding=default_encoding,
|
||||
backup_enabled=backup_enabled,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._runner.close()
|
||||
self._tmpdir.cleanup()
|
||||
|
||||
def _to_mirage_path(self, file_path: str) -> str:
|
||||
path_str = file_path
|
||||
if not path_str.startswith("/"):
|
||||
base = self._mirage_root.rstrip("/") or ""
|
||||
path_str = f"{base}/{path_str}"
|
||||
return path_str
|
||||
|
||||
def _read_mirage_bytes(self, mirage_path: str) -> bytes:
|
||||
quoted = shlex.quote(mirage_path)
|
||||
io = self._runner.run(self._ws.execute(f"cat {quoted}"))
|
||||
if io.exit_code != 0:
|
||||
stderr = io.stderr if isinstance(io.stderr, bytes) else b""
|
||||
raise FileNotFoundError(stderr.decode("utf-8", errors="replace"))
|
||||
return io.stdout if isinstance(io.stdout, bytes) else b""
|
||||
|
||||
def _write_mirage_bytes(self, mirage_path: str, data: bytes) -> None:
|
||||
parent = str(Path(mirage_path).parent)
|
||||
if parent and parent != "/":
|
||||
mkdir_io = self._runner.run(
|
||||
self._ws.execute(f"mkdir -p {shlex.quote(parent)}"))
|
||||
if mkdir_io.exit_code != 0:
|
||||
stderr = mkdir_io.stderr if isinstance(mkdir_io.stderr,
|
||||
bytes) else b""
|
||||
raise OSError(stderr.decode("utf-8", errors="replace"))
|
||||
quoted = shlex.quote(mirage_path)
|
||||
io = self._runner.run(self._ws.execute(f"cat > {quoted}", stdin=data))
|
||||
if io.exit_code != 0:
|
||||
stderr = io.stderr if isinstance(io.stderr, bytes) else b""
|
||||
raise OSError(stderr.decode("utf-8", errors="replace"))
|
||||
|
||||
def write_to_file(
|
||||
self,
|
||||
title: str,
|
||||
content: str | list[list[str]],
|
||||
filename: str,
|
||||
encoding: str | None = None,
|
||||
use_latex: bool = False,
|
||||
) -> str:
|
||||
"""Write content to filename over Mirage Workspace.
|
||||
|
||||
Args:
|
||||
title (str): Document title (used by some format writers).
|
||||
content: Content payload.
|
||||
filename (str): Logical Mirage path.
|
||||
encoding (str | None): Override default encoding.
|
||||
use_latex (bool): Forwarded to PDF writer.
|
||||
|
||||
Returns:
|
||||
str: Success or error message.
|
||||
"""
|
||||
mirage_path = self._to_mirage_path(filename)
|
||||
local_name = Path(filename).name or "out"
|
||||
local_path = Path(self._tmpdir.name) / local_name
|
||||
super_msg = super().write_to_file(
|
||||
title=title,
|
||||
content=content,
|
||||
filename=str(local_path),
|
||||
encoding=encoding,
|
||||
use_latex=use_latex,
|
||||
)
|
||||
if super_msg.startswith("Error"):
|
||||
return super_msg
|
||||
produced = self._find_produced_file(local_path)
|
||||
if produced is None:
|
||||
return f"Error: format writer produced no file for {local_path}"
|
||||
target = self._adjust_extension(mirage_path, produced)
|
||||
try:
|
||||
self._write_mirage_bytes(target, produced.read_bytes())
|
||||
except OSError as exc:
|
||||
return f"Error writing {target}: {exc}"
|
||||
return f"Content successfully written to file: {target}"
|
||||
|
||||
def read_file(self, file_paths: str | list[str]) -> str | dict[str, str]:
|
||||
"""Read files from Mirage and return Markdown-rendered text.
|
||||
|
||||
Args:
|
||||
file_paths (str | list[str]): Single path or list of paths.
|
||||
|
||||
Returns:
|
||||
str | dict[str, str]: Same shape as camel read_file.
|
||||
"""
|
||||
if isinstance(file_paths, str):
|
||||
return self._read_one(file_paths)
|
||||
out: dict[str, str] = {}
|
||||
for fp in file_paths:
|
||||
out[fp] = self._read_one(fp)
|
||||
return out
|
||||
|
||||
def _read_one(self, file_path: str) -> str:
|
||||
mirage_path = self._to_mirage_path(file_path)
|
||||
try:
|
||||
data = self._read_mirage_bytes(mirage_path)
|
||||
except FileNotFoundError as exc:
|
||||
return f"Failed to read file: {mirage_path} ({exc})"
|
||||
suffix = Path(mirage_path).suffix or ".txt"
|
||||
local = Path(
|
||||
self._tmpdir.name) / f"read_{abs(hash(mirage_path))}{suffix}"
|
||||
local.write_bytes(data)
|
||||
return super().read_file(file_paths=str(local))
|
||||
|
||||
def _find_produced_file(self, expected: Path) -> Path | None:
|
||||
if expected.exists():
|
||||
return expected
|
||||
for ext in (".md", ".pdf", ".docx", ".csv", ".json", ".html", ".txt"):
|
||||
candidate = expected.with_suffix(ext)
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
parent = expected.parent
|
||||
if parent.exists():
|
||||
for child in parent.iterdir():
|
||||
if child.stem == expected.stem and child.is_file():
|
||||
return child
|
||||
return None
|
||||
|
||||
def _adjust_extension(self, mirage_path: str, produced: Path) -> str:
|
||||
target = Path(mirage_path)
|
||||
if target.suffix:
|
||||
return mirage_path
|
||||
return str(target.with_suffix(produced.suffix))
|
||||
|
||||
def edit_file(self, file_path: str, old_content: str,
|
||||
new_content: str) -> str:
|
||||
"""Replace old_content with new_content in a Mirage file.
|
||||
|
||||
Args:
|
||||
file_path (str): Logical Mirage path.
|
||||
old_content (str): Exact text to find.
|
||||
new_content (str): Replacement text.
|
||||
|
||||
Returns:
|
||||
str: Success or error message.
|
||||
"""
|
||||
mirage_path = self._to_mirage_path(file_path)
|
||||
try:
|
||||
data = self._read_mirage_bytes(mirage_path).decode(
|
||||
self.default_encoding)
|
||||
except FileNotFoundError:
|
||||
return f"Error: File {mirage_path} does not exist"
|
||||
if old_content not in data:
|
||||
return f"Error: old_content not found in {mirage_path}"
|
||||
new_data = data.replace(old_content, new_content)
|
||||
self._write_mirage_bytes(mirage_path,
|
||||
new_data.encode(self.default_encoding))
|
||||
return f"Successfully edited file: {mirage_path}"
|
||||
|
||||
def search_files(self, file_name: str, path: str | None = None) -> str:
|
||||
"""Locate files by name pattern via Mirage's find.
|
||||
|
||||
Args:
|
||||
file_name (str): Glob pattern passed to find -name.
|
||||
path (str | None): Search root; defaults to working_directory.
|
||||
|
||||
Returns:
|
||||
str: Newline-separated list of matching paths.
|
||||
"""
|
||||
root = self._to_mirage_path(path or self._mirage_root)
|
||||
cmd = f"find {shlex.quote(root)} -name {shlex.quote(file_name)}"
|
||||
io = self._runner.run(self._ws.execute(cmd))
|
||||
return _io_text(io)
|
||||
|
||||
def glob_files(self, pattern: str, path: str | None = None) -> str:
|
||||
"""Glob via Mirage's find -name.
|
||||
|
||||
Args:
|
||||
pattern (str): Glob pattern.
|
||||
path (str | None): Search root.
|
||||
|
||||
Returns:
|
||||
str: Newline-separated list of matches.
|
||||
"""
|
||||
return self.search_files(file_name=pattern, path=path)
|
||||
|
||||
def grep_files(
|
||||
self,
|
||||
pattern: str,
|
||||
path: str | None = None,
|
||||
file_pattern: str | None = None,
|
||||
) -> str:
|
||||
"""Regex search via Mirage's grep -rn.
|
||||
|
||||
Args:
|
||||
pattern (str): Regex.
|
||||
path (str | None): Search root.
|
||||
file_pattern (str | None): --include glob.
|
||||
|
||||
Returns:
|
||||
str: Concatenated grep output.
|
||||
"""
|
||||
root = self._to_mirage_path(path or self._mirage_root)
|
||||
parts = ["grep", "-rn", shlex.quote(pattern)]
|
||||
if file_pattern:
|
||||
parts.insert(2, f"--include={shlex.quote(file_pattern)}")
|
||||
parts.append(shlex.quote(root))
|
||||
io = self._runner.run(self._ws.execute(" ".join(parts)))
|
||||
return _io_text(io)
|
||||
|
||||
|
||||
def _io_text(io) -> str:
|
||||
stdout = io.stdout if isinstance(io.stdout, bytes) else b""
|
||||
stderr = io.stderr if isinstance(io.stderr, bytes) else b""
|
||||
out = stdout.decode("utf-8", errors="replace")
|
||||
err = stderr.decode("utf-8", errors="replace")
|
||||
if err and not out:
|
||||
return err
|
||||
if err:
|
||||
return f"{out}\n{err}"
|
||||
return out
|
||||
@@ -0,0 +1,169 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import shlex
|
||||
|
||||
from camel.toolkits import BaseToolkit, FunctionTool
|
||||
|
||||
from mirage.agents.camel._async import AsyncRunner
|
||||
from mirage.agents.io_text import decode, io_to_str
|
||||
from mirage.workspace.workspace import Workspace
|
||||
|
||||
|
||||
def _parse_job_id(stderr: str) -> int | None:
|
||||
line = stderr.strip()
|
||||
if line.startswith("[") and "]" in line:
|
||||
try:
|
||||
return int(line[1:line.index("]")])
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class MirageTerminalToolkit(BaseToolkit):
|
||||
|
||||
def __init__(self,
|
||||
workspace: Workspace,
|
||||
timeout: float | None = 20.0) -> None:
|
||||
super().__init__(timeout=timeout)
|
||||
self._ws = workspace
|
||||
self._runner = AsyncRunner()
|
||||
self._sessions: dict[str, int] = {}
|
||||
|
||||
def close(self) -> None:
|
||||
self._runner.close()
|
||||
|
||||
def shell_exec(
|
||||
self,
|
||||
id: str,
|
||||
command: str,
|
||||
block: bool = True,
|
||||
timeout: float = 20.0,
|
||||
) -> str:
|
||||
"""Run command in the Mirage workspace.
|
||||
|
||||
Args:
|
||||
id (str): Session identifier (mapped to a Mirage job id).
|
||||
command (str): Shell command to execute.
|
||||
block (bool): Wait for completion when True; otherwise launch
|
||||
the command in the background via Mirage's & operator.
|
||||
timeout (float): Reserved for future use.
|
||||
|
||||
Returns:
|
||||
str: Combined stdout/stderr (blocking) or a confirmation
|
||||
message with the session id (non-blocking).
|
||||
"""
|
||||
if block:
|
||||
io = self._runner.run(self._ws.execute(command))
|
||||
return io_to_str(io)
|
||||
bg_cmd = f"{command} &"
|
||||
io = self._runner.run(self._ws.execute(bg_cmd))
|
||||
stderr = decode(io.stderr if isinstance(io.stderr, bytes) else None)
|
||||
job_id = _parse_job_id(stderr)
|
||||
if job_id is None:
|
||||
return f"Failed to launch background job: {stderr}"
|
||||
self._sessions[id] = job_id
|
||||
return f"Started session '{id}' as Mirage job [{job_id}]"
|
||||
|
||||
def shell_view(self, id: str) -> str:
|
||||
"""Return the latest output for a session.
|
||||
|
||||
Args:
|
||||
id (str): Session identifier from a prior non-blocking
|
||||
shell_exec call.
|
||||
|
||||
Returns:
|
||||
str: jobs status if still running, or wait output if completed.
|
||||
"""
|
||||
job_id = self._sessions.get(id)
|
||||
if job_id is None:
|
||||
return f"Error: no session '{id}'"
|
||||
ps_io = self._runner.run(self._ws.execute("ps"))
|
||||
ps_out = decode(
|
||||
ps_io.stdout if isinstance(ps_io.stdout, bytes) else None)
|
||||
if any(line.startswith(f"{job_id}\t") for line in ps_out.splitlines()):
|
||||
return ps_out
|
||||
wait_io = self._runner.run(self._ws.execute(f"wait %{job_id}"))
|
||||
return io_to_str(wait_io)
|
||||
|
||||
def shell_write_to_process(self, id: str, command: str) -> str:
|
||||
"""Stub for camel API parity; Mirage shell is non-interactive.
|
||||
|
||||
Args:
|
||||
id (str): Session identifier (unused).
|
||||
command (str): Input that would be sent to stdin (unused).
|
||||
|
||||
Returns:
|
||||
str: Explanatory error message.
|
||||
"""
|
||||
return ("Mirage shell is not interactive. Re-run shell_exec with "
|
||||
"stdin redirected via the command, e.g. "
|
||||
"'cat <<EOF | yourcmd\\nINPUT\\nEOF'.")
|
||||
|
||||
def shell_kill_process(self, id: str) -> str:
|
||||
"""Kill the Mirage job for a session.
|
||||
|
||||
Args:
|
||||
id (str): Session identifier from a prior non-blocking
|
||||
shell_exec call.
|
||||
|
||||
Returns:
|
||||
str: Status message.
|
||||
"""
|
||||
job_id = self._sessions.pop(id, None)
|
||||
if job_id is None:
|
||||
return f"Error: no session '{id}'"
|
||||
io = self._runner.run(self._ws.execute(f"kill %{job_id}"))
|
||||
if io.exit_code != 0:
|
||||
return io_to_str(io) or f"kill failed for [{job_id}]"
|
||||
return f"killed session '{id}' (job [{job_id}])"
|
||||
|
||||
def shell_ask_user_for_help(self, id: str, prompt: str) -> str:
|
||||
"""Placeholder hook for human-in-the-loop frameworks.
|
||||
|
||||
Args:
|
||||
id (str): Session identifier (unused).
|
||||
prompt (str): The question the agent wants to ask.
|
||||
|
||||
Returns:
|
||||
str: Echoed prompt.
|
||||
"""
|
||||
return f"User prompt recorded (no human attached): {prompt}"
|
||||
|
||||
def shell_write_content_to_file(self, content: str, file_path: str) -> str:
|
||||
"""Write content to a file via Mirage Workspace.
|
||||
|
||||
Args:
|
||||
content (str): UTF-8 text to write.
|
||||
file_path (str): Logical Mirage path.
|
||||
|
||||
Returns:
|
||||
str: Success or error message.
|
||||
"""
|
||||
quoted = shlex.quote(file_path)
|
||||
io = self._runner.run(
|
||||
self._ws.execute(f"cat > {quoted}", stdin=content.encode()))
|
||||
if io.exit_code != 0:
|
||||
return f"Error writing {file_path}: {io_to_str(io)}"
|
||||
return f"Wrote {len(content)} bytes to {file_path}"
|
||||
|
||||
def get_tools(self) -> list[FunctionTool]:
|
||||
return [
|
||||
FunctionTool(self.shell_exec),
|
||||
FunctionTool(self.shell_view),
|
||||
FunctionTool(self.shell_write_to_process),
|
||||
FunctionTool(self.shell_kill_process),
|
||||
FunctionTool(self.shell_ask_user_for_help),
|
||||
FunctionTool(self.shell_write_content_to_file),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.agents.claude_agent_sdk.options import build_options
|
||||
from mirage.agents.claude_agent_sdk.prompt import (MIRAGE_SYSTEM_PROMPT,
|
||||
build_system_prompt)
|
||||
from mirage.agents.claude_agent_sdk.server import MirageServer
|
||||
|
||||
__all__ = [
|
||||
"MirageServer",
|
||||
"build_options",
|
||||
"MIRAGE_SYSTEM_PROMPT",
|
||||
"build_system_prompt",
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
try:
|
||||
from claude_agent_sdk import ClaudeAgentOptions
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"`claude-agent-sdk` not installed. "
|
||||
"Install with: pip install 'mirage-ai[claude-agent-sdk]'") from exc
|
||||
|
||||
from mirage.agents.claude_agent_sdk.prompt import build_system_prompt
|
||||
from mirage.agents.claude_agent_sdk.server import MirageServer
|
||||
from mirage.workspace.workspace import Workspace
|
||||
|
||||
|
||||
def build_options(
|
||||
workspace: Workspace,
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
) -> ClaudeAgentOptions:
|
||||
"""Build ClaudeAgentOptions backed by a Mirage Workspace.
|
||||
|
||||
Disables all built-in file tools and registers Mirage tools
|
||||
(execute_command, read, write, edit, ls, grep) as the agent's
|
||||
only file access layer.
|
||||
|
||||
Args:
|
||||
workspace (Workspace): The workspace to serve.
|
||||
system_prompt (str | None): Override the default Mirage system prompt.
|
||||
|
||||
Returns:
|
||||
ClaudeAgentOptions: Ready to pass to claude_agent_sdk.query().
|
||||
"""
|
||||
server = MirageServer(workspace)
|
||||
return ClaudeAgentOptions(
|
||||
mcp_servers={"mirage": server},
|
||||
allowed_tools=["mcp__mirage__*"],
|
||||
tools=[],
|
||||
system_prompt=system_prompt
|
||||
or build_system_prompt(workspace=workspace),
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.agents.prompts import MIRAGE_SYSTEM_PROMPT, build_system_prompt
|
||||
|
||||
EXECUTE_DESCRIPTION = (
|
||||
"Run a shell-style command on the Mirage virtual filesystem. "
|
||||
"Supports cat, grep, find, head, tail, ls, wc, sort, uniq, tee, pipe, "
|
||||
"and any other Unix command on mounted resources (S3, disk, RAM, etc.). "
|
||||
"Also supports reading structured files: "
|
||||
"cat on .parquet/.orc/.csv returns a table.")
|
||||
|
||||
READ_DESCRIPTION = (
|
||||
"Read the contents of a file on the Mirage virtual filesystem. "
|
||||
"Returns line-numbered text. "
|
||||
"Optionally pass 'offset' (default 0) to start at a given line "
|
||||
"and 'limit' (default 2000) to cap the number of lines returned.")
|
||||
|
||||
WRITE_DESCRIPTION = (
|
||||
"Write content to a new file on the Mirage virtual filesystem. "
|
||||
"Fails if the file already exists; use edit to modify an existing file.")
|
||||
|
||||
EDIT_DESCRIPTION = (
|
||||
"Replace a string in an existing file on the Mirage virtual filesystem. "
|
||||
"Fails if old_string is not found or appears more than once. "
|
||||
"Pass replace_all=true (default false) to replace every occurrence.")
|
||||
|
||||
LS_DESCRIPTION = ("List files and directories at the given path "
|
||||
"on the Mirage virtual filesystem.")
|
||||
|
||||
GREP_DESCRIPTION = (
|
||||
"Search for a pattern in files on the Mirage virtual filesystem. "
|
||||
"Supports regex. Searches recursively under path.")
|
||||
|
||||
__all__ = [
|
||||
"MIRAGE_SYSTEM_PROMPT",
|
||||
"build_system_prompt",
|
||||
"EXECUTE_DESCRIPTION",
|
||||
"READ_DESCRIPTION",
|
||||
"WRITE_DESCRIPTION",
|
||||
"EDIT_DESCRIPTION",
|
||||
"LS_DESCRIPTION",
|
||||
"GREP_DESCRIPTION",
|
||||
]
|
||||
@@ -0,0 +1,181 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import shlex
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from claude_agent_sdk import ToolAnnotations, create_sdk_mcp_server, tool
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"`claude-agent-sdk` not installed. "
|
||||
"Install with: pip install 'mirage-ai[claude-agent-sdk]'") from exc
|
||||
|
||||
from mirage import __version__
|
||||
from mirage.agents.claude_agent_sdk.prompt import ( # yapf: disable
|
||||
EDIT_DESCRIPTION, EXECUTE_DESCRIPTION, GREP_DESCRIPTION, LS_DESCRIPTION,
|
||||
READ_DESCRIPTION, WRITE_DESCRIPTION)
|
||||
from mirage.agents.io_text import io_to_str
|
||||
from mirage.io.types import IOResult
|
||||
from mirage.workspace.workspace import Workspace
|
||||
|
||||
|
||||
def _text(text: str) -> dict[str, Any]:
|
||||
return {"content": [{"type": "text", "text": text}]}
|
||||
|
||||
|
||||
def _error(text: str) -> dict[str, Any]:
|
||||
return {"content": [{"type": "text", "text": text}], "is_error": True}
|
||||
|
||||
|
||||
def _io_to_result(io: IOResult) -> dict[str, Any]:
|
||||
result = _text(io_to_str(io))
|
||||
if io.exit_code != 0:
|
||||
result["is_error"] = True
|
||||
return result
|
||||
|
||||
|
||||
class _MirageTools:
|
||||
|
||||
def __init__(self, workspace: Workspace) -> None:
|
||||
self._ws = workspace
|
||||
|
||||
async def execute_command(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
io = await self._ws.execute(args["command"])
|
||||
return _io_to_result(io)
|
||||
|
||||
async def _ensure_parents(self, path: str) -> None:
|
||||
parts = [p for p in path.split("/") if p]
|
||||
ops = self._ws.ops
|
||||
current = ""
|
||||
for part in parts[:-1]:
|
||||
current += "/" + part
|
||||
try:
|
||||
await ops.mkdir(current)
|
||||
except FileExistsError:
|
||||
continue
|
||||
|
||||
async def read(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
path = args["path"]
|
||||
offset = int(args.get("offset", 0))
|
||||
limit = int(args.get("limit", 2000))
|
||||
ops = self._ws.ops
|
||||
try:
|
||||
data = await ops.read(path)
|
||||
except FileNotFoundError:
|
||||
return _error(f"Error: file '{path}' not found")
|
||||
except ValueError as exc:
|
||||
return _error(f"Error: {exc}")
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
lines = text.splitlines(keepends=True)
|
||||
sliced = lines[offset:offset + limit]
|
||||
numbered = [
|
||||
f"{i + offset + 1:>6}\t{line}" for i, line in enumerate(sliced)
|
||||
]
|
||||
return _text("".join(numbered))
|
||||
|
||||
async def write(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
path = args["path"]
|
||||
content = args["content"]
|
||||
ops = self._ws.ops
|
||||
try:
|
||||
await ops.stat(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
return _error(f"Error: file '{path}' already exists")
|
||||
await self._ensure_parents(path)
|
||||
data = content.encode("utf-8") if isinstance(content, str) else content
|
||||
await ops.write(path, data)
|
||||
return _text(f"Written: {path}")
|
||||
|
||||
async def edit(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
path = args["path"]
|
||||
old_string = args["old_string"]
|
||||
new_string = args["new_string"]
|
||||
replace_all = bool(args.get("replace_all", False))
|
||||
ops = self._ws.ops
|
||||
try:
|
||||
data = await ops.read(path)
|
||||
except FileNotFoundError:
|
||||
return _error(f"Error: file '{path}' not found")
|
||||
content = data.decode("utf-8", errors="replace")
|
||||
count = content.count(old_string)
|
||||
if count == 0:
|
||||
return _error(f"Error: string not found in file: '{old_string}'")
|
||||
if count > 1 and not replace_all:
|
||||
return _error(
|
||||
f"Error: string appears {count} times. Pass replace_all=true")
|
||||
new_content = content.replace(
|
||||
old_string, new_string) if replace_all else content.replace(
|
||||
old_string, new_string, 1)
|
||||
await ops.write(path, new_content.encode("utf-8"))
|
||||
occurrences = count if replace_all else 1
|
||||
return _text(f"Edited: {path} ({occurrences} occurrence(s))")
|
||||
|
||||
async def ls(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
path = args["path"]
|
||||
io = await self._ws.execute(f"ls {shlex.quote(path)}")
|
||||
return _io_to_result(io)
|
||||
|
||||
async def grep(self, args: dict[str, Any]) -> dict[str, Any]:
|
||||
pattern = args["pattern"]
|
||||
path = args["path"]
|
||||
io = await self._ws.execute(
|
||||
f"grep -rn {shlex.quote(pattern)} {shlex.quote(path)}")
|
||||
return _text(io_to_str(io))
|
||||
|
||||
|
||||
def MirageServer(workspace: Workspace):
|
||||
"""Create an in-process Mirage server for the Claude Agent SDK.
|
||||
|
||||
Args:
|
||||
workspace (Workspace): The workspace to serve.
|
||||
|
||||
Returns:
|
||||
An SDK server object to pass to ClaudeAgentOptions(mcp_servers=...).
|
||||
"""
|
||||
tools_impl = _MirageTools(workspace)
|
||||
return create_sdk_mcp_server(
|
||||
name="mirage",
|
||||
version=__version__,
|
||||
tools=[
|
||||
tool("execute_command", EXECUTE_DESCRIPTION,
|
||||
{"command": str})(tools_impl.execute_command),
|
||||
tool("read",
|
||||
READ_DESCRIPTION, {"path": str},
|
||||
annotations=ToolAnnotations(readOnlyHint=True))(
|
||||
tools_impl.read),
|
||||
tool("write", WRITE_DESCRIPTION, {
|
||||
"path": str,
|
||||
"content": str
|
||||
})(tools_impl.write),
|
||||
tool("edit", EDIT_DESCRIPTION, {
|
||||
"path": str,
|
||||
"old_string": str,
|
||||
"new_string": str
|
||||
})(tools_impl.edit),
|
||||
tool("ls",
|
||||
LS_DESCRIPTION, {"path": str},
|
||||
annotations=ToolAnnotations(readOnlyHint=True))(
|
||||
tools_impl.ls),
|
||||
tool("grep",
|
||||
GREP_DESCRIPTION, {
|
||||
"pattern": str,
|
||||
"path": str
|
||||
},
|
||||
annotations=ToolAnnotations(readOnlyHint=True))(
|
||||
tools_impl.grep),
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.io.types import IOResult
|
||||
|
||||
|
||||
def decode(value: bytes | None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return value.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def io_to_str(io: IOResult) -> str:
|
||||
stdout = decode(io.stdout if isinstance(io.stdout, bytes) else None)
|
||||
stderr = decode(io.stderr if isinstance(io.stderr, bytes) else None)
|
||||
if stderr:
|
||||
return f"{stdout}\n{stderr}" if stdout else stderr
|
||||
return stdout
|
||||
@@ -0,0 +1,25 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.agents.langchain._messages import extract_text
|
||||
from mirage.agents.langchain.backend import LangchainWorkspace
|
||||
from mirage.agents.langchain.prompt import (MIRAGE_SYSTEM_PROMPT,
|
||||
build_system_prompt)
|
||||
|
||||
__all__ = [
|
||||
"LangchainWorkspace",
|
||||
"MIRAGE_SYSTEM_PROMPT",
|
||||
"build_system_prompt",
|
||||
"extract_text",
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from deepagents.backends.protocol import ExecuteResponse, FileInfo, GrepMatch
|
||||
|
||||
from mirage.agents.io_text import decode
|
||||
from mirage.io.types import IOResult
|
||||
|
||||
|
||||
def io_to_execute_response(io: IOResult) -> ExecuteResponse:
|
||||
stdout = decode(io.stdout)
|
||||
stderr = decode(io.stderr)
|
||||
output = stdout
|
||||
if stderr:
|
||||
output = f"{stdout}\n{stderr}" if stdout else stderr
|
||||
return ExecuteResponse(output=output, exit_code=io.exit_code)
|
||||
|
||||
|
||||
def io_to_grep_matches(io: IOResult) -> list[GrepMatch]:
|
||||
stdout = decode(io.stdout).strip()
|
||||
if not stdout:
|
||||
return []
|
||||
matches: list[GrepMatch] = []
|
||||
for line in stdout.split("\n"):
|
||||
parts = line.split(":", 2)
|
||||
if len(parts) >= 3:
|
||||
try:
|
||||
line_num = int(parts[1])
|
||||
except ValueError:
|
||||
continue
|
||||
matches.append(
|
||||
GrepMatch(path=parts[0], line=line_num, text=parts[2]))
|
||||
return matches
|
||||
|
||||
|
||||
def io_to_file_infos(io: IOResult) -> list[FileInfo]:
|
||||
stdout = decode(io.stdout).strip()
|
||||
if not stdout:
|
||||
return []
|
||||
infos: list[FileInfo] = []
|
||||
for entry in stdout.split("\n"):
|
||||
entry = entry.strip()
|
||||
if not entry:
|
||||
continue
|
||||
is_dir = entry.endswith("/")
|
||||
infos.append(FileInfo(path=entry.rstrip("/"), is_dir=is_dir))
|
||||
return infos
|
||||
@@ -0,0 +1,41 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def extract_text(messages: list[Any]) -> list[str]:
|
||||
"""Extract text content from LangGraph messages.
|
||||
|
||||
Args:
|
||||
messages (list[Any]): LangGraph message objects.
|
||||
|
||||
Returns:
|
||||
list[str]: Non-empty text strings.
|
||||
"""
|
||||
texts: list[str] = []
|
||||
for msg in messages:
|
||||
if not hasattr(msg, "content"):
|
||||
continue
|
||||
content = msg.content
|
||||
if isinstance(content, str):
|
||||
if content.strip():
|
||||
texts.append(content)
|
||||
elif isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
text = block["text"].strip()
|
||||
if text:
|
||||
texts.append(text)
|
||||
return texts
|
||||
@@ -0,0 +1,252 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import shlex
|
||||
|
||||
from deepagents.backends.protocol import (EditResult, ExecuteResponse,
|
||||
FileDownloadResponse, FileInfo,
|
||||
FileUploadResponse, GrepMatch,
|
||||
SandboxBackendProtocol, WriteResult)
|
||||
|
||||
from mirage.agents.langchain._convert import (io_to_execute_response,
|
||||
io_to_file_infos,
|
||||
io_to_grep_matches)
|
||||
from mirage.io.types import IOResult
|
||||
from mirage.types import DEFAULT_SESSION_ID
|
||||
from mirage.workspace.workspace import Workspace
|
||||
|
||||
|
||||
class LangchainWorkspace(SandboxBackendProtocol):
|
||||
"""Deep Agents backend backed by a Mirage Workspace.
|
||||
|
||||
File operations (read, write, edit, ls, upload, download) go through the
|
||||
Ops layer directly. Shell operations (execute, grep, glob) go through
|
||||
Workspace.execute() for pipe and flag support.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace: Workspace,
|
||||
sandbox_id: str = "mirage",
|
||||
session_id: str = DEFAULT_SESSION_ID,
|
||||
) -> None:
|
||||
self._ws = workspace
|
||||
self._id = sandbox_id
|
||||
self._session_id = session_id
|
||||
|
||||
def _run(self, coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return self._id
|
||||
|
||||
async def _exec(self, command: str) -> IOResult:
|
||||
return await self._ws.execute(command, session_id=self._session_id)
|
||||
|
||||
# ── execute ──────────────────────────────────────────────
|
||||
|
||||
def execute(self,
|
||||
command: str,
|
||||
*,
|
||||
timeout: int | None = None) -> ExecuteResponse:
|
||||
return self._run(self.aexecute(command, timeout=timeout))
|
||||
|
||||
async def aexecute(self,
|
||||
command: str,
|
||||
*,
|
||||
timeout: int | None = None) -> ExecuteResponse:
|
||||
io = await self._exec(command)
|
||||
return io_to_execute_response(io)
|
||||
|
||||
# ── ls_info ─────────────────────────────────────────────
|
||||
|
||||
def ls_info(self, path: str) -> list[FileInfo]:
|
||||
return self._run(self.als_info(path))
|
||||
|
||||
async def als_info(self, path: str) -> list[FileInfo]:
|
||||
io = await self._exec(f"ls {shlex.quote(path)}")
|
||||
stdout = (io.stdout or b"").decode("utf-8", errors="replace").strip()
|
||||
if not stdout:
|
||||
return []
|
||||
base = path.rstrip("/")
|
||||
result: list[FileInfo] = []
|
||||
for name in stdout.split("\n"):
|
||||
name = name.strip()
|
||||
if not name:
|
||||
continue
|
||||
is_dir = name.endswith("/")
|
||||
clean = name.rstrip("/")
|
||||
result.append(FileInfo(path=f"{base}/{clean}", is_dir=is_dir))
|
||||
return result
|
||||
|
||||
# ── read ─────────────────────────────────────────────────
|
||||
|
||||
def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> str:
|
||||
return self._run(self.aread(file_path, offset, limit))
|
||||
|
||||
async def aread(self,
|
||||
file_path: str,
|
||||
offset: int = 0,
|
||||
limit: int = 2000) -> str:
|
||||
ops = self._ws.ops
|
||||
try:
|
||||
data = await ops.read(file_path)
|
||||
except (FileNotFoundError, ValueError) as exc:
|
||||
return f"Error: {exc}"
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
lines = text.splitlines(keepends=True)
|
||||
sliced = lines[offset:offset + limit]
|
||||
numbered = []
|
||||
for i, line in enumerate(sliced, start=offset + 1):
|
||||
numbered.append(f"{i:>6}\t{line}")
|
||||
return "".join(numbered)
|
||||
|
||||
# ── write ────────────────────────────────────────────────
|
||||
|
||||
def write(self, file_path: str, content: str) -> WriteResult:
|
||||
return self._run(self.awrite(file_path, content))
|
||||
|
||||
async def awrite(self, file_path: str, content: str) -> WriteResult:
|
||||
ops = self._ws.ops
|
||||
try:
|
||||
await ops.stat(file_path)
|
||||
return WriteResult(
|
||||
error=f"Error: file '{file_path}' already exists")
|
||||
except (FileNotFoundError, ValueError):
|
||||
pass
|
||||
parent = "/".join(file_path.rstrip("/").split("/")[:-1]) or "/"
|
||||
try:
|
||||
await ops.mkdir(parent)
|
||||
except (FileExistsError, ValueError):
|
||||
pass
|
||||
await ops.write(file_path, content.encode("utf-8"))
|
||||
return WriteResult(path=file_path)
|
||||
|
||||
# ── edit ─────────────────────────────────────────────────
|
||||
|
||||
def edit(
|
||||
self,
|
||||
file_path: str,
|
||||
old_string: str,
|
||||
new_string: str,
|
||||
replace_all: bool = False,
|
||||
) -> EditResult:
|
||||
return self._run(
|
||||
self.aedit(file_path, old_string, new_string, replace_all))
|
||||
|
||||
async def aedit(
|
||||
self,
|
||||
file_path: str,
|
||||
old_string: str,
|
||||
new_string: str,
|
||||
replace_all: bool = False,
|
||||
) -> EditResult:
|
||||
ops = self._ws.ops
|
||||
try:
|
||||
data = await ops.read(file_path)
|
||||
except (FileNotFoundError, ValueError):
|
||||
return EditResult(error=f"Error: file '{file_path}' not found")
|
||||
content = data.decode("utf-8", errors="replace")
|
||||
count = content.count(old_string)
|
||||
if count == 0:
|
||||
return EditResult(
|
||||
error=f"Error: string not found in file: '{old_string}'")
|
||||
if count > 1 and not replace_all:
|
||||
return EditResult(
|
||||
error=f"Error: string '{old_string}' appears {count} times. "
|
||||
f"Use replace_all=True")
|
||||
if replace_all:
|
||||
new_content = content.replace(old_string, new_string)
|
||||
else:
|
||||
new_content = content.replace(old_string, new_string, 1)
|
||||
await ops.write(file_path, new_content.encode("utf-8"))
|
||||
return EditResult(path=file_path,
|
||||
occurrences=count if replace_all else 1)
|
||||
|
||||
# ── grep_raw ─────────────────────────────────────────────
|
||||
|
||||
def grep_raw(
|
||||
self,
|
||||
pattern: str,
|
||||
path: str | None = None,
|
||||
glob: str | None = None,
|
||||
) -> list[GrepMatch] | str:
|
||||
return self._run(self.agrep_raw(pattern, path, glob))
|
||||
|
||||
async def agrep_raw(
|
||||
self,
|
||||
pattern: str,
|
||||
path: str | None = None,
|
||||
glob: str | None = None,
|
||||
) -> list[GrepMatch] | str:
|
||||
parts = ["grep", "-rn"]
|
||||
if glob:
|
||||
parts.extend(["--include", shlex.quote(glob)])
|
||||
parts.append(shlex.quote(pattern))
|
||||
parts.append(shlex.quote(path or "/"))
|
||||
io = await self._exec(" ".join(parts))
|
||||
return io_to_grep_matches(io)
|
||||
|
||||
# ── glob_info ────────────────────────────────────────────
|
||||
|
||||
def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
|
||||
return self._run(self.aglob_info(pattern, path))
|
||||
|
||||
async def aglob_info(self,
|
||||
pattern: str,
|
||||
path: str = "/") -> list[FileInfo]:
|
||||
name = pattern.split("/")[-1] if "/" in pattern else pattern
|
||||
io = await self._exec(
|
||||
f"find {shlex.quote(path)} -name {shlex.quote(name)}")
|
||||
return io_to_file_infos(io)
|
||||
|
||||
# ── upload / download ────────────────────────────────────
|
||||
|
||||
def upload_files(
|
||||
self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
||||
return self._run(self.aupload_files(files))
|
||||
|
||||
async def aupload_files(
|
||||
self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
|
||||
ops = self._ws.ops
|
||||
results: list[FileUploadResponse] = []
|
||||
for path, data in files:
|
||||
parent = "/".join(path.rstrip("/").split("/")[:-1]) or "/"
|
||||
try:
|
||||
await ops.mkdir(parent)
|
||||
except (FileExistsError, ValueError):
|
||||
pass
|
||||
await ops.write(path, data)
|
||||
results.append(FileUploadResponse(path=path))
|
||||
return results
|
||||
|
||||
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
|
||||
return self._run(self.adownload_files(paths))
|
||||
|
||||
async def adownload_files(self,
|
||||
paths: list[str]) -> list[FileDownloadResponse]:
|
||||
ops = self._ws.ops
|
||||
results: list[FileDownloadResponse] = []
|
||||
for path in paths:
|
||||
try:
|
||||
data = await ops.read(path)
|
||||
results.append(FileDownloadResponse(path=path, content=data))
|
||||
except (FileNotFoundError, ValueError):
|
||||
results.append(
|
||||
FileDownloadResponse(path=path,
|
||||
content=None,
|
||||
error="file_not_found"))
|
||||
return results
|
||||
@@ -0,0 +1,17 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.agents.prompts import MIRAGE_SYSTEM_PROMPT, build_system_prompt
|
||||
|
||||
__all__ = ["MIRAGE_SYSTEM_PROMPT", "build_system_prompt"]
|
||||
@@ -0,0 +1,31 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.agents.openai_agents.editor import MirageEditor
|
||||
from mirage.agents.openai_agents.prompt import (MIRAGE_SYSTEM_PROMPT,
|
||||
build_system_prompt)
|
||||
from mirage.agents.openai_agents.runner import MirageRunner
|
||||
from mirage.agents.openai_agents.sandbox import (MirageSandboxClient,
|
||||
MirageSandboxSession)
|
||||
from mirage.agents.openai_agents.shell import MirageShellExecutor
|
||||
|
||||
__all__ = [
|
||||
"MirageEditor",
|
||||
"MirageRunner",
|
||||
"MirageSandboxClient",
|
||||
"MirageSandboxSession",
|
||||
"MirageShellExecutor",
|
||||
"MIRAGE_SYSTEM_PROMPT",
|
||||
"build_system_prompt",
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from agents.apply_diff import apply_diff
|
||||
from agents.editor import (ApplyPatchEditor, ApplyPatchOperation,
|
||||
ApplyPatchResult)
|
||||
|
||||
from mirage.workspace.workspace import Workspace
|
||||
|
||||
|
||||
class MirageEditor(ApplyPatchEditor):
|
||||
"""ApplyPatchEditor backed by a Mirage Workspace.
|
||||
|
||||
Args:
|
||||
workspace (Workspace): The workspace for file operations.
|
||||
"""
|
||||
|
||||
def __init__(self, workspace: Workspace) -> None:
|
||||
self._ws = workspace
|
||||
|
||||
async def create_file(self, op: ApplyPatchOperation) -> ApplyPatchResult:
|
||||
ops = self._ws.ops
|
||||
parent = "/".join(op.path.rstrip("/").split("/")[:-1]) or "/"
|
||||
try:
|
||||
await ops.mkdir(parent)
|
||||
except (FileExistsError, ValueError):
|
||||
pass
|
||||
content = apply_diff("", op.diff or "", mode="create")
|
||||
await ops.write(op.path, content.encode("utf-8"))
|
||||
return ApplyPatchResult(status="completed")
|
||||
|
||||
async def update_file(self, op: ApplyPatchOperation) -> ApplyPatchResult:
|
||||
ops = self._ws.ops
|
||||
try:
|
||||
data = await ops.read(op.path)
|
||||
except (FileNotFoundError, ValueError):
|
||||
return ApplyPatchResult(status="failed",
|
||||
output=f"File not found: {op.path}")
|
||||
current = data.decode("utf-8", errors="replace")
|
||||
new_content = apply_diff(current, op.diff or "")
|
||||
await ops.write(op.path, new_content.encode("utf-8"))
|
||||
return ApplyPatchResult(status="completed")
|
||||
|
||||
async def delete_file(self, op: ApplyPatchOperation) -> ApplyPatchResult:
|
||||
ops = self._ws.ops
|
||||
try:
|
||||
await ops.unlink(op.path)
|
||||
except (FileNotFoundError, ValueError):
|
||||
return ApplyPatchResult(status="failed",
|
||||
output=f"File not found: {op.path}")
|
||||
return ApplyPatchResult(status="completed")
|
||||
@@ -0,0 +1,17 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.agents.prompts import MIRAGE_SYSTEM_PROMPT, build_system_prompt
|
||||
|
||||
__all__ = ["MIRAGE_SYSTEM_PROMPT", "build_system_prompt"]
|
||||
@@ -0,0 +1,121 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import base64
|
||||
|
||||
from agents import Runner
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from mirage.types import FileType
|
||||
from mirage.workspace.workspace import Workspace
|
||||
|
||||
_VISION_TYPES = {
|
||||
FileType.IMAGE_PNG,
|
||||
FileType.IMAGE_JPEG,
|
||||
FileType.IMAGE_GIF,
|
||||
}
|
||||
|
||||
_MIMETYPE_FOR = {
|
||||
FileType.IMAGE_PNG: "image/png",
|
||||
FileType.IMAGE_JPEG: "image/jpeg",
|
||||
FileType.IMAGE_GIF: "image/gif",
|
||||
FileType.PDF: "application/pdf",
|
||||
}
|
||||
|
||||
|
||||
class MirageRunner:
|
||||
"""Run OpenAI agents with mirage-resolved multimodal attachments.
|
||||
|
||||
Args:
|
||||
workspace (Workspace): The workspace to resolve paths against.
|
||||
client (AsyncOpenAI | None): OpenAI async client, used for PDF
|
||||
uploads via the Files API. Required if any attachment is
|
||||
a PDF; constructed with default settings if not given.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace: Workspace,
|
||||
client: AsyncOpenAI | None = None,
|
||||
) -> None:
|
||||
self._ws = workspace
|
||||
self._client = client
|
||||
|
||||
async def _block_for_path(self, path: str) -> dict:
|
||||
st = await self._ws.ops.stat(path)
|
||||
data = await self._ws.ops.read(path)
|
||||
if st.type in _VISION_TYPES:
|
||||
mime = _MIMETYPE_FOR[st.type]
|
||||
b64 = base64.b64encode(data).decode("ascii")
|
||||
return {
|
||||
"type": "input_image",
|
||||
"image_url": f"data:{mime};base64,{b64}",
|
||||
}
|
||||
if st.type == FileType.PDF:
|
||||
if self._client is None:
|
||||
self._client = AsyncOpenAI()
|
||||
filename = path.rsplit("/", 1)[-1]
|
||||
uploaded = await self._client.files.create(
|
||||
file=(filename, data),
|
||||
purpose="user_data",
|
||||
)
|
||||
return {"type": "input_file", "file_id": uploaded.id}
|
||||
return {
|
||||
"type": "input_text",
|
||||
"text": data.decode("utf-8", errors="replace"),
|
||||
}
|
||||
|
||||
async def build_blocks(
|
||||
self,
|
||||
prompt: str,
|
||||
paths: list[str],
|
||||
) -> list[dict]:
|
||||
"""Build the user-message content blocks for a prompt + paths.
|
||||
|
||||
Args:
|
||||
prompt (str): User-facing instruction text.
|
||||
paths (list[str]): Mirage paths to attach (any resource).
|
||||
|
||||
Returns:
|
||||
list[dict]: Content blocks ready to embed in a user message.
|
||||
"""
|
||||
blocks: list[dict] = [{"type": "input_text", "text": prompt}]
|
||||
for path in paths:
|
||||
blocks.append(await self._block_for_path(path))
|
||||
return blocks
|
||||
|
||||
async def run_with_attachments(
|
||||
self,
|
||||
agent,
|
||||
prompt: str,
|
||||
paths: list[str],
|
||||
):
|
||||
"""Run the agent with mirage paths as multimodal attachments.
|
||||
|
||||
Args:
|
||||
agent: The OpenAI Agents SDK Agent instance.
|
||||
prompt (str): User-facing instruction text.
|
||||
paths (list[str]): Mirage paths to attach.
|
||||
|
||||
Returns:
|
||||
The result from `agents.Runner.run`.
|
||||
"""
|
||||
blocks = await self.build_blocks(prompt, paths)
|
||||
return await Runner.run(
|
||||
agent,
|
||||
[{
|
||||
"role": "user",
|
||||
"content": blocks
|
||||
}],
|
||||
)
|
||||
@@ -0,0 +1,186 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import io
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from agents.sandbox.manifest import Manifest
|
||||
from agents.sandbox.session.base_sandbox_session import BaseSandboxSession
|
||||
from agents.sandbox.session.sandbox_client import BaseSandboxClient
|
||||
from agents.sandbox.session.sandbox_session import SandboxSession
|
||||
from agents.sandbox.session.sandbox_session_state import SandboxSessionState
|
||||
from agents.sandbox.snapshot import NoopSnapshot, SnapshotBase, SnapshotSpec
|
||||
from agents.sandbox.types import ExecResult, User
|
||||
|
||||
from mirage.workspace.snapshot import apply_state_dict, read_tar
|
||||
from mirage.workspace.workspace import Workspace
|
||||
|
||||
|
||||
class MirageSandboxSessionState(SandboxSessionState):
|
||||
type: Literal["mirage"] = "mirage"
|
||||
|
||||
|
||||
class MirageSandboxSession(BaseSandboxSession):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace: Workspace,
|
||||
state: MirageSandboxSessionState,
|
||||
) -> None:
|
||||
self._ws = workspace
|
||||
self.state = state
|
||||
|
||||
async def _exec_internal(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
) -> ExecResult:
|
||||
cmd_str = " ".join(str(c) for c in command)
|
||||
io_result = await self._ws.execute(cmd_str)
|
||||
stdout = (io_result.stdout or b"")
|
||||
stderr = (io_result.stderr or b"")
|
||||
if isinstance(stdout, str):
|
||||
stdout = stdout.encode("utf-8")
|
||||
if isinstance(stderr, str):
|
||||
stderr = stderr.encode("utf-8")
|
||||
return ExecResult(
|
||||
exit_code=io_result.exit_code,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
|
||||
async def read(
|
||||
self,
|
||||
path: Path,
|
||||
*,
|
||||
user: str | User | None = None,
|
||||
) -> io.IOBase:
|
||||
data = await self._ws.ops.read(str(path))
|
||||
return io.BytesIO(data)
|
||||
|
||||
async def write(
|
||||
self,
|
||||
path: Path,
|
||||
data: io.IOBase,
|
||||
*,
|
||||
user: str | User | None = None,
|
||||
) -> None:
|
||||
content = data.read()
|
||||
if isinstance(content, str):
|
||||
content = content.encode("utf-8")
|
||||
parent = str(path.parent)
|
||||
if parent and parent != ".":
|
||||
try:
|
||||
await self._ws.ops.mkdir(parent)
|
||||
except (FileExistsError, ValueError):
|
||||
pass
|
||||
await self._ws.ops.write(str(path), content)
|
||||
|
||||
def _prepare_exec_command(
|
||||
self,
|
||||
*command: str | Path,
|
||||
shell: bool | list[str],
|
||||
user: str | User | None,
|
||||
) -> list[str]:
|
||||
return [str(c) for c in command]
|
||||
|
||||
async def running(self) -> bool:
|
||||
return not self._ws._closed
|
||||
|
||||
async def persist_workspace(self) -> io.IOBase:
|
||||
buf = io.BytesIO()
|
||||
await self._ws.snapshot(buf)
|
||||
buf.seek(0)
|
||||
return buf
|
||||
|
||||
async def hydrate_workspace(self, data: io.IOBase) -> None:
|
||||
# Restore the snapshot's non-mount state (cache, sessions,
|
||||
# inodes, history, jobs) AND each resource's content (via
|
||||
# load_state) into THIS workspace. The workspace must already
|
||||
# have the same mount shape that was saved — Workspace.load()
|
||||
# is the alternative that constructs a fresh Workspace from
|
||||
# scratch.
|
||||
if hasattr(data, "seek"):
|
||||
data.seek(0)
|
||||
state = read_tar(data)
|
||||
await apply_state_dict(self._ws, state)
|
||||
|
||||
|
||||
class MirageSandboxClient(BaseSandboxClient[None]):
|
||||
# In-process integration: every sandbox session shares one Workspace
|
||||
# instance owned by the agent's process. No HTTP, no daemon -- the
|
||||
# agent and the workspace run on the same event loop.
|
||||
#
|
||||
# If you need cross-process isolation (each agent talks to a
|
||||
# workspace hosted in a separate daemon process), see
|
||||
# docs/plans/2026-04-17-workspace-server-cli.md -- a
|
||||
# MirageRemoteSandboxClient that speaks HTTP to `mirage daemon`
|
||||
# would slot into the same BaseSandboxClient interface.
|
||||
|
||||
backend_id: str = "mirage"
|
||||
supports_default_options: bool = True
|
||||
|
||||
def __init__(self, workspace: Workspace) -> None:
|
||||
self._ws = workspace
|
||||
self._sessions: dict[uuid.UUID, MirageSandboxSession] = {}
|
||||
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
snapshot: SnapshotSpec | SnapshotBase | None = None,
|
||||
manifest: Manifest | None = None,
|
||||
options: None = None,
|
||||
) -> SandboxSession:
|
||||
session_id = uuid.uuid4()
|
||||
snapshot_id = str(session_id)
|
||||
|
||||
snap: SnapshotBase
|
||||
if isinstance(snapshot, SnapshotSpec):
|
||||
snap = snapshot.build(snapshot_id)
|
||||
elif isinstance(snapshot, SnapshotBase):
|
||||
snap = snapshot
|
||||
else:
|
||||
snap = NoopSnapshot(id=snapshot_id)
|
||||
|
||||
state = MirageSandboxSessionState(
|
||||
session_id=session_id,
|
||||
snapshot=snap,
|
||||
manifest=manifest or Manifest(root="/"),
|
||||
)
|
||||
session = MirageSandboxSession(workspace=self._ws, state=state)
|
||||
self._sessions[session_id] = session
|
||||
return self._wrap_session(session)
|
||||
|
||||
async def delete(self, session: SandboxSession) -> SandboxSession:
|
||||
sid = session.state.session_id
|
||||
self._sessions.pop(sid, None)
|
||||
return session
|
||||
|
||||
async def resume(
|
||||
self,
|
||||
state: SandboxSessionState,
|
||||
) -> SandboxSession:
|
||||
sid = state.session_id
|
||||
session = self._sessions.get(sid)
|
||||
if session is None:
|
||||
raise ValueError(f"No session to resume: {sid}")
|
||||
return self._wrap_session(session)
|
||||
|
||||
def deserialize_session_state(
|
||||
self,
|
||||
payload: dict[str, object],
|
||||
) -> SandboxSessionState:
|
||||
return MirageSandboxSessionState.model_validate(payload)
|
||||
@@ -0,0 +1,35 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.agents.io_text import io_to_str
|
||||
from mirage.workspace.workspace import Workspace
|
||||
|
||||
|
||||
class MirageShellExecutor:
|
||||
"""ShellTool executor backed by a Mirage Workspace.
|
||||
|
||||
Args:
|
||||
workspace (Workspace): The workspace to execute commands in.
|
||||
"""
|
||||
|
||||
def __init__(self, workspace: Workspace) -> None:
|
||||
self._ws = workspace
|
||||
|
||||
async def __call__(self, request) -> str:
|
||||
commands = request.data.action.commands
|
||||
outputs: list[str] = []
|
||||
for cmd in commands:
|
||||
io = await self._ws.execute(cmd)
|
||||
outputs.append(io_to_str(io))
|
||||
return "\n".join(outputs)
|
||||
@@ -0,0 +1,23 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.agents.openhands.terminal import (MirageTerminalExecutor,
|
||||
register_mirage_terminal)
|
||||
from mirage.agents.openhands.workspace import MirageWorkspace
|
||||
|
||||
__all__ = [
|
||||
"MirageWorkspace",
|
||||
"MirageTerminalExecutor",
|
||||
"register_mirage_terminal",
|
||||
]
|
||||
@@ -0,0 +1,107 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import functools
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from openhands.sdk.llm import TextContent
|
||||
from openhands.sdk.tool import ToolExecutor, register_tool
|
||||
from openhands.tools.terminal import TerminalTool
|
||||
from openhands.tools.terminal.definition import (TerminalAction,
|
||||
TerminalObservation)
|
||||
from openhands.tools.terminal.metadata import CmdOutputMetadata
|
||||
|
||||
from mirage.agents.openhands.workspace import MirageWorkspace
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openhands.sdk.conversation.impl.local_conversation import \
|
||||
LocalConversation
|
||||
|
||||
|
||||
class MirageTerminalExecutor(ToolExecutor[TerminalAction,
|
||||
TerminalObservation]):
|
||||
"""Routes OpenHands TerminalTool actions through a Mirage Workspace.
|
||||
|
||||
Args:
|
||||
workspace: MirageWorkspace adapter wrapping a Mirage Workspace.
|
||||
Each TerminalAction is executed via
|
||||
``workspace.execute_command``, which dispatches the shell
|
||||
command through Mirage's accessor stack so virtual mounts
|
||||
(S3, Slack, ...) appear as paths.
|
||||
"""
|
||||
|
||||
def __init__(self, workspace: MirageWorkspace) -> None:
|
||||
self._mw = workspace
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
action: TerminalAction,
|
||||
conversation: "LocalConversation | None" = None,
|
||||
) -> TerminalObservation:
|
||||
if action.is_input:
|
||||
return TerminalObservation(
|
||||
command=action.command,
|
||||
content=[
|
||||
TextContent(text=("MirageTerminalExecutor does not "
|
||||
"support is_input=True"))
|
||||
],
|
||||
exit_code=-1,
|
||||
metadata=CmdOutputMetadata(exit_code=-1,
|
||||
working_dir=self._mw.working_dir),
|
||||
)
|
||||
timeout = action.timeout if action.timeout is not None else 30.0
|
||||
result = self._mw.execute_command(action.command, timeout=timeout)
|
||||
text = (result.stdout or "") + (result.stderr or "")
|
||||
metadata = CmdOutputMetadata(
|
||||
exit_code=result.exit_code,
|
||||
working_dir=self._mw.working_dir,
|
||||
)
|
||||
return TerminalObservation(
|
||||
command=action.command,
|
||||
content=[TextContent(text=text)],
|
||||
exit_code=result.exit_code,
|
||||
timeout=result.timeout_occurred,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def _mirage_terminal_factory(
|
||||
workspace: MirageWorkspace,
|
||||
*,
|
||||
conv_state: Any,
|
||||
**params: Any,
|
||||
) -> list[TerminalTool]:
|
||||
executor = MirageTerminalExecutor(workspace)
|
||||
return list(
|
||||
TerminalTool.create(conv_state=conv_state, executor=executor,
|
||||
**params))
|
||||
|
||||
|
||||
def register_mirage_terminal(
|
||||
workspace: MirageWorkspace,
|
||||
name: str = "mirage_terminal",
|
||||
) -> str:
|
||||
"""Register a TerminalTool variant routed through Mirage.
|
||||
|
||||
Args:
|
||||
workspace: MirageWorkspace adapter to bind to the executor.
|
||||
name: Tool name to register under. Defaults to
|
||||
"mirage_terminal".
|
||||
|
||||
Returns:
|
||||
str: The registered tool name, ready to use as
|
||||
``Tool(name=...)`` in the agent's tool list.
|
||||
"""
|
||||
register_tool(name, functools.partial(_mirage_terminal_factory, workspace))
|
||||
return name
|
||||
@@ -0,0 +1,253 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shlex
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from openhands.sdk.git.models import GitChange, GitDiff
|
||||
from openhands.sdk.workspace.local import LocalWorkspace
|
||||
from openhands.sdk.workspace.models import CommandResult, FileOperationResult
|
||||
from pydantic import Field, PrivateAttr
|
||||
|
||||
from mirage.workspace.workspace import Workspace as MirageBackingWorkspace
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _run_loop_in_thread(state: dict, ready: threading.Event) -> None:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
state["loop"] = loop
|
||||
ready.set()
|
||||
try:
|
||||
loop.run_forever()
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
async def _execute_with_timeout(
|
||||
ws: MirageBackingWorkspace,
|
||||
command: str,
|
||||
timeout: float,
|
||||
) -> Any:
|
||||
return await asyncio.wait_for(ws.execute(command), timeout=timeout)
|
||||
|
||||
|
||||
class _AsyncBridge:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._state: dict[str, Any] = {}
|
||||
self._thread: threading.Thread | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _ensure(self) -> asyncio.AbstractEventLoop:
|
||||
with self._lock:
|
||||
loop = self._state.get("loop")
|
||||
if loop is not None:
|
||||
return loop
|
||||
ready = threading.Event()
|
||||
t = threading.Thread(
|
||||
target=_run_loop_in_thread,
|
||||
args=(self._state, ready),
|
||||
name="mirage-openhands-loop",
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
ready.wait()
|
||||
self._thread = t
|
||||
return self._state["loop"]
|
||||
|
||||
def run(self, coro: Any, timeout: float | None = None) -> Any:
|
||||
loop = self._ensure()
|
||||
fut = asyncio.run_coroutine_threadsafe(coro, loop)
|
||||
return fut.result(timeout=timeout)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
with self._lock:
|
||||
loop = self._state.get("loop")
|
||||
if loop is None:
|
||||
return
|
||||
self._state.pop("loop", None)
|
||||
loop.call_soon_threadsafe(loop.stop)
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=5)
|
||||
self._thread = None
|
||||
|
||||
|
||||
class MirageWorkspace(LocalWorkspace):
|
||||
"""OpenHands LocalWorkspace adapter backed by a Mirage Workspace.
|
||||
|
||||
Inherits LocalWorkspace so OpenHands' Conversation isinstance check
|
||||
passes, but overrides every operation to route through a Mirage
|
||||
Workspace's virtual filesystem and shell. The async-to-sync bridge
|
||||
dispatches coroutines onto a dedicated background event loop.
|
||||
|
||||
Args:
|
||||
workspace: Mirage Workspace instance to delegate filesystem and
|
||||
shell operations to.
|
||||
working_dir: Default working directory used as cwd for shell
|
||||
commands when no explicit cwd is provided. Must be a valid
|
||||
host path (OpenHands validates it on the host fs). Defaults
|
||||
to "/".
|
||||
"""
|
||||
|
||||
working_dir: str = Field(
|
||||
default="/",
|
||||
description="Default working directory inside the Mirage workspace.",
|
||||
)
|
||||
|
||||
_ws: Any = PrivateAttr()
|
||||
_bridge: Any = PrivateAttr()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
workspace: MirageBackingWorkspace,
|
||||
working_dir: str = "/",
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(working_dir=working_dir, **kwargs)
|
||||
self._ws = workspace
|
||||
self._bridge = _AsyncBridge()
|
||||
|
||||
@property
|
||||
def workspace(self) -> MirageBackingWorkspace:
|
||||
return self._ws
|
||||
|
||||
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
||||
try:
|
||||
if not self._ws._closed:
|
||||
self._bridge.run(self._ws.close())
|
||||
finally:
|
||||
self._bridge.shutdown()
|
||||
|
||||
def execute_command(
|
||||
self,
|
||||
command: str,
|
||||
cwd: str | Path | None = None,
|
||||
timeout: float = 30.0,
|
||||
) -> CommandResult:
|
||||
cwd_str = str(cwd) if cwd is not None else self.working_dir
|
||||
if cwd_str and cwd_str not in ("", "."):
|
||||
full_command = f"cd {shlex.quote(cwd_str)} && {command}"
|
||||
else:
|
||||
full_command = command
|
||||
try:
|
||||
io_result = self._bridge.run(
|
||||
_execute_with_timeout(self._ws, full_command, timeout))
|
||||
stdout = self._coerce_text(getattr(io_result, "stdout", b""))
|
||||
stderr = self._coerce_text(getattr(io_result, "stderr", b""))
|
||||
exit_code = int(getattr(io_result, "exit_code", 0) or 0)
|
||||
return CommandResult(
|
||||
command=command,
|
||||
exit_code=exit_code,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
timeout_occurred=False,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return CommandResult(
|
||||
command=command,
|
||||
exit_code=-1,
|
||||
stdout="",
|
||||
stderr=f"Command timed out after {timeout}s",
|
||||
timeout_occurred=True,
|
||||
)
|
||||
|
||||
def file_upload(
|
||||
self,
|
||||
source_path: str | Path,
|
||||
destination_path: str | Path,
|
||||
) -> FileOperationResult:
|
||||
src = Path(source_path)
|
||||
dst = str(destination_path)
|
||||
try:
|
||||
data = src.read_bytes()
|
||||
parent = str(Path(dst).parent)
|
||||
if parent and parent not in (".", "/"):
|
||||
self._ensure_parent(parent)
|
||||
self._bridge.run(self._ws.ops.write(dst, data))
|
||||
return FileOperationResult(
|
||||
success=True,
|
||||
source_path=str(src),
|
||||
destination_path=dst,
|
||||
file_size=len(data),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("file_upload %s -> %s failed: %s", src, dst, e)
|
||||
return FileOperationResult(
|
||||
success=False,
|
||||
source_path=str(src),
|
||||
destination_path=dst,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
def file_download(
|
||||
self,
|
||||
source_path: str | Path,
|
||||
destination_path: str | Path,
|
||||
) -> FileOperationResult:
|
||||
src = str(source_path)
|
||||
dst = Path(destination_path)
|
||||
try:
|
||||
data = self._bridge.run(self._ws.ops.read(src))
|
||||
if isinstance(data, str):
|
||||
data = data.encode("utf-8")
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
dst.write_bytes(data)
|
||||
return FileOperationResult(
|
||||
success=True,
|
||||
source_path=src,
|
||||
destination_path=str(dst),
|
||||
file_size=len(data),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("file_download %s -> %s failed: %s", src, dst, e)
|
||||
return FileOperationResult(
|
||||
success=False,
|
||||
source_path=src,
|
||||
destination_path=str(dst),
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
def git_changes(self, path: str | Path) -> list[GitChange]:
|
||||
raise NotImplementedError(
|
||||
"Mirage workspaces do not expose git semantics over their "
|
||||
"virtual mounts; query the underlying resource directly.")
|
||||
|
||||
def git_diff(self, path: str | Path) -> GitDiff:
|
||||
raise NotImplementedError(
|
||||
"Mirage workspaces do not expose git semantics over their "
|
||||
"virtual mounts; query the underlying resource directly.")
|
||||
|
||||
def _ensure_parent(self, parent: str) -> None:
|
||||
result = self._bridge.run(
|
||||
self._ws.execute(f"mkdir -p {shlex.quote(parent)}"))
|
||||
exit_code = int(getattr(result, "exit_code", 0) or 0)
|
||||
if exit_code != 0:
|
||||
stderr = self._coerce_text(getattr(result, "stderr", b""))
|
||||
raise RuntimeError(
|
||||
f"mkdir -p {parent!r} failed (exit {exit_code}): {stderr}")
|
||||
|
||||
@staticmethod
|
||||
def _coerce_text(value: Any) -> str:
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
return value.decode("utf-8", errors="replace")
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value)
|
||||
@@ -0,0 +1,59 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
MIRAGE_SYSTEM_PROMPT = """\
|
||||
Your filesystem is powered by Mirage — a virtual filesystem that mounts \
|
||||
cloud storage, local files, and in-memory data as a unified file tree.
|
||||
|
||||
Capabilities beyond standard filesystem:
|
||||
- cat on .parquet, .orc, .feather files returns a formatted table
|
||||
- head -n 5 on data files returns the first 5 rows
|
||||
- grep works natively on CSV, JSON, Parquet — not just text
|
||||
- Pipes work: cat data.parquet | grep error | sort | uniq | wc -l
|
||||
- head, tail, cut, wc, sort, uniq, tee, xargs are all available
|
||||
|
||||
You can write Python code and execute it. The workspace is pre-configured \
|
||||
with your data sources mounted at their respective paths.
|
||||
|
||||
Use the execute tool for complex operations. \
|
||||
Use read/write/edit for simple file operations.
|
||||
"""
|
||||
|
||||
|
||||
def build_system_prompt(
|
||||
mount_info: dict[str, str] | None = None,
|
||||
extra_instructions: str = "",
|
||||
workspace: "object | None" = None,
|
||||
) -> str:
|
||||
"""Build a system prompt with optional mount info and extra instructions.
|
||||
|
||||
Args:
|
||||
mount_info (dict[str, str] | None): Map of mount prefix to description.
|
||||
extra_instructions (str): Additional instructions to append.
|
||||
workspace (Workspace | None): If provided, uses workspace.file_prompt.
|
||||
|
||||
Returns:
|
||||
str: The complete system prompt.
|
||||
"""
|
||||
parts = [MIRAGE_SYSTEM_PROMPT]
|
||||
if workspace is not None and hasattr(workspace, "file_prompt"):
|
||||
parts.append("Mounted data sources:\n" + workspace.file_prompt)
|
||||
elif mount_info:
|
||||
parts.append("\nMounted data sources:")
|
||||
for prefix, description in mount_info.items():
|
||||
parts.append(f"- {prefix} — {description}")
|
||||
parts.append("")
|
||||
if extra_instructions:
|
||||
parts.append(extra_instructions)
|
||||
return "\n".join(parts)
|
||||
@@ -0,0 +1,23 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.agents.pydantic_ai.backend import PydanticAIWorkspace
|
||||
from mirage.agents.pydantic_ai.prompt import (MIRAGE_SYSTEM_PROMPT,
|
||||
build_system_prompt)
|
||||
|
||||
__all__ = [
|
||||
"PydanticAIWorkspace",
|
||||
"MIRAGE_SYSTEM_PROMPT",
|
||||
"build_system_prompt",
|
||||
]
|
||||
@@ -0,0 +1,60 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from pydantic_ai_backends.types import ExecuteResponse, FileInfo, GrepMatch
|
||||
|
||||
from mirage.agents.io_text import decode
|
||||
from mirage.io.types import IOResult
|
||||
|
||||
|
||||
def io_to_execute_response(io: IOResult) -> ExecuteResponse:
|
||||
stdout = decode(io.stdout)
|
||||
stderr = decode(io.stderr)
|
||||
output = stdout
|
||||
if stderr:
|
||||
output = f"{stdout}\n{stderr}" if stdout else stderr
|
||||
return ExecuteResponse(output=output, exit_code=io.exit_code)
|
||||
|
||||
|
||||
def io_to_grep_matches(io: IOResult) -> list[GrepMatch]:
|
||||
stdout = decode(io.stdout).strip()
|
||||
if not stdout:
|
||||
return []
|
||||
matches: list[GrepMatch] = []
|
||||
for line in stdout.split("\n"):
|
||||
parts = line.split(":", 2)
|
||||
if len(parts) >= 3:
|
||||
try:
|
||||
line_num = int(parts[1])
|
||||
except ValueError:
|
||||
continue
|
||||
matches.append(
|
||||
GrepMatch(path=parts[0], line_number=line_num, line=parts[2]))
|
||||
return matches
|
||||
|
||||
|
||||
def io_to_file_infos(io: IOResult) -> list[FileInfo]:
|
||||
stdout = decode(io.stdout).strip()
|
||||
if not stdout:
|
||||
return []
|
||||
infos: list[FileInfo] = []
|
||||
for entry in stdout.split("\n"):
|
||||
entry = entry.strip()
|
||||
if not entry:
|
||||
continue
|
||||
is_dir = entry.endswith("/")
|
||||
clean = entry.rstrip("/")
|
||||
name = clean.rsplit("/", 1)[-1] if "/" in clean else clean
|
||||
infos.append(FileInfo(name=name, path=clean, is_dir=is_dir, size=None))
|
||||
return infos
|
||||
@@ -0,0 +1,247 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import shlex
|
||||
|
||||
from pydantic_ai import BinaryContent, ToolReturn
|
||||
from pydantic_ai_backends.protocol import SandboxProtocol
|
||||
from pydantic_ai_backends.types import (EditResult, ExecuteResponse, FileInfo,
|
||||
GrepMatch, WriteResult)
|
||||
|
||||
from mirage.agents.pydantic_ai._convert import (io_to_execute_response,
|
||||
io_to_file_infos,
|
||||
io_to_grep_matches)
|
||||
from mirage.bridge.sync import run_async_from_sync
|
||||
from mirage.core.filetype.pdf import pages_to_images
|
||||
from mirage.io.types import IOResult
|
||||
from mirage.types import DEFAULT_SESSION_ID
|
||||
from mirage.workspace.workspace import Workspace
|
||||
|
||||
|
||||
class PydanticAIWorkspace(SandboxProtocol):
|
||||
"""Pydantic AI backend backed by a Mirage Workspace.
|
||||
|
||||
File operations (read, write, edit, ls) go through the Ops layer directly.
|
||||
Shell operations (execute, grep, glob) go through Workspace.execute()
|
||||
for pipe and flag support.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace: Workspace,
|
||||
sandbox_id: str = "mirage",
|
||||
session_id: str = DEFAULT_SESSION_ID,
|
||||
) -> None:
|
||||
self._ws = workspace
|
||||
self._id = sandbox_id
|
||||
self._session_id = session_id
|
||||
|
||||
def _run(self, coro):
|
||||
return run_async_from_sync(coro)
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return self._id
|
||||
|
||||
async def _exec(self, command: str) -> IOResult:
|
||||
return await self._ws.execute(command, session_id=self._session_id)
|
||||
|
||||
def _read_bytes(self, path: str) -> bytes:
|
||||
return self._run(self._aread_bytes(path))
|
||||
|
||||
async def _aread_bytes(self, path: str) -> bytes:
|
||||
ops = self._ws.ops
|
||||
return await ops.read(path)
|
||||
|
||||
# -- execute -------------------------------------------------------
|
||||
|
||||
def execute(self,
|
||||
command: str,
|
||||
timeout: int | None = None) -> ExecuteResponse:
|
||||
return self._run(self.aexecute(command, timeout=timeout))
|
||||
|
||||
async def aexecute(self,
|
||||
command: str,
|
||||
timeout: int | None = None) -> ExecuteResponse:
|
||||
io = await self._exec(command)
|
||||
return io_to_execute_response(io)
|
||||
|
||||
# -- ls_info -------------------------------------------------------
|
||||
|
||||
def ls_info(self, path: str) -> list[FileInfo]:
|
||||
return self._run(self.als_info(path))
|
||||
|
||||
async def als_info(self, path: str) -> list[FileInfo]:
|
||||
io = await self._exec(f"ls {shlex.quote(path)}")
|
||||
stdout = (io.stdout or b"").decode("utf-8", errors="replace").strip()
|
||||
if not stdout:
|
||||
return []
|
||||
base = path.rstrip("/")
|
||||
result: list[FileInfo] = []
|
||||
for name in stdout.split("\n"):
|
||||
name = name.strip()
|
||||
if not name:
|
||||
continue
|
||||
is_dir = name.endswith("/")
|
||||
clean = name.rstrip("/")
|
||||
result.append(
|
||||
FileInfo(name=clean,
|
||||
path=f"{base}/{clean}",
|
||||
is_dir=is_dir,
|
||||
size=None))
|
||||
return result
|
||||
|
||||
# -- read ----------------------------------------------------------
|
||||
|
||||
def read(self,
|
||||
path: str,
|
||||
offset: int = 0,
|
||||
limit: int = 2000) -> str | ToolReturn:
|
||||
return self._run(self.aread(path, offset, limit))
|
||||
|
||||
async def aread(self,
|
||||
path: str,
|
||||
offset: int = 0,
|
||||
limit: int = 2000) -> str | ToolReturn:
|
||||
ops = self._ws.ops
|
||||
if path.lower().endswith(".pdf"):
|
||||
return await self._aread_pdf(path)
|
||||
try:
|
||||
data = await ops.read(path)
|
||||
except (FileNotFoundError, ValueError) as exc:
|
||||
return f"Error: {exc}"
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
lines = text.splitlines(keepends=True)
|
||||
sliced = lines[offset:offset + limit]
|
||||
numbered = []
|
||||
for i, line in enumerate(sliced, start=offset + 1):
|
||||
numbered.append(f"{i:>6}\t{line}")
|
||||
return "".join(numbered)
|
||||
|
||||
async def _aread_pdf(self, path: str) -> str | ToolReturn:
|
||||
ops = self._ws.ops
|
||||
try:
|
||||
raw = await ops.read(path)
|
||||
except (FileNotFoundError, ValueError) as exc:
|
||||
return f"Error: {exc}"
|
||||
images = pages_to_images(raw)
|
||||
filename = path.rsplit("/", 1)[-1]
|
||||
content: list[str | BinaryContent] = []
|
||||
for page_num, png_bytes in images:
|
||||
content.append(f"Page {page_num}:")
|
||||
content.append(
|
||||
BinaryContent(data=png_bytes, media_type="image/png"))
|
||||
return ToolReturn(
|
||||
return_value=f"PDF: {filename} ({len(images)} pages)",
|
||||
content=content,
|
||||
)
|
||||
|
||||
# -- write ---------------------------------------------------------
|
||||
|
||||
def write(self, path: str, content: str | bytes) -> WriteResult:
|
||||
return self._run(self.awrite(path, content))
|
||||
|
||||
async def awrite(self, path: str, content: str | bytes) -> WriteResult:
|
||||
ops = self._ws.ops
|
||||
try:
|
||||
await ops.stat(path)
|
||||
return WriteResult(error=f"Error: file '{path}' already exists")
|
||||
except (FileNotFoundError, ValueError):
|
||||
pass
|
||||
parent = "/".join(path.rstrip("/").split("/")[:-1]) or "/"
|
||||
try:
|
||||
await ops.mkdir(parent)
|
||||
except (FileExistsError, ValueError):
|
||||
pass
|
||||
data = content.encode("utf-8") if isinstance(content, str) else content
|
||||
await ops.write(path, data)
|
||||
return WriteResult(path=path)
|
||||
|
||||
# -- edit ----------------------------------------------------------
|
||||
|
||||
def edit(
|
||||
self,
|
||||
path: str,
|
||||
old_string: str,
|
||||
new_string: str,
|
||||
replace_all: bool = False,
|
||||
) -> EditResult:
|
||||
return self._run(self.aedit(path, old_string, new_string, replace_all))
|
||||
|
||||
async def aedit(
|
||||
self,
|
||||
path: str,
|
||||
old_string: str,
|
||||
new_string: str,
|
||||
replace_all: bool = False,
|
||||
) -> EditResult:
|
||||
ops = self._ws.ops
|
||||
try:
|
||||
data = await ops.read(path)
|
||||
except (FileNotFoundError, ValueError):
|
||||
return EditResult(error=f"Error: file '{path}' not found")
|
||||
content = data.decode("utf-8", errors="replace")
|
||||
count = content.count(old_string)
|
||||
if count == 0:
|
||||
return EditResult(
|
||||
error=f"Error: string not found in file: '{old_string}'")
|
||||
if count > 1 and not replace_all:
|
||||
return EditResult(
|
||||
error=f"Error: string '{old_string}' appears {count} times. "
|
||||
f"Use replace_all=True")
|
||||
if replace_all:
|
||||
new_content = content.replace(old_string, new_string)
|
||||
else:
|
||||
new_content = content.replace(old_string, new_string, 1)
|
||||
await ops.write(path, new_content.encode("utf-8"))
|
||||
return EditResult(path=path, occurrences=count if replace_all else 1)
|
||||
|
||||
# -- grep_raw ------------------------------------------------------
|
||||
|
||||
def grep_raw(
|
||||
self,
|
||||
pattern: str,
|
||||
path: str | None = None,
|
||||
glob: str | None = None,
|
||||
ignore_hidden: bool = True,
|
||||
) -> list[GrepMatch] | str:
|
||||
return self._run(self.agrep_raw(pattern, path, glob, ignore_hidden))
|
||||
|
||||
async def agrep_raw(
|
||||
self,
|
||||
pattern: str,
|
||||
path: str | None = None,
|
||||
glob: str | None = None,
|
||||
ignore_hidden: bool = True,
|
||||
) -> list[GrepMatch] | str:
|
||||
parts = ["grep", "-rn"]
|
||||
if glob:
|
||||
parts.extend(["--include", shlex.quote(glob)])
|
||||
parts.append(shlex.quote(pattern))
|
||||
parts.append(shlex.quote(path or "/"))
|
||||
io = await self._exec(" ".join(parts))
|
||||
return io_to_grep_matches(io)
|
||||
|
||||
# -- glob_info -----------------------------------------------------
|
||||
|
||||
def glob_info(self, pattern: str, path: str = "/") -> list[FileInfo]:
|
||||
return self._run(self.aglob_info(pattern, path))
|
||||
|
||||
async def aglob_info(self,
|
||||
pattern: str,
|
||||
path: str = "/") -> list[FileInfo]:
|
||||
name = pattern.split("/")[-1] if "/" in pattern else pattern
|
||||
io = await self._exec(
|
||||
f"find {shlex.quote(path)} -name {shlex.quote(name)}")
|
||||
return io_to_file_infos(io)
|
||||
@@ -0,0 +1,17 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.agents.prompts import MIRAGE_SYSTEM_PROMPT, build_system_prompt
|
||||
|
||||
__all__ = ["MIRAGE_SYSTEM_PROMPT", "build_system_prompt"]
|
||||
@@ -0,0 +1,18 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.bridge.sync import run_async_from_sync
|
||||
from mirage.bridge.thread import run_in_thread
|
||||
|
||||
__all__ = ["run_async_from_sync", "run_in_thread"]
|
||||
@@ -0,0 +1,36 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
|
||||
|
||||
def run_async_from_sync(coro, loop=None):
|
||||
"""Call from a sync thread to run an async coroutine.
|
||||
|
||||
Args:
|
||||
coro: The coroutine to run.
|
||||
loop (asyncio.AbstractEventLoop | None): Shared event loop.
|
||||
If provided, uses run_coroutine_threadsafe.
|
||||
If None, creates a new event loop.
|
||||
"""
|
||||
if loop is not None:
|
||||
future = asyncio.run_coroutine_threadsafe(coro, loop)
|
||||
return future.result()
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
with concurrent.futures.ThreadPoolExecutor(1) as pool:
|
||||
return pool.submit(asyncio.run, coro).result()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro)
|
||||
@@ -0,0 +1,25 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
|
||||
|
||||
async def run_in_thread(fn, *args):
|
||||
"""Run a blocking function in a thread pool.
|
||||
|
||||
Args:
|
||||
fn: The blocking function to run.
|
||||
*args: Arguments to pass to the function.
|
||||
"""
|
||||
return await asyncio.to_thread(fn, *args)
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.cache.file import CacheEntry, FileCacheMixin
|
||||
from mirage.cache.index import IndexCacheStore, RAMIndexCacheStore
|
||||
from mirage.cache.lock import KeyLockMixin
|
||||
|
||||
__all__ = [
|
||||
"CacheEntry",
|
||||
"FileCacheMixin",
|
||||
"IndexCacheStore",
|
||||
"KeyLockMixin",
|
||||
"RAMIndexCacheStore",
|
||||
"RedisIndexCacheStore",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "RedisIndexCacheStore":
|
||||
from mirage.cache.index import RedisIndexCacheStore
|
||||
return RedisIndexCacheStore
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
Vendored
+92
@@ -0,0 +1,92 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from contextvars import ContextVar
|
||||
from typing import Protocol
|
||||
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
class CacheInvalidator(Protocol):
|
||||
"""What this module needs from a cache manager.
|
||||
|
||||
``mirage.cache.manager.CacheManager`` satisfies this structurally;
|
||||
this module never imports it, keeping the dependency one-way:
|
||||
core mutators -> cache.context <- mount (pushes a manager).
|
||||
"""
|
||||
|
||||
async def invalidate_after_write(self, path: str | PathSpec) -> None:
|
||||
...
|
||||
|
||||
async def invalidate_after_unlink(self, path: str | PathSpec) -> None:
|
||||
...
|
||||
|
||||
async def cached_bytes(self, path: PathSpec) -> bytes | None:
|
||||
...
|
||||
|
||||
|
||||
_active: ContextVar[CacheInvalidator | None] = ContextVar(
|
||||
"_active_cache_manager", default=None)
|
||||
|
||||
|
||||
def push_cache_manager(
|
||||
manager: CacheInvalidator | None) -> CacheInvalidator | None:
|
||||
"""Set the active cache manager for the current async context.
|
||||
|
||||
Mirrors ``observe.context.push_mount_prefix``: the mount entry point
|
||||
pushes its manager before dispatching a command, core backend
|
||||
mutators report through :func:`invalidate_after_write` /
|
||||
:func:`invalidate_after_unlink`, and the caller restores the
|
||||
previous value afterwards.
|
||||
|
||||
Args:
|
||||
manager (CacheInvalidator | None): Manager to activate, or None
|
||||
to clear.
|
||||
|
||||
Returns:
|
||||
CacheInvalidator | None: The previously active manager, so
|
||||
callers can restore it.
|
||||
"""
|
||||
prev = _active.get()
|
||||
_active.set(manager)
|
||||
return prev
|
||||
|
||||
|
||||
def active_cache_manager() -> CacheInvalidator | None:
|
||||
"""Return the active cache manager for the current async context."""
|
||||
return _active.get()
|
||||
|
||||
|
||||
async def invalidate_after_write(path: str | PathSpec) -> None:
|
||||
"""Report a backend write so caches are invalidated at the mutation
|
||||
site. No-op if no cache manager is active.
|
||||
|
||||
Args:
|
||||
path (str | PathSpec): Resource-relative path that was written.
|
||||
"""
|
||||
manager = _active.get()
|
||||
if manager is not None:
|
||||
await manager.invalidate_after_write(path)
|
||||
|
||||
|
||||
async def invalidate_after_unlink(path: str | PathSpec) -> None:
|
||||
"""Report a backend deletion so caches are invalidated at the
|
||||
mutation site. No-op if no cache manager is active.
|
||||
|
||||
Args:
|
||||
path (str | PathSpec): Resource-relative path that was removed.
|
||||
"""
|
||||
manager = _active.get()
|
||||
if manager is not None:
|
||||
await manager.invalidate_after_unlink(path)
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.cache.file.entry import CacheEntry
|
||||
from mirage.cache.file.mixin import FileCacheMixin
|
||||
|
||||
__all__ = ["CacheEntry", "FileCacheMixin"]
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mirage.types import CacheType
|
||||
|
||||
|
||||
class CacheConfig(BaseModel):
|
||||
type: CacheType = CacheType.RAM
|
||||
limit: str | int = "512MB"
|
||||
max_drain_bytes: int | None = None
|
||||
|
||||
|
||||
class RedisCacheConfig(CacheConfig):
|
||||
type: CacheType = CacheType.REDIS
|
||||
url: str = "redis://localhost:6379/0"
|
||||
key_prefix: str = "mirage:cache:"
|
||||
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import time
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CacheEntry(BaseModel):
|
||||
size: int
|
||||
cached_at: int
|
||||
fingerprint: str | None = None
|
||||
ttl: int | None = None
|
||||
|
||||
@property
|
||||
def expired(self) -> bool:
|
||||
if self.ttl is None:
|
||||
return False
|
||||
return (int(time.time()) - self.cached_at) >= self.ttl
|
||||
Vendored
+139
@@ -0,0 +1,139 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
from mirage.cache.file.mixin import FileCacheMixin
|
||||
from mirage.io import CachableAsyncIterator, IOResult
|
||||
from mirage.observe.record import OpRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def read_fingerprint(records: list[OpRecord] | None, path: str) -> str | None:
|
||||
"""Latest backend fingerprint recorded for a read of ``path``.
|
||||
|
||||
Backends stamp read records with the content identifier they
|
||||
returned (S3 ETag, OneDrive cTag, Postgres sha256). Threading it
|
||||
into the cache entry lets ALWAYS-mode ``is_fresh`` compare like
|
||||
with like; the MD5-of-content default only matches simple-PUT S3
|
||||
objects.
|
||||
|
||||
Args:
|
||||
records (list[OpRecord] | None): Op records emitted by the
|
||||
command that produced the IOResult being applied.
|
||||
path (str): Virtual path used as the cache key.
|
||||
"""
|
||||
if records is None:
|
||||
return None
|
||||
for rec in reversed(records):
|
||||
if rec.op == "read" and rec.path == path and rec.fingerprint:
|
||||
return rec.fingerprint
|
||||
return None
|
||||
|
||||
|
||||
async def _set_cached(
|
||||
cache: FileCacheMixin,
|
||||
path: str,
|
||||
data: bytes,
|
||||
records: list[OpRecord] | None,
|
||||
) -> None:
|
||||
fingerprint = read_fingerprint(records, path)
|
||||
if fingerprint is None and await cache.get(path) == data:
|
||||
# Warm read: the bytes were served from this cache, so there is
|
||||
# no backend read record. Re-setting would replace the backend
|
||||
# fingerprint stamped on the cold read with the MD5 default and
|
||||
# force ALWAYS mode to evict and refetch on every read.
|
||||
return
|
||||
await cache.set(path, data, fingerprint=fingerprint)
|
||||
|
||||
|
||||
async def apply_io(
|
||||
cache: FileCacheMixin,
|
||||
io: IOResult,
|
||||
is_cacheable: Callable[[str], bool] | None = None,
|
||||
records: list[OpRecord] | None = None,
|
||||
) -> None:
|
||||
cache_set = set(io.cache)
|
||||
max_bytes = getattr(cache, "max_drain_bytes", None)
|
||||
for path in io.cache:
|
||||
if is_cacheable is not None and not is_cacheable(path):
|
||||
continue
|
||||
data = io.reads.get(path)
|
||||
if data is None:
|
||||
data = io.writes.get(path)
|
||||
if data is None:
|
||||
continue
|
||||
if isinstance(data, bytes):
|
||||
await _set_cached(cache, path, data, records)
|
||||
elif isinstance(data, CachableAsyncIterator):
|
||||
if data.exhausted:
|
||||
await _set_cached(cache, path, b"".join(data.buffered_chunks),
|
||||
records)
|
||||
else:
|
||||
if (hasattr(cache, "_drain_tasks")
|
||||
and path not in cache._drain_tasks
|
||||
and not await cache.exists(path)):
|
||||
task = asyncio.create_task(
|
||||
_background_drain(cache, path, data, max_bytes,
|
||||
records))
|
||||
cache._drain_tasks[path] = task
|
||||
task.add_done_callback(
|
||||
lambda t, p=path: cache._drain_tasks.pop(p, None))
|
||||
for path in io.writes:
|
||||
if path in cache_set:
|
||||
continue
|
||||
if is_cacheable is not None and not is_cacheable(path):
|
||||
continue
|
||||
await cache.remove(path)
|
||||
|
||||
|
||||
async def _background_drain(
|
||||
cache: FileCacheMixin,
|
||||
path: str,
|
||||
it: CachableAsyncIterator,
|
||||
max_bytes: int | None = None,
|
||||
records: list[OpRecord] | None = None,
|
||||
) -> None:
|
||||
"""Drain an unconsumed stream and write to cache.
|
||||
|
||||
Cancelled by workspace.close() if the stream is still draining at
|
||||
shutdown. If max_bytes is set and the drain exceeds it without
|
||||
exhausting the source, the partial buffer is discarded and the path
|
||||
is not cached (next read will fetch fresh from the resource).
|
||||
The fingerprint is looked up after the drain: streaming backends
|
||||
stamp their read record lazily, once the GET response arrives.
|
||||
"""
|
||||
try:
|
||||
if max_bytes is None:
|
||||
materialized = await it.drain()
|
||||
await cache.add(path,
|
||||
materialized,
|
||||
fingerprint=read_fingerprint(records, path))
|
||||
return
|
||||
materialized, fully_drained = await it.drain_bounded(max_bytes)
|
||||
if fully_drained:
|
||||
await cache.add(path,
|
||||
materialized,
|
||||
fingerprint=read_fingerprint(records, path))
|
||||
else:
|
||||
logger.info(
|
||||
"cache drain budget exceeded for %s "
|
||||
"(>%d bytes), skipping cache fill", path, max_bytes)
|
||||
except asyncio.CancelledError:
|
||||
logger.warning("background drain cancelled for %s", path)
|
||||
except Exception:
|
||||
logger.warning("background drain failed for %s", path, exc_info=True)
|
||||
Vendored
+92
@@ -0,0 +1,92 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
class FileCacheMixin:
|
||||
"""LRU file cache mixin for resources.
|
||||
|
||||
Adds cache tracking (sizes, fingerprints, TTL, LRU order)
|
||||
on top of any resource. Data lives in the resource's storage —
|
||||
subclass implements the cache methods using resource's store.
|
||||
"""
|
||||
|
||||
async def get(self, key: str) -> bytes | None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def set(self,
|
||||
key: str,
|
||||
data: bytes,
|
||||
fingerprint: str | None = None,
|
||||
ttl: int | None = None) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def add(self,
|
||||
key: str,
|
||||
data: bytes,
|
||||
fingerprint: str | None = None,
|
||||
ttl: int | None = None) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
async def remove(self, key: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
async def is_fresh(self, key: str, remote_fingerprint: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
async def clear(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def evict_paths(self, paths: Iterable[str]) -> None:
|
||||
"""Synchronously evict the given keys from the cache.
|
||||
|
||||
Load-time helper invoked from ``Workspace.load`` (sync) to drop
|
||||
snapshot-restored bytes for paths the caller wants to re-fetch
|
||||
live. Backends whose state cannot be mutated synchronously
|
||||
(e.g. Redis) override this as a no-op.
|
||||
|
||||
Args:
|
||||
paths (Iterable[str]): Cache keys to drop.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def multi_get(self, keys: list[str]) -> list[bytes | None]:
|
||||
return [await self.get(k) for k in keys]
|
||||
|
||||
async def multi_set(
|
||||
self,
|
||||
items: list[tuple[str, bytes]],
|
||||
fingerprint: str | None = None,
|
||||
ttl: int | None = None,
|
||||
) -> None:
|
||||
for key, data in items:
|
||||
await self.set(key, data, fingerprint=fingerprint, ttl=ttl)
|
||||
|
||||
@property
|
||||
def cache_size(self) -> int | None:
|
||||
"""Cached bytes; None for stores that don't track them."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def cache_entries(self) -> int | None:
|
||||
"""Number of cached entries; None for stores that don't track them."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def cache_limit(self) -> int:
|
||||
raise NotImplementedError
|
||||
Vendored
+168
@@ -0,0 +1,168 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Iterable
|
||||
|
||||
from mirage.cache.file.entry import CacheEntry
|
||||
from mirage.cache.file.mixin import FileCacheMixin
|
||||
from mirage.cache.file.utils import default_fingerprint, parse_limit
|
||||
from mirage.cache.lock import KeyLockMixin
|
||||
from mirage.resource.ram import RAMResource
|
||||
|
||||
|
||||
class RAMFileCacheStore(RAMResource, FileCacheMixin, KeyLockMixin):
|
||||
"""RAMResource with LRU cache tracking.
|
||||
|
||||
Data lives in inherited _store.files (RAMStore).
|
||||
_entries tracks LRU metadata only.
|
||||
All RAM commands (cat, grep, head, ...) inherited.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cache_limit: str | int = "512MB",
|
||||
max_drain_bytes: int | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._cache_limit: int = parse_limit(cache_limit)
|
||||
self._cache_size: int = 0
|
||||
self._entries: OrderedDict[str, CacheEntry] = OrderedDict()
|
||||
self._drain_tasks: dict[str, asyncio.Task] = {}
|
||||
self._clear_lock: asyncio.Lock = asyncio.Lock()
|
||||
self.max_drain_bytes: int | None = max_drain_bytes
|
||||
|
||||
async def get(self, key: str) -> bytes | None:
|
||||
async with self._lock_for(key):
|
||||
entry = self._entries.get(key)
|
||||
if entry is None:
|
||||
return None
|
||||
if entry.expired:
|
||||
self._cache_size -= entry.size
|
||||
del self._entries[key]
|
||||
self._store.files.pop(key, None)
|
||||
return None
|
||||
self._entries.move_to_end(key)
|
||||
return self._store.files.get(key)
|
||||
|
||||
async def set(self,
|
||||
key: str,
|
||||
data: bytes,
|
||||
fingerprint: str | None = None,
|
||||
ttl: int | None = None) -> None:
|
||||
async with self._lock_for(key):
|
||||
if key in self._entries:
|
||||
self._cache_size -= self._entries[key].size
|
||||
del self._entries[key]
|
||||
if fingerprint is None:
|
||||
fingerprint = default_fingerprint(data)
|
||||
entry = CacheEntry(
|
||||
size=len(data),
|
||||
cached_at=int(time.time()),
|
||||
fingerprint=fingerprint,
|
||||
ttl=ttl,
|
||||
)
|
||||
self._entries[key] = entry
|
||||
self._store.files[key] = data
|
||||
self._cache_size += entry.size
|
||||
await self._evict()
|
||||
|
||||
async def add(self,
|
||||
key: str,
|
||||
data: bytes,
|
||||
fingerprint: str | None = None,
|
||||
ttl: int | None = None) -> bool:
|
||||
async with self._lock_for(key):
|
||||
existing = self._entries.get(key)
|
||||
if existing is not None and not existing.expired:
|
||||
return False
|
||||
if key in self._entries:
|
||||
self._cache_size -= self._entries[key].size
|
||||
del self._entries[key]
|
||||
if fingerprint is None:
|
||||
fingerprint = default_fingerprint(data)
|
||||
entry = CacheEntry(
|
||||
size=len(data),
|
||||
cached_at=int(time.time()),
|
||||
fingerprint=fingerprint,
|
||||
ttl=ttl,
|
||||
)
|
||||
self._entries[key] = entry
|
||||
self._store.files[key] = data
|
||||
self._cache_size += entry.size
|
||||
await self._evict()
|
||||
return True
|
||||
|
||||
async def remove(self, key: str) -> None:
|
||||
async with self._lock_for(key):
|
||||
task = self._drain_tasks.pop(key, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
if key in self._entries:
|
||||
self._cache_size -= self._entries[key].size
|
||||
del self._entries[key]
|
||||
self._store.files.pop(key, None)
|
||||
self._discard_lock(key)
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
entry = self._entries.get(key)
|
||||
return entry is not None and not entry.expired
|
||||
|
||||
async def is_fresh(self, key: str, remote_fingerprint: str) -> bool:
|
||||
entry = self._entries.get(key)
|
||||
if entry is None:
|
||||
return False
|
||||
return entry.fingerprint == remote_fingerprint
|
||||
|
||||
async def clear(self) -> None:
|
||||
async with self._clear_lock:
|
||||
for task in self._drain_tasks.values():
|
||||
task.cancel()
|
||||
self._drain_tasks.clear()
|
||||
self._entries.clear()
|
||||
self._store.files.clear()
|
||||
self._cache_size = 0
|
||||
self._clear_locks()
|
||||
|
||||
def evict_paths(self, paths: Iterable[str]) -> None:
|
||||
for key in paths:
|
||||
entry = self._entries.pop(key, None)
|
||||
if entry is not None:
|
||||
self._cache_size -= entry.size
|
||||
self._store.files.pop(key, None)
|
||||
|
||||
async def _evict(self) -> None:
|
||||
while self._cache_size > self._cache_limit and self._entries:
|
||||
evicted_key = next(iter(self._entries))
|
||||
async with self._lock_for(evicted_key):
|
||||
if evicted_key not in self._entries:
|
||||
continue
|
||||
evicted = self._entries.pop(evicted_key)
|
||||
self._cache_size -= evicted.size
|
||||
self._store.files.pop(evicted_key, None)
|
||||
self._discard_lock(evicted_key)
|
||||
|
||||
@property
|
||||
def cache_size(self) -> int:
|
||||
return self._cache_size
|
||||
|
||||
@property
|
||||
def cache_entries(self) -> int:
|
||||
return len(self._entries)
|
||||
|
||||
@property
|
||||
def cache_limit(self) -> int:
|
||||
return self._cache_limit
|
||||
Vendored
+130
@@ -0,0 +1,130 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Iterable
|
||||
|
||||
from mirage.cache.file.mixin import FileCacheMixin
|
||||
from mirage.cache.file.utils import default_fingerprint, parse_limit
|
||||
from mirage.resource.redis.redis import RedisResource
|
||||
|
||||
|
||||
class RedisFileCacheStore(RedisResource, FileCacheMixin):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cache_limit: str | int = "512MB",
|
||||
url: str = "redis://localhost:6379/0",
|
||||
key_prefix: str = "mirage:cache:",
|
||||
max_drain_bytes: int | None = None,
|
||||
) -> None:
|
||||
super().__init__(url=url, key_prefix=key_prefix)
|
||||
# Advisory only: unlike the RAM store there is no client-side
|
||||
# LRU, so nothing evicts on overflow. Cap memory on the Redis
|
||||
# server instead (maxmemory + maxmemory-policy allkeys-lru) to
|
||||
# approximate the RAM store's eviction behavior.
|
||||
self._cache_limit: int = parse_limit(cache_limit)
|
||||
self._cache_client = self._store._client
|
||||
self._data_prefix = f"{key_prefix}data:"
|
||||
self._meta_prefix = f"{key_prefix}meta:"
|
||||
self.max_drain_bytes: int | None = max_drain_bytes
|
||||
self._drain_tasks: dict[str, asyncio.Task] = {}
|
||||
|
||||
async def get(self, key: str) -> bytes | None:
|
||||
return await self._cache_client.get(f"{self._data_prefix}{key}")
|
||||
|
||||
async def set(
|
||||
self,
|
||||
key: str,
|
||||
data: bytes,
|
||||
fingerprint: str | None = None,
|
||||
ttl: int | None = None,
|
||||
) -> None:
|
||||
if fingerprint is None:
|
||||
fingerprint = default_fingerprint(data)
|
||||
pipe = self._cache_client.pipeline()
|
||||
dk = f"{self._data_prefix}{key}"
|
||||
mk = f"{self._meta_prefix}{key}"
|
||||
pipe.set(dk, data)
|
||||
pipe.set(mk, fingerprint)
|
||||
if ttl is not None:
|
||||
pipe.expire(dk, ttl)
|
||||
pipe.expire(mk, ttl)
|
||||
await pipe.execute()
|
||||
|
||||
async def add(
|
||||
self,
|
||||
key: str,
|
||||
data: bytes,
|
||||
fingerprint: str | None = None,
|
||||
ttl: int | None = None,
|
||||
) -> bool:
|
||||
dk = f"{self._data_prefix}{key}"
|
||||
exists = await self._cache_client.exists(dk)
|
||||
if exists:
|
||||
return False
|
||||
await self.set(key, data, fingerprint=fingerprint, ttl=ttl)
|
||||
return True
|
||||
|
||||
async def remove(self, key: str) -> None:
|
||||
task = self._drain_tasks.pop(key, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
pipe = self._cache_client.pipeline()
|
||||
pipe.delete(f"{self._data_prefix}{key}")
|
||||
pipe.delete(f"{self._meta_prefix}{key}")
|
||||
await pipe.execute()
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
return bool(await
|
||||
self._cache_client.exists(f"{self._data_prefix}{key}"))
|
||||
|
||||
async def is_fresh(self, key: str, remote_fingerprint: str) -> bool:
|
||||
fp = await self._cache_client.get(f"{self._meta_prefix}{key}")
|
||||
if fp is None:
|
||||
return False
|
||||
if isinstance(fp, bytes):
|
||||
fp = fp.decode()
|
||||
return fp == remote_fingerprint
|
||||
|
||||
async def clear(self) -> None:
|
||||
for task in self._drain_tasks.values():
|
||||
task.cancel()
|
||||
self._drain_tasks.clear()
|
||||
for pattern in (
|
||||
f"{self._data_prefix}*",
|
||||
f"{self._meta_prefix}*",
|
||||
):
|
||||
keys: list = []
|
||||
async for k in self._cache_client.scan_iter(pattern):
|
||||
keys.append(k)
|
||||
if keys:
|
||||
await self._cache_client.delete(*keys)
|
||||
|
||||
def evict_paths(self, paths: Iterable[str]) -> None:
|
||||
# No-op: Redis cache holds nothing restored from the snapshot
|
||||
# (only RAM caches are repopulated by _restore_cache), and the
|
||||
# snapshot load path is sync so we cannot await redis deletes
|
||||
# here. If a caller needs to drop live Redis-cached entries, use
|
||||
# await self.remove(key) per path from an async context.
|
||||
pass
|
||||
|
||||
@property
|
||||
def cache_size(self) -> int | None:
|
||||
# Size lives in the redis server and is not tracked client-side.
|
||||
return None
|
||||
|
||||
@property
|
||||
def cache_limit(self) -> int:
|
||||
return self._cache_limit
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import hashlib
|
||||
|
||||
|
||||
def parse_limit(limit: str | int) -> int:
|
||||
if isinstance(limit, int):
|
||||
return limit
|
||||
s = limit.strip().upper()
|
||||
for suffix, mult in [("GB", 1 << 30), ("MB", 1 << 20), ("KB", 1 << 10)]:
|
||||
if s.endswith(suffix):
|
||||
return int(s[:-len(suffix)]) * mult
|
||||
return int(s)
|
||||
|
||||
|
||||
def default_fingerprint(data: bytes) -> str:
|
||||
return hashlib.md5(data).hexdigest()
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.cache.index.config import (IndexConfig, IndexEntry, ListResult,
|
||||
LookupResult, LookupStatus,
|
||||
RedisIndexConfig, ResourceType)
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.cache.index.store import IndexCacheStore
|
||||
|
||||
__all__ = [
|
||||
"IndexCacheStore",
|
||||
"IndexEntry",
|
||||
"ListResult",
|
||||
"LookupResult",
|
||||
"LookupStatus",
|
||||
"IndexConfig",
|
||||
"RAMIndexCacheStore",
|
||||
"RedisIndexCacheStore",
|
||||
"RedisIndexConfig",
|
||||
"ResourceType",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "RedisIndexCacheStore":
|
||||
from mirage.cache.index.redis import RedisIndexCacheStore
|
||||
return RedisIndexCacheStore
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
Vendored
+61
@@ -0,0 +1,61 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mirage.types import IndexType
|
||||
|
||||
|
||||
class ResourceType(str, Enum):
|
||||
FILE = "file"
|
||||
FOLDER = "folder"
|
||||
|
||||
|
||||
class LookupStatus(str, Enum):
|
||||
EXPIRED = "expired"
|
||||
NOT_FOUND = "not_found"
|
||||
|
||||
|
||||
class IndexEntry(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
resource_type: str
|
||||
remote_time: str = ""
|
||||
index_time: str = ""
|
||||
vfs_name: str = ""
|
||||
size: int | None = None
|
||||
extra: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class LookupResult(BaseModel):
|
||||
entry: IndexEntry | None = None
|
||||
status: LookupStatus | None = None
|
||||
|
||||
|
||||
class ListResult(BaseModel):
|
||||
entries: list[str] | None = None
|
||||
status: LookupStatus | None = None
|
||||
|
||||
|
||||
class IndexConfig(BaseModel):
|
||||
type: IndexType = IndexType.RAM
|
||||
ttl: float = 600
|
||||
|
||||
|
||||
class RedisIndexConfig(IndexConfig):
|
||||
type: IndexType = IndexType.REDIS
|
||||
url: str = "redis://localhost:6379/0"
|
||||
key_prefix: str = "mirage:index:"
|
||||
Vendored
+89
@@ -0,0 +1,89 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from mirage.cache.index.config import (IndexEntry, ListResult, LookupResult,
|
||||
LookupStatus)
|
||||
from mirage.cache.index.store import IndexCacheStore
|
||||
from mirage.cache.lock import KeyLockMixin
|
||||
from mirage.core.timeutil import to_iso_z
|
||||
|
||||
|
||||
class RAMIndexCacheStore(IndexCacheStore, KeyLockMixin):
|
||||
"""In-memory index cache using plain dicts + asyncio locks."""
|
||||
|
||||
def __init__(self, ttl: float = 600) -> None:
|
||||
super().__init__()
|
||||
self._ttl = ttl
|
||||
self._entries: dict[str, IndexEntry] = {}
|
||||
self._children: dict[str, list[str]] = {}
|
||||
self._expiry: dict[str, datetime] = {}
|
||||
|
||||
async def get(self, resource_path: str) -> LookupResult:
|
||||
entry = self._entries.get(resource_path)
|
||||
if entry is None:
|
||||
return LookupResult(status=LookupStatus.NOT_FOUND)
|
||||
return LookupResult(entry=entry)
|
||||
|
||||
async def put(self, resource_path: str, entry: IndexEntry) -> None:
|
||||
async with self._lock_for(resource_path):
|
||||
if not entry.index_time:
|
||||
entry = entry.model_copy(
|
||||
update={
|
||||
"index_time": to_iso_z(datetime.now(timezone.utc))
|
||||
})
|
||||
self._entries[resource_path] = entry
|
||||
|
||||
async def list_dir(self, resource_path: str) -> ListResult:
|
||||
exp = self._expiry.get(resource_path)
|
||||
if exp is None:
|
||||
return ListResult(status=LookupStatus.NOT_FOUND)
|
||||
if datetime.now(timezone.utc) > exp:
|
||||
return ListResult(status=LookupStatus.EXPIRED)
|
||||
children = self._children.get(resource_path)
|
||||
return ListResult(entries=children or [])
|
||||
|
||||
async def set_dir(
|
||||
self,
|
||||
resource_path: str,
|
||||
entries: list[tuple[str, IndexEntry]],
|
||||
expired_at: datetime | None = None,
|
||||
) -> None:
|
||||
async with self._lock_for(resource_path):
|
||||
now = datetime.now(timezone.utc)
|
||||
exp = expired_at or (now + timedelta(seconds=self._ttl))
|
||||
now_iso = to_iso_z(now)
|
||||
prefix = "/" if resource_path == "/" else resource_path + "/"
|
||||
child_keys: list[str] = []
|
||||
for name, entry in entries:
|
||||
full_path = prefix + name
|
||||
if not entry.index_time:
|
||||
entry = entry.model_copy(update={"index_time": now_iso})
|
||||
self._entries[full_path] = entry
|
||||
child_keys.append(full_path)
|
||||
self._children[resource_path] = child_keys
|
||||
self._expiry[resource_path] = exp
|
||||
|
||||
async def invalidate_dir(self, resource_path: str) -> None:
|
||||
for child in self._children.get(resource_path, []):
|
||||
self._entries.pop(child, None)
|
||||
self._expiry.pop(resource_path, None)
|
||||
self._children.pop(resource_path, None)
|
||||
|
||||
async def clear(self) -> None:
|
||||
self._entries.clear()
|
||||
self._children.clear()
|
||||
self._expiry.clear()
|
||||
self._clear_locks()
|
||||
Vendored
+161
@@ -0,0 +1,161 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
try:
|
||||
from redis.asyncio import Redis
|
||||
except ImportError as _err:
|
||||
raise ImportError("RedisIndexCacheStore requires the 'redis' extra. "
|
||||
"Install with: pip install mirage-ai[redis]") from _err
|
||||
|
||||
from mirage.cache.index.config import (IndexEntry, ListResult, LookupResult,
|
||||
LookupStatus)
|
||||
from mirage.cache.index.store import IndexCacheStore
|
||||
from mirage.core.timeutil import to_iso_z
|
||||
|
||||
ENTRY_PREFIX = "mirage:idx:entry:"
|
||||
CHILDREN_PREFIX = "mirage:idx:children:"
|
||||
|
||||
|
||||
class RedisIndexCacheStore(IndexCacheStore):
|
||||
"""Redis-backed index cache for remote resource metadata.
|
||||
|
||||
Stores IndexEntry objects as JSON strings and directory children as
|
||||
Redis lists. Directory TTL is managed via native Redis key expiration.
|
||||
All writes within set_dir are batched in a single pipeline for efficiency.
|
||||
|
||||
Multiple stores can share one Redis server by using distinct key_prefix
|
||||
values (e.g. "gdrive:", "s3:"). The full key layout is::
|
||||
|
||||
{key_prefix}mirage:idx:entry:{resource_path} -> JSON string
|
||||
{key_prefix}mirage:idx:children:{resource_path} -> Redis list
|
||||
|
||||
Args:
|
||||
ttl (float): Default time-to-live in seconds for directory listings.
|
||||
url (str): Redis connection URL, used when *client* is not provided.
|
||||
client (Redis | None): Pre-existing async Redis client. When given,
|
||||
the store will not close it on ``close()``.
|
||||
key_prefix (str): Namespace prefix prepended to every Redis key,
|
||||
allowing multiple stores to coexist on the same server.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ttl: float = 600,
|
||||
url: str = "redis://localhost:6379/0",
|
||||
client: Redis | None = None,
|
||||
key_prefix: str = "",
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._ttl = ttl
|
||||
self._client = client or Redis.from_url(url, decode_responses=True)
|
||||
self._owns_client = client is None
|
||||
p = key_prefix or ""
|
||||
self._entry_prefix = f"{p}{ENTRY_PREFIX}"
|
||||
self._children_prefix = f"{p}{CHILDREN_PREFIX}"
|
||||
|
||||
def _entry_key(self, resource_path: str) -> str:
|
||||
return f"{self._entry_prefix}{resource_path}"
|
||||
|
||||
def _children_key(self, resource_path: str) -> str:
|
||||
return f"{self._children_prefix}{resource_path}"
|
||||
|
||||
async def get(self, resource_path: str) -> LookupResult:
|
||||
raw = await self._client.get(self._entry_key(resource_path))
|
||||
if raw is None:
|
||||
return LookupResult(status=LookupStatus.NOT_FOUND)
|
||||
entry = IndexEntry.model_validate_json(raw)
|
||||
return LookupResult(entry=entry)
|
||||
|
||||
async def put(self, resource_path: str, entry: IndexEntry) -> None:
|
||||
if not entry.index_time:
|
||||
entry = entry.model_copy(
|
||||
update={"index_time": to_iso_z(datetime.now(timezone.utc))})
|
||||
await self._client.set(self._entry_key(resource_path),
|
||||
entry.model_dump_json())
|
||||
|
||||
async def list_dir(self, resource_path: str) -> ListResult:
|
||||
key = self._children_key(resource_path)
|
||||
exists = await self._client.exists(key)
|
||||
if not exists:
|
||||
return ListResult(status=LookupStatus.NOT_FOUND)
|
||||
ttl_remaining = await self._client.ttl(key)
|
||||
if ttl_remaining == -2:
|
||||
return ListResult(status=LookupStatus.EXPIRED)
|
||||
raw = await self._client.lrange(key, 0, -1)
|
||||
return ListResult(entries=raw)
|
||||
|
||||
async def set_dir(
|
||||
self,
|
||||
resource_path: str,
|
||||
entries: list[tuple[str, IndexEntry]],
|
||||
expired_at: datetime | None = None,
|
||||
) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
now_iso = to_iso_z(now)
|
||||
prefix = "/" if resource_path == "/" else resource_path + "/"
|
||||
|
||||
pipe = self._client.pipeline()
|
||||
child_keys: list[str] = []
|
||||
for name, entry in entries:
|
||||
full_path = prefix + name
|
||||
if not entry.index_time:
|
||||
entry = entry.model_copy(update={"index_time": now_iso})
|
||||
pipe.set(self._entry_key(full_path), entry.model_dump_json())
|
||||
child_keys.append(full_path)
|
||||
|
||||
children_key = self._children_key(resource_path)
|
||||
pipe.delete(children_key)
|
||||
if child_keys:
|
||||
pipe.rpush(children_key, *child_keys)
|
||||
|
||||
if expired_at:
|
||||
ttl_seconds = max(1, int((expired_at - now).total_seconds()))
|
||||
else:
|
||||
ttl_seconds = max(1, int(self._ttl))
|
||||
pipe.expire(children_key, ttl_seconds)
|
||||
|
||||
await pipe.execute()
|
||||
|
||||
async def invalidate_dir(self, resource_path: str) -> None:
|
||||
children_key = f"{self._children_prefix}{resource_path}"
|
||||
child_paths = await self._client.lrange(children_key, 0, -1)
|
||||
pipe = self._client.pipeline()
|
||||
for child in child_paths:
|
||||
pipe.delete(self._entry_key(child))
|
||||
pipe.delete(children_key)
|
||||
await pipe.execute()
|
||||
|
||||
async def clear(self) -> None:
|
||||
cursor = 0
|
||||
while True:
|
||||
cursor, keys = await self._client.scan(
|
||||
cursor, match=f"{self._entry_prefix}*", count=500)
|
||||
if keys:
|
||||
await self._client.delete(*keys)
|
||||
if cursor == 0:
|
||||
break
|
||||
cursor = 0
|
||||
while True:
|
||||
cursor, keys = await self._client.scan(
|
||||
cursor, match=f"{self._children_prefix}*", count=500)
|
||||
if keys:
|
||||
await self._client.delete(*keys)
|
||||
if cursor == 0:
|
||||
break
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._owns_client:
|
||||
await self._client.aclose()
|
||||
Vendored
+48
@@ -0,0 +1,48 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from mirage.cache.index.config import IndexEntry, ListResult, LookupResult
|
||||
|
||||
|
||||
class IndexCacheStore:
|
||||
"""Per-resource metadata index for remote resources.
|
||||
|
||||
Abstract base. Maps resource paths to IndexEntry metadata.
|
||||
Subclasses implement storage and concurrency.
|
||||
"""
|
||||
|
||||
async def get(self, resource_path: str) -> LookupResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def put(self, resource_path: str, entry: IndexEntry) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def list_dir(self, resource_path: str) -> ListResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def set_dir(
|
||||
self,
|
||||
resource_path: str,
|
||||
entries: list[tuple[str, IndexEntry]],
|
||||
expired_at: datetime | None = None,
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def invalidate_dir(self, resource_path: str) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def clear(self) -> None:
|
||||
raise NotImplementedError
|
||||
Vendored
+41
@@ -0,0 +1,41 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
|
||||
|
||||
class KeyLockMixin:
|
||||
"""Per-key async locking for RAM-backed cache stores.
|
||||
|
||||
Provides fine-grained locking so operations on different keys
|
||||
run concurrently while same-key operations are serialized.
|
||||
|
||||
Only for in-process RAM stores. Redis/SQLite backends handle
|
||||
concurrency natively and do not need this mixin.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._key_locks: dict[str, asyncio.Lock] = {}
|
||||
|
||||
def _lock_for(self, key: str) -> asyncio.Lock:
|
||||
if key not in self._key_locks:
|
||||
self._key_locks[key] = asyncio.Lock()
|
||||
return self._key_locks[key]
|
||||
|
||||
def _discard_lock(self, key: str) -> None:
|
||||
self._key_locks.pop(key, None)
|
||||
|
||||
def _clear_locks(self) -> None:
|
||||
self._key_locks.clear()
|
||||
Vendored
+110
@@ -0,0 +1,110 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.cache.file.mixin import FileCacheMixin
|
||||
from mirage.cache.index.store import IndexCacheStore
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
class CacheManager:
|
||||
"""Post-mutation cache coherence for one mount.
|
||||
|
||||
A backend mutation has two cache consequences: the file-cache entry
|
||||
for the path is stale, and the parent directory listing in the
|
||||
index cache (including negative knowledge that the path does not
|
||||
exist) is stale. This class discharges both, synchronously, at the
|
||||
mutation site: core backend mutators report through
|
||||
``mirage.cache.context`` right where they already emit observe
|
||||
records, so invalidation happens before the next command in a
|
||||
pipeline runs instead of after the whole command tree.
|
||||
"""
|
||||
|
||||
def __init__(self, file_cache: FileCacheMixin | None,
|
||||
index: IndexCacheStore | None, prefix: str,
|
||||
caches_reads: bool) -> None:
|
||||
"""Args:
|
||||
file_cache (FileCacheMixin | None): Workspace file cache
|
||||
store; entries are keyed by mount-absolute path.
|
||||
index (IndexCacheStore | None): The mount resource's index
|
||||
cache; listings are keyed by mount-absolute path.
|
||||
prefix (str): Mount prefix (e.g. "/data/").
|
||||
caches_reads (bool): Whether the resource caches reads; the
|
||||
file cache only holds paths for read-caching backends.
|
||||
"""
|
||||
self._file_cache = file_cache
|
||||
self._index = index
|
||||
self._prefix = prefix.rstrip("/")
|
||||
self._caches_reads = caches_reads
|
||||
|
||||
def _virtual(self, path: str | PathSpec) -> str:
|
||||
if isinstance(path, PathSpec):
|
||||
path = path.mount_path
|
||||
if not path.startswith("/"):
|
||||
path = "/" + path
|
||||
if self._prefix and not path.startswith(self._prefix):
|
||||
return self._prefix + path
|
||||
return path
|
||||
|
||||
async def cached_bytes(self, path: PathSpec) -> bytes | None:
|
||||
"""Return cached bytes for ``path`` if present, else None.
|
||||
|
||||
Lookup only: never fetches from the backend. The single
|
||||
read-cache check, called by the shared read-through wrappers
|
||||
(``mirage.cache.read_through``) that every read command reads
|
||||
through, so warm reads are served from the file cache without the
|
||||
command knowing about it.
|
||||
|
||||
Args:
|
||||
path (PathSpec): the path to look up.
|
||||
"""
|
||||
if not self._caches_reads or self._file_cache is None:
|
||||
return None
|
||||
virtual = self._virtual(path)
|
||||
if await self._file_cache.exists(virtual):
|
||||
return await self._file_cache.get(virtual)
|
||||
return None
|
||||
|
||||
async def invalidate_after_write(self, path: str | PathSpec) -> None:
|
||||
"""Invalidate caches after a write to ``path``.
|
||||
|
||||
Args:
|
||||
path (str | PathSpec): Resource-relative path that was
|
||||
written.
|
||||
"""
|
||||
virtual = self._virtual(path)
|
||||
if self._caches_reads and self._file_cache is not None:
|
||||
await self._file_cache.remove(virtual)
|
||||
await self._invalidate_parent(virtual)
|
||||
|
||||
async def invalidate_after_unlink(self, path: str | PathSpec) -> None:
|
||||
"""Invalidate caches after a deletion of ``path``.
|
||||
|
||||
Args:
|
||||
path (str | PathSpec): Resource-relative path that was
|
||||
removed.
|
||||
"""
|
||||
virtual = self._virtual(path)
|
||||
if self._caches_reads and self._file_cache is not None:
|
||||
await self._file_cache.remove(virtual)
|
||||
if self._index is not None:
|
||||
await self._index.invalidate_dir(virtual)
|
||||
await self._index.invalidate_dir(virtual + "/")
|
||||
await self._invalidate_parent(virtual)
|
||||
|
||||
async def _invalidate_parent(self, virtual: str) -> None:
|
||||
if self._index is None:
|
||||
return
|
||||
parent = virtual.rsplit("/", 1)[0] or "/"
|
||||
await self._index.invalidate_dir(parent)
|
||||
await self._index.invalidate_dir(parent + "/")
|
||||
Vendored
+136
@@ -0,0 +1,136 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import inspect
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
|
||||
from mirage.cache.context import active_cache_manager
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
async def _serve_stream(manager, raw: Callable, accessor, path: PathSpec,
|
||||
*args, **kwargs) -> AsyncIterator[bytes]:
|
||||
if manager is not None and isinstance(path, PathSpec):
|
||||
cached = await manager.cached_bytes(path)
|
||||
if cached is not None:
|
||||
yield cached
|
||||
return
|
||||
async for chunk in raw(accessor, path, *args, **kwargs):
|
||||
yield chunk
|
||||
|
||||
|
||||
def cache_aware_read_stream(raw: Callable) -> Callable:
|
||||
"""Wrap a backend ``read_stream`` so warm reads serve cached bytes.
|
||||
|
||||
The returned reader keeps the backend's ``(accessor, path, ...)``
|
||||
signature, so it is a drop-in for the raw reader wherever a command
|
||||
injects one (the factory, ``head_multi``, ``generic_grep``, ...). On a
|
||||
warm hit it yields the whole cached blob as one chunk; otherwise it
|
||||
streams from the backend. ``cached_bytes`` is a no-op (returns None)
|
||||
for local or non-caching mounts, so this is safe to apply uniformly.
|
||||
|
||||
The wrapper is a ``def`` (not an ``async def``) that captures the
|
||||
active cache manager eagerly and returns the async generator: the
|
||||
manager must be read when the command calls the reader (inside the
|
||||
mount's cache-manager scope), not lazily when the stream drains, by
|
||||
which time that scope is gone.
|
||||
|
||||
Args:
|
||||
raw (Callable): the backend ``read_stream`` op.
|
||||
"""
|
||||
|
||||
def reader(accessor, path: PathSpec, *args, **kwargs):
|
||||
manager = active_cache_manager()
|
||||
return _serve_stream(manager, raw, accessor, path, *args, **kwargs)
|
||||
|
||||
return reader
|
||||
|
||||
|
||||
def cache_aware_read_bytes(raw: Callable) -> Callable:
|
||||
"""Wrap a backend ``read_bytes`` so warm reads serve cached bytes.
|
||||
|
||||
Drop-in for the raw reader, same signature. Returns the cached bytes
|
||||
on a warm hit, else reads from the backend. No-op for local or
|
||||
non-caching mounts.
|
||||
|
||||
Args:
|
||||
raw (Callable): the backend ``read_bytes`` op.
|
||||
"""
|
||||
|
||||
async def reader(accessor, path: PathSpec, *args, **kwargs) -> bytes:
|
||||
manager = active_cache_manager()
|
||||
if manager is not None and isinstance(path, PathSpec):
|
||||
cached = await manager.cached_bytes(path)
|
||||
if cached is not None:
|
||||
return cached
|
||||
return await raw(accessor, path, *args, **kwargs)
|
||||
|
||||
return reader
|
||||
|
||||
|
||||
def cache_aware_read(raw: Callable) -> Callable:
|
||||
"""Wrap a polymorphic reader so warm reads serve cached bytes.
|
||||
|
||||
For the ``read`` contract used by ``head_multi`` / ``tail_multi`` /
|
||||
wc ``format_multi``: the reader is called as ``read(accessor, path,
|
||||
...)`` and may return bytes, an awaitable of bytes, or an async byte
|
||||
iterator. On a warm hit the wrapped reader returns the cached bytes;
|
||||
otherwise it calls the raw reader and returns whatever it produced
|
||||
unchanged, so the consumer's own ``isawaitable`` / ``ensure_stream``
|
||||
normalization still applies. No-op for local or non-caching mounts.
|
||||
|
||||
The active cache manager is captured **eagerly**, when this wrapper
|
||||
is applied, not when the wrapped reader is later called: a consumer
|
||||
that yields lazily (``head_multi``) is drained after the mount's
|
||||
cache-manager scope is gone, so reading the contextvar at drain time
|
||||
would always miss. Apply this wrapper inside the command's scope
|
||||
(which the consumers do) so the captured manager travels with the
|
||||
stream, mirroring :func:`cache_aware_read_stream`.
|
||||
|
||||
Args:
|
||||
raw (Callable): the backend reader (bytes / awaitable / stream).
|
||||
"""
|
||||
manager = active_cache_manager()
|
||||
|
||||
async def reader(accessor, path: PathSpec, *args, **kwargs):
|
||||
if manager is not None and isinstance(path, PathSpec):
|
||||
cached = await manager.cached_bytes(path)
|
||||
if cached is not None:
|
||||
return cached
|
||||
result = raw(accessor, path, *args, **kwargs)
|
||||
if inspect.isawaitable(result):
|
||||
return await result
|
||||
return result
|
||||
|
||||
return reader
|
||||
|
||||
|
||||
async def cached_prefix_bytes(path: PathSpec, n: int | None) -> bytes | None:
|
||||
"""Return the first ``n`` cached bytes of ``path`` when warm, else None.
|
||||
|
||||
Lets a range-read fast path (e.g. ``head -c N``) serve from a fully
|
||||
cached file without a partial backend fetch. ``n=None`` returns the
|
||||
whole cached blob.
|
||||
|
||||
Args:
|
||||
path (PathSpec): the path to look up.
|
||||
n (int | None): byte count, or None for the whole file.
|
||||
"""
|
||||
manager = active_cache_manager()
|
||||
if manager is None or not isinstance(path, PathSpec):
|
||||
return None
|
||||
cached = await manager.cached_bytes(path)
|
||||
if cached is None:
|
||||
return None
|
||||
return cached if n is None else cached[:n]
|
||||
@@ -0,0 +1,55 @@
|
||||
# Mirage CLI
|
||||
|
||||
`mirage` is the command-line client for the Mirage daemon. It talks to a local
|
||||
daemon over HTTP and auto-spawns one on first use if none is running.
|
||||
|
||||
## Basic usage
|
||||
|
||||
```bash
|
||||
# A minimal workspace config (workspace.yaml)
|
||||
cat > workspace.yaml <<'YAML'
|
||||
mounts:
|
||||
/:
|
||||
resource: ram
|
||||
mode: WRITE
|
||||
YAML
|
||||
|
||||
# Create a workspace (spawns the daemon if needed)
|
||||
mirage workspace create ./workspace.yaml --id myws
|
||||
|
||||
# Run a command in it
|
||||
mirage execute -w myws -c 'echo hello'
|
||||
|
||||
# Inspect / clean up
|
||||
mirage workspace list
|
||||
mirage workspace get myws
|
||||
mirage workspace delete myws
|
||||
|
||||
# Stop the daemon
|
||||
mirage daemon stop
|
||||
```
|
||||
|
||||
Other command groups: `session`, `provision`, `daemon`, plus
|
||||
`workspace clone|snapshot|load`. Run `mirage <command> --help` for details.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| --------------------------- | ------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `MIRAGE_DAEMON_URL` | `http://127.0.0.1:8765` | Daemon address the CLI connects to |
|
||||
| `MIRAGE_TOKEN` | (none) | Bearer token the CLI sends to the daemon |
|
||||
| `MIRAGE_AUTH_MODE` | `local` | Daemon auth mode: `local`, `token`, or `jwt` |
|
||||
| `MIRAGE_AUTH_TOKEN` | (auto-minted in `local`) | Token the daemon accepts |
|
||||
| `MIRAGE_IDLE_GRACE_SECONDS` | `30` | Seconds the daemon waits after its last workspace is removed before shutting down |
|
||||
| `MIRAGE_ALLOWED_HOSTS` | `127.0.0.1,localhost,::1` | Daemon Host-header allowlist (CSV; `*` disables the check) |
|
||||
| `MIRAGE_DAEMON_PORT` | `8765` | Port the spawned daemon binds |
|
||||
| `MIRAGE_PID_FILE` | `~/.mirage/daemon.pid` | Daemon pid file location |
|
||||
| `MIRAGE_VERSION_ROOT` | `~/.mirage/repos` | Bare git repos backing workspace versioning |
|
||||
| `MIRAGE_SNAPSHOT_ROOT` | `~/.mirage/snapshots` | Snapshot storage root |
|
||||
|
||||
Every daemon setting (except the bootstrap `MIRAGE_HOME` and raw
|
||||
secrets) can also live in `~/.mirage/config.toml` under `[daemon]`,
|
||||
managed with `mirage config list|get|set|unset`. Per key the
|
||||
precedence is: env var > config.toml > default. `mirage config list --resolved` prints the effective value of every key and where it came
|
||||
from. The daemon validates the `[daemon]` table at startup and
|
||||
refuses to start on unknown keys or malformed TOML; `mirage config unset <key>` accepts unknown keys so a broken file can be repaired.
|
||||
@@ -0,0 +1,17 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.cli.main import app
|
||||
|
||||
__all__ = ["app"]
|
||||
@@ -0,0 +1,167 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from mirage.cli.env import ENV_AUTH_MODE, ENV_AUTH_TOKEN
|
||||
from mirage.cli.settings import DaemonSettings, load_daemon_settings
|
||||
from mirage.server.auth import AuthMode
|
||||
from mirage.server.auth import storage as auth_storage
|
||||
from mirage.server.daemon_config import (read_daemon_table,
|
||||
validate_daemon_table)
|
||||
from mirage.server.env import ENV_DAEMON_PORT
|
||||
from mirage.server.paths import mirage_home
|
||||
|
||||
|
||||
class DaemonUnreachable(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class DaemonClient:
|
||||
"""Thin httpx wrapper around the Mirage daemon REST API.
|
||||
|
||||
Constructs once per CLI invocation and is reused across calls.
|
||||
Handles auth header injection, daemon auto-spawn on first
|
||||
``workspace --create``, and discovery via the settings chain.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: DaemonSettings) -> None:
|
||||
self.settings = settings
|
||||
self._client = httpx.Client(base_url=settings.url, timeout=60.0)
|
||||
|
||||
def __enter__(self) -> "DaemonClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info) -> None:
|
||||
self._client.close()
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
if self.settings.auth_token:
|
||||
return {"Authorization": f"Bearer {self.settings.auth_token}"}
|
||||
return {}
|
||||
|
||||
def request(self, method: str, path: str, **kwargs) -> httpx.Response:
|
||||
headers = {**self._headers(), **kwargs.pop("headers", {})}
|
||||
return self._client.request(method, path, headers=headers, **kwargs)
|
||||
|
||||
def is_reachable(self, timeout: float = 0.5) -> bool:
|
||||
try:
|
||||
r = self._client.get("/v1/health",
|
||||
timeout=timeout,
|
||||
headers=self._headers())
|
||||
return r.status_code == 200
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
|
||||
def ensure_running(self,
|
||||
startup_timeout: float = 5.0,
|
||||
allow_spawn: bool = True) -> None:
|
||||
"""Ensure the daemon is reachable, optionally spawning it.
|
||||
|
||||
Args:
|
||||
startup_timeout (float): seconds to wait for the spawned
|
||||
daemon to answer ``/v1/health``.
|
||||
allow_spawn (bool): if True and the daemon is unreachable,
|
||||
fork-execs ``uvicorn mirage.server.app:app`` detached.
|
||||
|
||||
Raises:
|
||||
DaemonUnreachable: daemon is not reachable and either
|
||||
``allow_spawn=False`` or the spawned daemon failed to
|
||||
come up within ``startup_timeout``.
|
||||
"""
|
||||
if self.is_reachable():
|
||||
return
|
||||
if not allow_spawn:
|
||||
raise DaemonUnreachable(
|
||||
f"daemon not reachable at {self.settings.url}; "
|
||||
"run `mirage workspace --create CONFIG.yaml` to spawn one")
|
||||
self._spawn_daemon()
|
||||
deadline = time.monotonic() + startup_timeout
|
||||
while time.monotonic() < deadline:
|
||||
if self.is_reachable(timeout=0.3):
|
||||
return
|
||||
time.sleep(0.1)
|
||||
raise DaemonUnreachable(
|
||||
f"daemon spawned but did not answer /v1/health within "
|
||||
f"{startup_timeout:.1f}s")
|
||||
|
||||
def _spawn_daemon(self) -> None:
|
||||
table = read_daemon_table(mirage_home())
|
||||
validate_daemon_table(table)
|
||||
port = self._resolve_port(table)
|
||||
env = dict(os.environ)
|
||||
if not self.settings.auth_token:
|
||||
self.settings.auth_token = auth_storage.ensure_token_file(
|
||||
auth_storage.default_token_file())
|
||||
env[ENV_AUTH_TOKEN] = self.settings.auth_token
|
||||
if ENV_AUTH_MODE not in env and not table.get("auth_mode"):
|
||||
env[ENV_AUTH_MODE] = AuthMode.LOCAL.value
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"mirage.server.daemon:app",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(port),
|
||||
"--log-level",
|
||||
"warning",
|
||||
]
|
||||
log_dir = mirage_home()
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = log_dir / "daemon.log"
|
||||
with open(log_file, "ab") as f:
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
env=env,
|
||||
stdout=f,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
def _resolve_port(self, table: dict) -> int:
|
||||
"""Resolve the listen port for a spawned daemon.
|
||||
|
||||
Priority: ``$MIRAGE_DAEMON_PORT``, then the ``port`` key in
|
||||
``config.toml`` ``[daemon]``, then the port embedded in the
|
||||
client ``url`` setting, then 8765.
|
||||
|
||||
Args:
|
||||
table (dict): the already-read ``[daemon]`` table.
|
||||
|
||||
Returns:
|
||||
int: the port to bind.
|
||||
"""
|
||||
env_port = os.environ.get(ENV_DAEMON_PORT)
|
||||
if env_port:
|
||||
return int(env_port)
|
||||
config_port = table.get("port")
|
||||
if config_port:
|
||||
return int(config_port)
|
||||
return self._port_from_url()
|
||||
|
||||
def _port_from_url(self) -> int:
|
||||
parsed = urlparse(self.settings.url)
|
||||
return parsed.port or 8765
|
||||
|
||||
|
||||
def make_client() -> DaemonClient:
|
||||
return DaemonClient(load_daemon_settings())
|
||||
@@ -0,0 +1,120 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import typer
|
||||
|
||||
from mirage.cli.output import emit, fail
|
||||
from mirage.cli.settings import (get_config, list_config, resolved_config,
|
||||
set_config, unset_config)
|
||||
from mirage.server.daemon_config import ALLOWED_KEYS, DaemonConfigError
|
||||
|
||||
app = typer.Typer(no_args_is_help=True,
|
||||
help="Read and write daemon settings in config.toml.")
|
||||
|
||||
|
||||
def _mask(key: str, value: str) -> str:
|
||||
if key == "auth_token" and value:
|
||||
return "***"
|
||||
return value
|
||||
|
||||
|
||||
def _human_table(table: dict) -> str:
|
||||
return "\n".join(f"{k} = {v}" for k, v in table.items())
|
||||
|
||||
|
||||
def _human_resolved(table: dict) -> str:
|
||||
return "\n".join(f"{k} = {e['value']} ({e['origin']})"
|
||||
for k, e in table.items())
|
||||
|
||||
|
||||
def _list_resolved() -> None:
|
||||
try:
|
||||
resolved = resolved_config()
|
||||
except DaemonConfigError as e:
|
||||
fail(str(e), exit_code=2)
|
||||
payload = {}
|
||||
for key, (value, origin) in resolved.items():
|
||||
payload[key] = {"value": _mask(key, value), "origin": origin}
|
||||
emit(payload, human=_human_resolved)
|
||||
|
||||
|
||||
def _list_file() -> None:
|
||||
try:
|
||||
table = list_config()
|
||||
except DaemonConfigError as e:
|
||||
fail(str(e), exit_code=2)
|
||||
unknown = sorted(set(table) - ALLOWED_KEYS)
|
||||
if unknown:
|
||||
typer.echo(
|
||||
"warning: unknown [daemon] keys (daemon will refuse to "
|
||||
f"start): {', '.join(unknown)}",
|
||||
err=True)
|
||||
emit(table, human=_human_table)
|
||||
|
||||
|
||||
_RESOLVED_OPTION = typer.Option(False,
|
||||
"--resolved",
|
||||
help="show effective values and their origins")
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def list_cmd(resolved: bool = _RESOLVED_OPTION) -> None:
|
||||
"""Print every key in the config.toml [daemon] table.
|
||||
|
||||
With --resolved, print the effective value of every key after
|
||||
applying precedence (env var > config file > default) and where
|
||||
each value came from. auth_token is masked.
|
||||
"""
|
||||
if resolved:
|
||||
_list_resolved()
|
||||
else:
|
||||
_list_file()
|
||||
|
||||
|
||||
@app.command("get")
|
||||
def get_cmd(key: str = typer.Argument(..., help="config key")) -> None:
|
||||
"""Print one [daemon] key's value from config.toml."""
|
||||
try:
|
||||
value = get_config(key)
|
||||
except DaemonConfigError as e:
|
||||
fail(str(e), exit_code=2)
|
||||
if value is None:
|
||||
fail(f"{key} is not set", exit_code=1)
|
||||
emit({key: value}, human=lambda d: str(d[key]))
|
||||
|
||||
|
||||
@app.command("set")
|
||||
def set_cmd(
|
||||
key: str = typer.Argument(..., help="config key"),
|
||||
value: str = typer.Argument(..., help="value to store"),
|
||||
) -> None:
|
||||
"""Write a [daemon] key to config.toml.
|
||||
|
||||
Path settings take effect on the next daemon start/restart.
|
||||
"""
|
||||
try:
|
||||
set_config(key, value)
|
||||
except DaemonConfigError as e:
|
||||
fail(str(e), exit_code=2)
|
||||
emit({key: value, "written": True}, human=lambda d: f"{key} = {value}")
|
||||
|
||||
|
||||
@app.command("unset")
|
||||
def unset_cmd(key: str = typer.Argument(..., help="config key")) -> None:
|
||||
"""Remove a [daemon] key from config.toml."""
|
||||
try:
|
||||
unset_config(key)
|
||||
except DaemonConfigError as e:
|
||||
fail(str(e), exit_code=2)
|
||||
emit({key: None, "unset": True}, human=lambda d: f"unset {key}")
|
||||
@@ -0,0 +1,232 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import typer
|
||||
|
||||
from mirage.cli.client import make_client
|
||||
from mirage.cli.output import emit, fail, format_age
|
||||
from mirage.server.paths import pid_file_path
|
||||
|
||||
app = typer.Typer(no_args_is_help=True,
|
||||
help="Manage the daemon process lifecycle.")
|
||||
|
||||
|
||||
def _format_status(d: dict) -> str:
|
||||
if not d.get("running"):
|
||||
return f"Daemon not running. URL: {d['url']}"
|
||||
parts = [f"Running. PID {d.get('pid', '?')}"]
|
||||
uptime = d.get("uptime_s")
|
||||
if uptime is not None:
|
||||
parts.append(f"uptime {format_age(time.time() - uptime)}")
|
||||
ws_count = d.get("workspaces")
|
||||
if ws_count is not None:
|
||||
parts.append(f"{ws_count} workspace{'s' if ws_count != 1 else ''}")
|
||||
return ", ".join(parts) + f". URL: {d['url']}"
|
||||
|
||||
|
||||
def _format_stop(d: dict) -> str:
|
||||
via = d.get("via", "?")
|
||||
pid = d.get("pid")
|
||||
return f"Stopped (via {via}{f', PID {pid}' if pid else ''})."
|
||||
|
||||
|
||||
def _format_restart(d: dict) -> str:
|
||||
if d.get("spawned_fresh"):
|
||||
return "Restarted (eager spawn)."
|
||||
return "Restarted; next CLI command will auto-spawn."
|
||||
|
||||
|
||||
def _format_kill(d: dict) -> str:
|
||||
if d.get("killed"):
|
||||
return f"Killed PID {d['pid']}."
|
||||
pid = d.get("pid")
|
||||
if pid is None:
|
||||
return "Daemon not running."
|
||||
return f"Already gone (PID {pid})."
|
||||
|
||||
|
||||
def _read_pid() -> int | None:
|
||||
pid_file = pid_file_path()
|
||||
if not pid_file.exists():
|
||||
return None
|
||||
try:
|
||||
return int(pid_file.read_text().strip())
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _process_alive(pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
@app.command("status")
|
||||
def status_cmd() -> None:
|
||||
"""Show daemon health, PID, uptime, workspace count."""
|
||||
pid = _read_pid()
|
||||
with make_client() as client:
|
||||
url = client.settings.url
|
||||
try:
|
||||
r = client.request("GET", "/v1/health")
|
||||
health = r.json()
|
||||
except httpx.RequestError:
|
||||
health = None
|
||||
if health is None:
|
||||
emit({"running": False, "pid": pid, "url": url}, human=_format_status)
|
||||
raise typer.Exit(code=1)
|
||||
emit({
|
||||
"running": True,
|
||||
"pid": pid,
|
||||
"url": url,
|
||||
**health
|
||||
},
|
||||
human=_format_status)
|
||||
|
||||
|
||||
@app.command("stop")
|
||||
def stop_cmd(
|
||||
timeout: float = typer.Option(
|
||||
5.0,
|
||||
"--timeout",
|
||||
help="Seconds to wait for graceful exit before SIGTERM."),
|
||||
) -> None:
|
||||
"""Gracefully stop the daemon.
|
||||
|
||||
Calls ``POST /v1/shutdown`` which trips the daemon's exit event.
|
||||
The daemon closes active workspaces and exits. If the daemon
|
||||
doesn't exit within ``--timeout``, fall back to SIGTERM via the
|
||||
PID file.
|
||||
"""
|
||||
with make_client() as client:
|
||||
try:
|
||||
r = client.request("POST", "/v1/shutdown")
|
||||
except httpx.RequestError as e:
|
||||
fail(f"daemon not reachable: {e}", exit_code=1)
|
||||
if r.status_code >= 400:
|
||||
fail(f"shutdown failed: {r.text}", exit_code=2)
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
with make_client() as client:
|
||||
if not client.is_reachable(timeout=0.3):
|
||||
emit({"stopped": True, "via": "graceful"}, human=_format_stop)
|
||||
return
|
||||
time.sleep(0.2)
|
||||
|
||||
pid = _read_pid()
|
||||
if pid and _process_alive(pid):
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
emit({
|
||||
"stopped": True,
|
||||
"via": "sigterm",
|
||||
"pid": pid
|
||||
},
|
||||
human=_format_stop)
|
||||
return
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
fail(f"daemon did not exit within {timeout}s and no live PID found",
|
||||
exit_code=2)
|
||||
|
||||
|
||||
@app.command("restart")
|
||||
def restart_cmd(
|
||||
timeout: float = typer.Option(5.0, "--timeout"),
|
||||
eager: bool = typer.Option(
|
||||
False,
|
||||
"--eager",
|
||||
help="Spawn a fresh daemon immediately rather than waiting for "
|
||||
"the next CLI command to auto-spawn."),
|
||||
) -> None:
|
||||
"""Stop the daemon. Next CLI command auto-spawns a fresh one.
|
||||
|
||||
Workspaces are LOST on restart. Save any you want to keep first
|
||||
with ``mirage workspace snapshot <id> <path>`` and bring them
|
||||
back with ``mirage workspace load <path>``.
|
||||
"""
|
||||
with make_client() as client:
|
||||
try:
|
||||
client.request("POST", "/v1/shutdown")
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
with make_client() as client:
|
||||
if not client.is_reachable(timeout=0.3):
|
||||
break
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
pid = _read_pid()
|
||||
if pid and _process_alive(pid):
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
if eager:
|
||||
with make_client() as client:
|
||||
client.ensure_running()
|
||||
emit({"restarted": True, "spawned_fresh": True}, human=_format_restart)
|
||||
return
|
||||
emit(
|
||||
{
|
||||
"restarted": True,
|
||||
"spawned_fresh": False,
|
||||
"note": "next workspace --create will auto-spawn",
|
||||
},
|
||||
human=_format_restart,
|
||||
)
|
||||
|
||||
|
||||
@app.command("kill")
|
||||
def kill_cmd() -> None:
|
||||
"""SIGKILL the daemon. Last resort -- skips graceful shutdown."""
|
||||
pid = _read_pid()
|
||||
if pid is None:
|
||||
emit({
|
||||
"killed": False,
|
||||
"reason": "no daemon running",
|
||||
"pid": None
|
||||
},
|
||||
human=_format_kill)
|
||||
return
|
||||
if not _process_alive(pid):
|
||||
emit({
|
||||
"killed": False,
|
||||
"reason": "process already gone",
|
||||
"pid": pid
|
||||
},
|
||||
human=_format_kill)
|
||||
return
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
emit({
|
||||
"killed": False,
|
||||
"reason": "process gone",
|
||||
"pid": pid
|
||||
},
|
||||
human=_format_kill)
|
||||
return
|
||||
emit({"killed": True, "pid": pid}, human=_format_kill)
|
||||
@@ -0,0 +1,38 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.server.auth.config import (ENV_AUTH_MODE, ENV_AUTH_TOKEN,
|
||||
ENV_JWT_ALG, ENV_JWT_AUDIENCE,
|
||||
ENV_JWT_AUTHORIZED_PARTIES,
|
||||
ENV_JWT_CLOCK_SKEW, ENV_JWT_ISSUER,
|
||||
ENV_JWT_PUBKEY, ENV_JWT_PUBKEY_FILE)
|
||||
from mirage.server.env import ENV_IDLE_GRACE_SECONDS
|
||||
|
||||
ENV_DAEMON_URL = "MIRAGE_DAEMON_URL"
|
||||
ENV_TOKEN = "MIRAGE_TOKEN"
|
||||
|
||||
__all__ = [
|
||||
"ENV_AUTH_MODE",
|
||||
"ENV_AUTH_TOKEN",
|
||||
"ENV_DAEMON_URL",
|
||||
"ENV_IDLE_GRACE_SECONDS",
|
||||
"ENV_JWT_ALG",
|
||||
"ENV_JWT_AUDIENCE",
|
||||
"ENV_JWT_AUTHORIZED_PARTIES",
|
||||
"ENV_JWT_CLOCK_SKEW",
|
||||
"ENV_JWT_ISSUER",
|
||||
"ENV_JWT_PUBKEY",
|
||||
"ENV_JWT_PUBKEY_FILE",
|
||||
"ENV_TOKEN",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user