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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:30:44 +08:00
commit bcbd1bdb22
5748 changed files with 562488 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
3.12
+201
View File
@@ -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.
+185
View File
@@ -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 &amp; 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.
+39
View File
@@ -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"
+26
View File
@@ -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",
]
+56
View File
@@ -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("/") + "/"
+23
View File
@@ -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
+29
View File
@@ -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
+8
View File
@@ -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
+22
View File
@@ -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
+23
View File
@@ -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
+52
View File
@@ -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
+23
View File
@@ -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
+23
View File
@@ -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
+32
View File
@@ -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
+22
View File
@@ -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
+23
View File
@@ -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
+23
View File
@@ -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
+23
View File
@@ -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
+59
View File
@@ -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}"
+60
View File
@@ -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}"
+60
View File
@@ -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}"
+60
View File
@@ -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}"
+26
View File
@@ -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
+71
View File
@@ -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
+32
View File
@@ -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
+22
View File
@@ -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
+80
View File
@@ -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
+21
View File
@@ -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)
+22
View File
@@ -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
+36
View File
@@ -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
+41
View File
@@ -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
+65
View File
@@ -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
+22
View File
@@ -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
+22
View File
@@ -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
+45
View File
@@ -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
+12
View File
@@ -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
+22
View File
@@ -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
+43
View File
@@ -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
+22
View File
@@ -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
+13
View File
@@ -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. =========
+22
View File
@@ -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",
]
+17
View File
@@ -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"]
+133
View File
@@ -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)
+18
View File
@@ -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"]
+56
View File
@@ -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()
+256
View File
@@ -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
+169
View File
@@ -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),
],
)
+29
View File
@@ -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
+252
View File
@@ -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
+17
View File
@@ -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",
]
+107
View File
@@ -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
+253
View File
@@ -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)
+59
View File
@@ -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
+247
View File
@@ -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"]
+18
View File
@@ -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"]
+36
View File
@@ -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)
+25
View 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. =========
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)
+33
View File
@@ -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}")
+92
View File
@@ -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)
+18
View File
@@ -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"]
+29
View File
@@ -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:"
+30
View File
@@ -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
+139
View File
@@ -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)
+92
View File
@@ -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
+168
View File
@@ -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
+130
View File
@@ -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
+29
View File
@@ -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
View File
@@ -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}")
+61
View File
@@ -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:"
+89
View File
@@ -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()
+161
View File
@@ -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()
+48
View File
@@ -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
+41
View File
@@ -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()
+110
View File
@@ -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 + "/")
+136
View File
@@ -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]
+55
View File
@@ -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.
+17
View File
@@ -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"]
+167
View File
@@ -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())
+120
View File
@@ -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}")
+232
View File
@@ -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)
+38
View File
@@ -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