chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:12:33 +08:00
commit aacb60a4af
3387 changed files with 981307 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
# Build-context exclusions — keep the image small and reproducible.
# Anything listed here never enters the build context sent to the daemon.
# --- VCS + editor + hidden local state -------------------------------------
.*
.vscode
.idea
.DS_Store
# --- Python runtime state ---------------------------------------------------
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache
.ruff_cache
.mypy_cache
.coverage
.coverage.*
htmlcov
.tox
.nox
# --- Virtualenvs ------------------------------------------------------------
.venv
venv
env
# --- Local data / logs / caches --------------------------------------------
data
logs
*.log
*.db
*.db-journal
*.db-wal
*.db-shm
# --- Docs + tests NOT needed inside the runtime image -----------------------
tests
docs
scripts
CHANGELOG.md
KNOWN_LIMITATIONS.md
# --- Install scripts (shipped at repo root for host-side install) ----------
install.sh
install.ps1
# --- Secrets + local config (never shipped) --------------------------------
.env
.env.*
*.pem
*.key
# --- Build artefacts --------------------------------------------------------
dist
build
*.egg-info
# --- CI / local tooling -----------------------------------------------------
uv.lock
.pre-commit-config.yaml
+22
View File
@@ -0,0 +1,22 @@
# OpenSquilla API Keys
# Copy this file to .env and fill in your keys:
# cp .env.example .env
# Required — LLM provider (OpenRouter)
OPENROUTER_API_KEY=
# Optional — TokenRhythm aggregator (DeepSeek/GLM/MiniMax/Kimi families)
TOKENRHYTHM_API_KEY=
# Optional — Brave Search API (auto-selected over DuckDuckGo when set)
# Get a key at https://brave.com/search/api/
BRAVE_SEARCH_API_KEY=
# Optional — Web search network proxy.
# Use this when Brave/DuckDuckGo endpoints are not reachable directly.
# This affects web_search only; LLM provider proxy is configured separately.
OPENSQUILLA_GATEWAY_SEARCH_PROXY=
# Optional — allow web_search providers to use HTTP_PROXY/HTTPS_PROXY when no
# explicit search proxy is set. Defaults to false for predictable behavior.
OPENSQUILLA_GATEWAY_SEARCH_USE_ENV_PROXY=false
+24
View File
@@ -0,0 +1,24 @@
*.sh text eol=lf
tests/test_provider/golden/requests/**/*.json text eol=lf
src/opensquilla/gateway/static/dist/**/*.css text eol=lf
src/opensquilla/gateway/static/dist/**/*.html text eol=lf
src/opensquilla/gateway/static/dist/**/*.js text eol=lf
src/opensquilla/gateway/static/dist/**/*.map text eol=lf
src/opensquilla/squilla_router/models/*.pkl filter=lfs diff=lfs merge=lfs -text
src/opensquilla/squilla_router/models/*.txt filter=lfs diff=lfs merge=lfs -text
src/opensquilla/squilla_router/models/embeddings/**/*.onnx filter=lfs diff=lfs merge=lfs -text
src/opensquilla/squilla_router/models/E3b/*.bin filter=lfs diff=lfs merge=lfs -text
src/opensquilla/squilla_router/models/E3b/*.pkl filter=lfs diff=lfs merge=lfs -text
src/opensquilla/squilla_router/models/v4.2_phase3_inference/*.bin filter=lfs diff=lfs merge=lfs -text
src/opensquilla/squilla_router/models/v4.2_phase3_inference/**/*.bin filter=lfs diff=lfs merge=lfs -text
src/opensquilla/squilla_router/models/v4.2_phase3_inference/**/*.onnx filter=lfs diff=lfs merge=lfs -text
src/opensquilla/squilla_router/models/v4.2_phase3_inference/**/*.pkl filter=lfs diff=lfs merge=lfs -text
src/opensquilla/squilla_router/models/v4.2_phase3_inference/**/*.joblib filter=lfs diff=lfs merge=lfs -text
src/opensquilla/squilla_router/models/v4.2_phase3_inference/*.json text eol=lf
src/opensquilla/squilla_router/models/v4.2_phase3_inference/*.txt text eol=lf
src/opensquilla/squilla_router/models/v4.2_phase3_inference/*.yaml text eol=lf
src/opensquilla/squilla_router/models/v4.2_phase3_inference/**/*.json text eol=lf
src/opensquilla/squilla_router/models/v4.2_phase3_inference/**/*.txt text eol=lf
src/opensquilla/squilla_router/models/v4.2_phase3_inference/**/*.yaml text eol=lf
+80
View File
@@ -0,0 +1,80 @@
name: Bug report
description: Report a reproducible OpenSquilla defect
title: "[Bug]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Do not include credentials, provider tokens, account identifiers, raw local paths, or vulnerability details. Use SECURITY.md for suspected vulnerabilities. A diagnostics bundle generated by `opensquilla bundle` (or the Web UI / desktop download button) is redacted and safe to attach.
- type: input
id: version
attributes:
label: OpenSquilla version or commit
description: Use the release version or commit hash where the issue occurs.
placeholder: "0.2.0 or commit hash"
validations:
required: true
- type: dropdown
id: area
attributes:
label: Area
options:
- CLI
- Gateway
- Web UI
- Desktop app
- Provider integration
- Channel integration
- Memory
- Scheduler
- Tools
- Packaging or install
- Other
validations:
required: true
- type: textarea
id: steps
attributes:
label: Reproduction steps
description: Provide the smallest public reproduction you can share.
placeholder: |
1. Run ...
2. Configure ...
3. Observe ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
description: Paste minimal sanitized output if it helps.
validations:
required: true
- type: input
id: environment
attributes:
label: Environment
placeholder: "Windows 11, Python 3.12+, uv version"
validations:
required: true
- type: textarea
id: diagnostics
attributes:
label: Diagnostic bundle and error reference
description: >-
Attach a diagnostics bundle if you can: run `opensquilla bundle`, or use
the Diagnostic bundle button on the Web UI Logs page, or the desktop app menu
"Download Diagnostics…". The bundle is redacted by default (secrets
removed, paths normalized, no conversation content) and safe to attach.
If your error message showed a reference code like `(ref: a1b2c3d4)`,
paste it here.
placeholder: "ref: a1b2c3d4 — bundle attached"
validations:
required: false
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+45
View File
@@ -0,0 +1,45 @@
name: Documentation issue
description: Report missing, confusing, or outdated OpenSquilla documentation
title: "[Docs]: "
labels: ["documentation"]
body:
- type: markdown
attributes:
value: |
Thanks for improving OpenSquilla's docs. Do not include credentials, provider tokens, private transcripts, account identifiers, or sensitive local paths.
- type: input
id: page
attributes:
label: Page or section
description: Link the page, heading, or command example that needs work.
placeholder: "docs/quickstart.md, docs/features/memory.md, or a GitHub URL"
validations:
required: true
- type: dropdown
id: issue_type
attributes:
label: Issue type
options:
- Incorrect command or option
- Missing setup step
- Confusing explanation
- Missing feature guide
- Broken link
- Screenshot or UI mismatch
- Other
validations:
required: true
- type: textarea
id: problem
attributes:
label: What is wrong or missing?
description: Describe what you expected to learn and where the documentation fell short.
validations:
required: true
- type: textarea
id: suggestion
attributes:
label: Suggested fix
description: If you know the correction, include the command, wording, or link that would help.
validations:
required: false
@@ -0,0 +1,44 @@
name: Feature request
description: Propose a focused OpenSquilla improvement
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: Problem
description: Describe the user-facing problem or workflow gap.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed behavior
description: Describe the smallest behavior change that would solve the problem.
validations:
required: true
- type: dropdown
id: area
attributes:
label: Area
options:
- CLI
- Gateway
- Web UI
- Provider integration
- Channel integration
- Memory
- Scheduler
- Tools
- Packaging or install
- Documentation
- Other
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Note any simpler workaround or existing OpenSquilla behavior you tried.
validations:
required: false
+59
View File
@@ -0,0 +1,59 @@
## Scope
Scope boundary:
Non-goals:
## Branch
Base branch: main | staging/collaboration
Target exception: N/A | release | hotfix | staging/collaboration | maintainer-approved
## Issue
Linked issue: Fixes #... | Refs #... | None
If None, reason:
## Release Note
Release note: NONE |
## Tests
Ruff:
Pytest:
Build:
Regression tests: added | not needed
Notes:
The default test path remains offline, deterministic, credential-free, and safe for forks.
## Maintainer Live Check
Maintainer live check: no | yes
Surface: N/A | provider | browser | gateway | channel | release
Maintainer-only note: contributors are not expected to provide secrets or run credentialed live checks. Maintainers may run `Live Release E2E` for provider, browser, gateway, channel, or release smoke coverage.
## Safety
Secrets, local-only artifacts, private prompts/transcripts, channel identifiers, AI session artifacts, non-public fixtures, and tests/_private/ contents must not be committed.
## Third-Party Origin
Third-party origin: none | inspired-by | adapted/ported | vendored | direct dependency | modified upstream
Details if non-none: upstream URL, license, copied/adapted code/rules/fixtures/text, and notice/provenance updates.
## Documentation Changes
- [ ] Links point to existing repository files or stable external pages.
- [ ] Code fences and Markdown tables render correctly on GitHub.
- [ ] Examples avoid real secrets, local private paths, and private transcripts.
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""Fail closed when required CI jobs or classifier outputs are incomplete."""
from __future__ import annotations
import os
import sys
from collections.abc import Mapping
from typing import Final
BOOLEAN_FLAGS: Final[tuple[str, ...]] = (
"docs_only",
"runtime_changed",
"test_changed",
"ci_changed",
"dependency_changed",
"release_changed",
"windows_full_required",
"frontend_changed",
"tui_changed",
"desktop_changed",
"python_changed",
"platform_sensitive_changed",
"build_wheel_required",
"full_required",
)
ALWAYS_REQUIRED_RESULTS: Final[tuple[tuple[str, str], ...]] = (
("RESULT_CLASSIFY", "Classify changed files"),
("RESULT_WORKFLOW_LINT", "Workflow lint"),
("RESULT_README_LOCALE", "README locale parity"),
)
def _flag_env(name: str) -> str:
return f"FLAG_{name.upper()}"
def _read_flags(env: Mapping[str, str], errors: list[str]) -> dict[str, bool]:
flags: dict[str, bool] = {}
for name in BOOLEAN_FLAGS:
raw = env.get(_flag_env(name), "")
if raw not in {"true", "false"}:
errors.append(f"Classifier output {name} must be exactly true or false; got {raw!r}.")
continue
flags[name] = raw == "true"
return flags
def _require_result(
env: Mapping[str, str],
errors: list[str],
variable: str,
label: str,
*,
required: bool,
) -> None:
result = env.get(variable, "")
allowed = {"success"} if required else {"success", "skipped"}
if result not in allowed:
expectation = "succeed" if required else "be successful or intentionally skipped"
errors.append(f"{label} must {expectation}; got {result or 'missing'}.")
def check_ci_results(env: Mapping[str, str]) -> list[str]:
"""Return gate errors; an empty list means the aggregate check may pass."""
errors: list[str] = []
flags = _read_flags(env, errors)
for variable, label in ALWAYS_REQUIRED_RESULTS:
_require_result(env, errors, variable, label, required=True)
if len(flags) != len(BOOLEAN_FLAGS):
return errors
full = flags["full_required"]
conditional_results = (
("RESULT_FRONTEND", "Frontend build and typecheck", flags["frontend_changed"] or full),
("RESULT_TUI", "OpenTUI package tests", flags["tui_changed"] or full),
("RESULT_DESKTOP", "Desktop Electron unit tests", flags["desktop_changed"] or full),
("RESULT_UBUNTU", "Ubuntu quality gate", flags["python_changed"] or full),
(
"RESULT_WINDOWS_SMOKE",
"Windows compatibility smoke tests",
flags["python_changed"]
or flags["platform_sensitive_changed"]
or flags["dependency_changed"]
or flags["release_changed"]
or full,
),
(
"RESULT_WINDOWS_FULL",
"Windows high-risk matrix",
flags["windows_full_required"] or full,
),
(
"RESULT_MACOS_RECOVERY",
"macOS profile recovery and native no-replace tests",
flags["platform_sensitive_changed"] or flags["desktop_changed"] or full,
),
(
"RESULT_DESKTOP_RECOVERY_E2E",
"Desktop recovery E2E matrix",
flags["platform_sensitive_changed"] or flags["desktop_changed"] or full,
),
(
"RESULT_RELEASE",
"Release packaging contracts",
flags["release_changed"] or full,
),
)
for variable, label, required in conditional_results:
_require_result(env, errors, variable, label, required=required)
if flags["platform_sensitive_changed"] and not flags["windows_full_required"]:
errors.append("Platform-sensitive changes must require the Windows high-risk matrix.")
if full:
if flags["docs_only"]:
errors.append("A full CI run cannot be classified as docs-only.")
for name in BOOLEAN_FLAGS:
if name in {"docs_only", "full_required"}:
continue
if not flags[name]:
errors.append(f"Full CI classification must set {name}=true.")
if flags["docs_only"]:
active = [
name
for name in BOOLEAN_FLAGS
if name != "docs_only" and flags[name]
]
if active:
errors.append(
"Docs-only classification cannot enable other flags: " + ", ".join(active)
)
return errors
def main() -> int:
errors = check_ci_results(os.environ)
for variable, label in ALWAYS_REQUIRED_RESULTS:
print(f"{label}: {os.environ.get(variable, 'missing')}")
print(
"Classifier flags: "
+ " ".join(
f"{name}={os.environ.get(_flag_env(name), 'missing')}" for name in BOOLEAN_FLAGS
)
)
if not errors:
print("All required CI results are complete and successful.")
return 0
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env bash
set -euo pipefail
changed_files="${1:?usage: classify-ci-changes.sh <changed-files-list>}"
output_file="${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}"
docs_only=true
runtime_changed=false
test_changed=false
ci_changed=false
dependency_changed=false
release_changed=false
windows_full_required=false
frontend_changed=false
tui_changed=false
desktop_changed=false
python_changed=false
platform_sensitive_changed=false
build_wheel_required=false
full_required=false
seen_file=false
mark_non_docs_changed() {
docs_only=false
}
mark_runtime_changed() {
mark_non_docs_changed
runtime_changed=true
python_changed=true
build_wheel_required=true
}
mark_test_changed() {
mark_non_docs_changed
test_changed=true
python_changed=true
}
mark_ci_changed() {
mark_non_docs_changed
ci_changed=true
python_changed=true
}
mark_dependency_changed() {
mark_runtime_changed
dependency_changed=true
release_changed=true
windows_full_required=true
}
mark_release_changed() {
mark_non_docs_changed
release_changed=true
windows_full_required=true
}
mark_frontend_changed() {
mark_non_docs_changed
frontend_changed=true
}
mark_tui_changed() {
mark_runtime_changed
tui_changed=true
}
mark_desktop_changed() {
mark_non_docs_changed
desktop_changed=true
}
mark_platform_sensitive_changed() {
mark_non_docs_changed
platform_sensitive_changed=true
windows_full_required=true
}
mark_full_required() {
docs_only=false
runtime_changed=true
test_changed=true
ci_changed=true
dependency_changed=true
release_changed=true
windows_full_required=true
frontend_changed=true
tui_changed=true
desktop_changed=true
python_changed=true
platform_sensitive_changed=true
build_wheel_required=true
full_required=true
}
while IFS= read -r path || [[ -n "${path}" ]]; do
path="${path%$'\r'}"
[[ -z "${path}" ]] && continue
seen_file=true
case "${path}" in
.ci/run-all)
mark_full_required
;;
pyproject.toml | uv.lock)
mark_dependency_changed
;;
opensquilla-webui/*)
mark_frontend_changed
;;
src/opensquilla/cli/tui/opentui/package/*)
mark_tui_changed
;;
.github/workflows/ci.yml | .github/scripts/classify-ci-changes.sh | .github/scripts/check_ci_results.py | .github/scripts/windows_test_shards.py)
# Changes to the gate itself must exercise every path it can suppress.
mark_full_required
;;
.github/workflows/wheelhouse-release.yml)
mark_ci_changed
mark_release_changed
;;
.github/workflows/*)
mark_full_required
;;
.github/scripts/verify-release-profile-preservation.py)
mark_ci_changed
mark_release_changed
mark_platform_sensitive_changed
;;
.github/scripts/*)
mark_full_required
;;
tests/test_scripts/test_build_wheelhouse_zip.py | tests/test_install_scripts.py | tests/test_root_start_scripts.py | tests/test_release_consistency.py | tests/test_public_release_hygiene.py)
mark_test_changed
mark_release_changed
;;
tests/test_tools/test_shell_* | tests/test_tools/test_path_* | tests/test_sandbox/* | tests/test_desktop/* | tests/test_compat/* | tests/test_recovery/* | tests/test_migration/* | tests/test_migrations/* | tests/test_persistence/* | tests/test_session/* | tests/test_scheduler/* | tests/test_uninstall/* | tests/test_packaging/*)
mark_test_changed
mark_platform_sensitive_changed
;;
tests/test_onboarding/* | tests/test_provider/* | tests/test_provider*.py)
mark_test_changed
mark_platform_sensitive_changed
;;
tests/functional/test_gateway_*_e2e.py)
mark_test_changed
mark_platform_sensitive_changed
;;
tests/*)
# New or unclassified tests fail closed to the Windows high-risk suite.
mark_test_changed
mark_platform_sensitive_changed
;;
scripts/build_wheelhouse_zip.py | scripts/install_source.sh | scripts/install_source.ps1)
mark_runtime_changed
mark_release_changed
;;
install.sh | install.ps1 | start.sh | start.ps1 | README.release.md | RELEASES.md)
mark_release_changed
;;
desktop/electron/scripts/test-packaged-update-policy.mjs)
mark_desktop_changed
mark_platform_sensitive_changed
mark_release_changed
;;
desktop/*)
mark_platform_sensitive_changed
mark_desktop_changed
;;
src/opensquilla/uninstall/*)
mark_runtime_changed
mark_release_changed
;;
src/opensquilla/recovery/* | src/opensquilla/migration/* | src/opensquilla/persistence/* | src/opensquilla/memory/* | src/opensquilla/session/* | src/opensquilla/scheduler/* | src/opensquilla/sandbox/* | src/opensquilla/tools/boundary.py | src/opensquilla/tools/builtin/code_exec.py | src/opensquilla/tools/builtin/filesystem.py | src/opensquilla/tools/builtin/git.py | src/opensquilla/tools/builtin/shell.py | src/opensquilla/tools/builtin/shell_policy.py | src/opensquilla/tools/path_* | src/opensquilla/tools/policy* | src/opensquilla/tools/write_*)
mark_runtime_changed
mark_platform_sensitive_changed
;;
src/opensquilla/onboarding/* | src/opensquilla/provider/*)
mark_runtime_changed
mark_platform_sensitive_changed
;;
migrations/*)
mark_runtime_changed
mark_platform_sensitive_changed
;;
src/* | scripts/*)
# Unknown runtime surfaces are high risk until explicitly proven otherwise.
mark_runtime_changed
mark_platform_sensitive_changed
;;
docs/* | README.md | README.*.md | CHANGELOG.md | CODE_OF_CONDUCT.md | CONTRIBUTING.md | MIGRATION.md | SECURITY.md | SUPPORT.md | THIRD_PARTY_NOTICES.md | META_SKILL_GUIDE.md | .github/pull_request_template.md | .github/ISSUE_TEMPLATE/*)
;;
*)
# Fail closed for new non-documentation paths.
mark_runtime_changed
mark_platform_sensitive_changed
;;
esac
done < "${changed_files}"
if [[ "${seen_file}" == "false" ]]; then
mark_full_required
fi
{
printf 'docs_only=%s\n' "${docs_only}"
printf 'runtime_changed=%s\n' "${runtime_changed}"
printf 'test_changed=%s\n' "${test_changed}"
printf 'ci_changed=%s\n' "${ci_changed}"
printf 'dependency_changed=%s\n' "${dependency_changed}"
printf 'release_changed=%s\n' "${release_changed}"
printf 'windows_full_required=%s\n' "${windows_full_required}"
printf 'frontend_changed=%s\n' "${frontend_changed}"
printf 'tui_changed=%s\n' "${tui_changed}"
printf 'desktop_changed=%s\n' "${desktop_changed}"
printf 'python_changed=%s\n' "${python_changed}"
printf 'platform_sensitive_changed=%s\n' "${platform_sensitive_changed}"
printf 'build_wheel_required=%s\n' "${build_wheel_required}"
printf 'full_required=%s\n' "${full_required}"
} >> "${output_file}"
+437
View File
@@ -0,0 +1,437 @@
#!/usr/bin/env python3
"""Synchronize OpenSquilla issue state after pull request lifecycle changes.
This script is intentionally safe for `pull_request_target`: it treats pull
request text as data only and never executes code from the pull request head.
"""
from __future__ import annotations
import json
import os
import re
import sys
from typing import Any, NamedTuple
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
HAS_LINKED_PR_LABEL = "has-linked-pr"
MERGED_TO_DEV_LABEL = "merged-to-dev"
NEEDS_VERIFICATION_LABEL = "needs-verification"
LABEL_DEFINITIONS = {
HAS_LINKED_PR_LABEL: {
"color": "C5DEF5",
"description": "An open pull request is linked to this issue",
},
MERGED_TO_DEV_LABEL: {
"color": "5319E7",
"description": "A related fix has merged to dev but has not necessarily shipped",
},
NEEDS_VERIFICATION_LABEL: {
"color": "C5DEF5",
"description": "Maintainers or reporters should retest the current behavior",
},
}
CLOSING_KEYWORDS = frozenset(
{
"close",
"closes",
"closed",
"fix",
"fixes",
"fixed",
"resolve",
"resolves",
"resolved",
}
)
#: Mutation statuses that describe the target's state or the token's scope on
#: that one target (labeling a pull request needs pull-requests write scope
#: this workflow does not request; locked or deleted issues answer 403/410)
#: rather than a broken sync setup — the affected action is logged and
#: skipped instead of failing every remaining action in the job.
TOLERATED_MUTATION_STATUSES = frozenset({403, 404, 410})
REFERENCE_KEYWORDS = frozenset({"ref", "refs", "reference", "references"})
OPEN_PR_ACTIONS = frozenset({"opened", "reopened", "edited"})
KEYWORD_RE = re.compile(
r"\b(?P<keyword>close|closes|closed|fix|fixes|fixed|resolve|resolves|"
r"resolved|ref|refs|reference|references)\s*:?\s+(?P<ref>"
r"#\d+|"
r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#\d+|"
r"https://github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/issues/\d+"
r")\b",
re.IGNORECASE,
)
LOCAL_REF_RE = re.compile(r"^#(?P<number>\d+)$")
REPO_REF_RE = re.compile(
r"^(?P<owner>[A-Za-z0-9_.-]+)/(?P<repo>[A-Za-z0-9_.-]+)#(?P<number>\d+)$"
)
ISSUE_URL_RE = re.compile(
r"^https://github\.com/(?P<owner>[A-Za-z0-9_.-]+)/(?P<repo>[A-Za-z0-9_.-]+)/"
r"issues/(?P<number>\d+)$"
)
class ParsedIssueLinks(NamedTuple):
closing: tuple[int, ...]
references: tuple[int, ...]
@property
def all(self) -> tuple[int, ...]:
return tuple(dict.fromkeys((*self.closing, *self.references)))
class IssueSyncAction(NamedTuple):
issue_number: int
kind: str
pr_number: int
pr_url: str
class GitHubClient:
def __init__(self, *, token: str, repository: str) -> None:
self._token = token
self._repository = repository
self._api_root = f"https://api.github.com/repos/{repository}"
def request_json(
self,
method: str,
path: str,
*,
payload: dict[str, Any] | None = None,
ignore_statuses: set[int] | None = None,
) -> Any:
ignore_statuses = ignore_statuses or set()
data = None
headers = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {self._token}",
"X-GitHub-Api-Version": "2022-11-28",
}
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
request = Request(
f"{self._api_root}{path}",
data=data,
headers=headers,
method=method,
)
try:
with urlopen(request, timeout=30) as response:
raw = response.read()
except HTTPError as exc:
if exc.code in ignore_statuses:
return None
body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"GitHub API {method} {path} failed: {exc.code} {body}") from exc
if not raw:
return None
return json.loads(raw.decode("utf-8"))
def get_issue(self, issue_number: int) -> dict[str, Any] | None:
"""Fetch an issue, or None when it is missing or unreadable.
Pull requests are returned too (the REST issues endpoint covers
both) — callers use the ``pull_request`` key to tell them apart.
"""
found = self.request_json(
"GET",
f"/issues/{issue_number}",
ignore_statuses=TOLERATED_MUTATION_STATUSES,
)
return found if isinstance(found, dict) else None
def ensure_label(self, name: str) -> None:
definition = LABEL_DEFINITIONS[name]
label_name = quote(name, safe="")
found = self.request_json(
"GET",
f"/labels/{label_name}",
ignore_statuses={404},
)
if found is not None:
return
self.request_json(
"POST",
"/labels",
payload={
"name": name,
"color": definition["color"],
"description": definition["description"],
},
# 422: another run created the label between the GET and here.
ignore_statuses=TOLERATED_MUTATION_STATUSES | {422},
)
def add_labels(self, issue_number: int, labels: list[str]) -> None:
for label in labels:
self.ensure_label(label)
self.request_json(
"POST",
f"/issues/{issue_number}/labels",
payload={"labels": labels},
ignore_statuses=TOLERATED_MUTATION_STATUSES,
)
def remove_label(self, issue_number: int, label: str) -> None:
label_name = quote(label, safe="")
self.request_json(
"DELETE",
f"/issues/{issue_number}/labels/{label_name}",
ignore_statuses=TOLERATED_MUTATION_STATUSES,
)
def list_comments(self, issue_number: int) -> list[dict[str, Any]]:
all_comments: list[dict[str, Any]] = []
page = 1
while True:
comments = self.request_json(
"GET",
f"/issues/{issue_number}/comments?per_page=100&page={page}",
)
if not isinstance(comments, list) or not comments:
return all_comments
all_comments.extend(comments)
if len(comments) < 100:
return all_comments
page += 1
def create_comment(self, issue_number: int, body: str) -> None:
self.request_json(
"POST",
f"/issues/{issue_number}/comments",
payload={"body": body},
# Locked conversations reject comments with 403; the labels above
# still applied, so the sync outcome is preserved.
ignore_statuses=TOLERATED_MUTATION_STATUSES,
)
def _normalize_repo_ref(raw_ref: str, *, owner: str, repo: str) -> int | None:
local = LOCAL_REF_RE.match(raw_ref)
if local is not None:
return int(local.group("number"))
repo_ref = REPO_REF_RE.match(raw_ref)
if repo_ref is not None:
if repo_ref.group("owner").lower() != owner.lower():
return None
if repo_ref.group("repo").lower() != repo.lower():
return None
return int(repo_ref.group("number"))
issue_url = ISSUE_URL_RE.match(raw_ref)
if issue_url is not None:
if issue_url.group("owner").lower() != owner.lower():
return None
if issue_url.group("repo").lower() != repo.lower():
return None
return int(issue_url.group("number"))
return None
def parse_linked_issues(body: str | None, *, owner: str, repo: str) -> ParsedIssueLinks:
closing: list[int] = []
references: list[int] = []
for match in KEYWORD_RE.finditer(body or ""):
issue_number = _normalize_repo_ref(match.group("ref"), owner=owner, repo=repo)
if issue_number is None:
continue
keyword = match.group("keyword").lower()
if keyword in CLOSING_KEYWORDS:
closing.append(issue_number)
elif keyword in REFERENCE_KEYWORDS:
references.append(issue_number)
return ParsedIssueLinks(
closing=tuple(dict.fromkeys(closing)),
references=tuple(dict.fromkeys(references)),
)
def plan_issue_sync_actions(event: dict[str, Any]) -> tuple[IssueSyncAction, ...]:
action = event.get("action")
pr = event.get("pull_request") or {}
repository = event.get("repository") or {}
full_name = repository.get("full_name") or ""
if "/" not in full_name:
return ()
owner, repo = full_name.split("/", 1)
pr_number = int(pr["number"])
pr_url = str(pr.get("html_url") or "")
parsed = parse_linked_issues(pr.get("body"), owner=owner, repo=repo)
if action in OPEN_PR_ACTIONS:
return tuple(
IssueSyncAction(
issue_number=issue_number,
kind="linked_pr_open",
pr_number=pr_number,
pr_url=pr_url,
)
for issue_number in parsed.all
)
if action != "closed":
return ()
if pr.get("merged") is True and (pr.get("base") or {}).get("ref") == "dev":
closing_actions = tuple(
IssueSyncAction(
issue_number=issue_number,
kind="merged_to_dev",
pr_number=pr_number,
pr_url=pr_url,
)
for issue_number in parsed.closing
)
closing_issue_numbers = set(parsed.closing)
reference_cleanup_actions = tuple(
IssueSyncAction(
issue_number=issue_number,
kind="remove_linked_pr",
pr_number=pr_number,
pr_url=pr_url,
)
for issue_number in parsed.references
if issue_number not in closing_issue_numbers
)
return (*closing_actions, *reference_cleanup_actions)
if pr.get("merged") is not True:
return tuple(
IssueSyncAction(
issue_number=issue_number,
kind="closed_unmerged",
pr_number=pr_number,
pr_url=pr_url,
)
for issue_number in parsed.all
)
return tuple(
IssueSyncAction(
issue_number=issue_number,
kind="remove_linked_pr",
pr_number=pr_number,
pr_url=pr_url,
)
for issue_number in parsed.all
)
def comment_marker(*, kind: str, pr_number: int) -> str:
marker_kind = kind.replace("_", "-")
return f"<!-- opensquilla-issue-link-sync:{marker_kind}:pr-{pr_number} -->"
def has_marker(comments: list[dict[str, Any]], marker: str) -> bool:
return any(marker in str(comment.get("body") or "") for comment in comments)
def _merged_to_dev_comment(action: IssueSyncAction) -> str:
marker = comment_marker(kind=action.kind, pr_number=action.pr_number)
return (
f"{marker}\n"
f"The linked fix for this issue has merged to `dev` via #{action.pr_number} "
f"({action.pr_url}). Keeping it open for verification before release."
)
def apply_action(client: GitHubClient, action: IssueSyncAction) -> None:
if action.kind == "linked_pr_open":
client.add_labels(action.issue_number, [HAS_LINKED_PR_LABEL])
return
if action.kind == "merged_to_dev":
client.add_labels(
action.issue_number,
[MERGED_TO_DEV_LABEL, NEEDS_VERIFICATION_LABEL],
)
client.remove_label(action.issue_number, HAS_LINKED_PR_LABEL)
marker = comment_marker(kind=action.kind, pr_number=action.pr_number)
if not has_marker(client.list_comments(action.issue_number), marker):
client.create_comment(action.issue_number, _merged_to_dev_comment(action))
return
if action.kind in {"closed_unmerged", "remove_linked_pr"}:
client.remove_label(action.issue_number, HAS_LINKED_PR_LABEL)
return
raise ValueError(f"Unsupported issue sync action: {action.kind}")
def classify_sync_target(client: GitHubClient, issue_number: int) -> str:
"""Classify a parsed ``#N`` reference before mutating it.
``#N`` is textual and shares one number space between issues and pull
requests, so a PR body saying "the main path was fixed in #535" parses as
an issue link even though 535 is a pull request. Labeling a PR needs a
scope this workflow does not hold (and is not the sync's job), so only
``"issue"`` targets are acted on; ``"pull_request"`` and ``"missing"``
are skipped by the caller.
"""
found = client.get_issue(issue_number)
if found is None:
return "missing"
if found.get("pull_request"):
return "pull_request"
return "issue"
def _load_event(path: str) -> dict[str, Any]:
with open(path, encoding="utf-8") as handle:
return json.load(handle)
def main() -> int:
event_path = os.environ.get("GITHUB_EVENT_PATH")
repository = os.environ.get("GITHUB_REPOSITORY")
token = os.environ.get("GITHUB_TOKEN")
if not event_path:
print("GITHUB_EVENT_PATH is required.", file=sys.stderr)
return 2
if not repository:
print("GITHUB_REPOSITORY is required.", file=sys.stderr)
return 2
if not token:
print("GITHUB_TOKEN is required.", file=sys.stderr)
return 2
event = _load_event(event_path)
actions = plan_issue_sync_actions(event)
if not actions:
print("No issue sync actions required.")
return 0
client = GitHubClient(token=token, repository=repository)
for action in actions:
target_kind = classify_sync_target(client, action.issue_number)
if target_kind != "issue":
print(
f"Skipping {action.kind} for #{action.issue_number} "
f"from PR #{action.pr_number}: target is {target_kind}."
)
continue
print(
f"Applying {action.kind} for issue #{action.issue_number} "
f"from PR #{action.pr_number}."
)
apply_action(client, action)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
set -euo pipefail
base_ref="${PR_BASE_REF:-${BASE_REF:-${BASE:-}}}"
event_path="${GITHUB_EVENT_PATH:-}"
if [[ -z "${base_ref}" ]]; then
{
echo "::error title=Missing PR target::PR_BASE_REF is required."
echo "Unable to validate the pull request target branch."
} >&2
exit 1
fi
if [[ "${base_ref}" == "main" ]]; then
echo "Pull request targets main."
exit 0
fi
event_label_names() {
if [[ -n "${PR_LABELS:-}" ]]; then
tr ',' '\n' <<< "${PR_LABELS}"
return
fi
if [[ -z "${event_path}" || ! -f "${event_path}" ]]; then
return
fi
local python_bin=""
if command -v python3 >/dev/null 2>&1; then
python_bin="python3"
elif command -v python >/dev/null 2>&1; then
python_bin="python"
fi
if [[ -n "${python_bin}" ]]; then
"${python_bin}" - "${event_path}" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as event_file:
event = json.load(event_file)
for label in event.get("pull_request", {}).get("labels", []):
name = label.get("name")
if name:
print(name)
PY
return
fi
if command -v jq >/dev/null 2>&1; then
jq -r '.pull_request.labels[]?.name // empty' "${event_path}"
fi
}
has_allowed_label() {
local allowed_kind="${1:?allowed kind is required}"
local label
while IFS= read -r label; do
case "${allowed_kind}:${label}" in
staging:maintainer-staging | staging:collaboration)
return 0
;;
esac
done < <(event_label_names)
return 1
}
is_staging_branch() {
case "${base_ref}" in
sandbox-* | integration/* | staging/* | release/* | hotfix/*)
return 0
;;
esac
return 1
}
if is_staging_branch || has_allowed_label staging; then
echo "Pull request targets a staging/collaboration branch."
echo "This is not a final integration path; final integration should target main."
exit 0
fi
{
echo "::error title=Wrong PR target::Ordinary pull requests should target main."
echo "Use sandbox-*, integration/*, staging/*, release/*, hotfix/*, or a maintainer-staging/collaboration label for maintainer collaboration PRs."
echo "Retarget this pull request to main or an approved staging/collaboration branch."
} >&2
exit 1
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Warn when a pull request body misses OpenSquilla governance fields."""
from __future__ import annotations
import json
import os
import re
from pathlib import Path
from typing import NamedTuple
class RequiredField(NamedTuple):
name: str
pattern: re.Pattern[str]
REQUIRED_FIELDS = (
RequiredField("Scope boundary", re.compile(r"(?im)^\s*Scope boundary\s*:")),
RequiredField("Base branch", re.compile(r"(?im)^\s*Base branch\s*:")),
RequiredField(
"Target exception",
re.compile(r"(?im)^\s*(?:Target exception|Main exception)\s*:"),
),
RequiredField("Linked issue", re.compile(r"(?im)^\s*Linked issue\s*:")),
RequiredField("Release note", re.compile(r"(?im)^\s*Release note\s*:")),
RequiredField("Tests", re.compile(r"(?im)^\s*(?:#+\s*)?Tests\s*:?\s*$")),
RequiredField("Maintainer live check", re.compile(r"(?im)^\s*Maintainer live check\s*:")),
RequiredField("Third-party origin", re.compile(r"(?im)^\s*Third-party origin\s*:")),
)
def missing_fields(body: str | None) -> list[str]:
text = body or ""
return [field.name for field in REQUIRED_FIELDS if field.pattern.search(text) is None]
def _annotation_escape(value: str) -> str:
return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
def _warning(title: str, message: str) -> None:
print(f"::warning title={_annotation_escape(title)}::{_annotation_escape(message)}")
def _is_strict() -> bool:
return os.environ.get("PR_BODY_LINT_STRICT", "0").strip().lower() in {
"1",
"true",
"yes",
}
def _load_pr_body(event_path: str) -> str | None:
with Path(event_path).open(encoding="utf-8") as event_file:
event = json.load(event_file)
return (event.get("pull_request") or {}).get("body")
def main() -> int:
strict = _is_strict()
event_path = os.environ.get("GITHUB_EVENT_PATH")
if not event_path:
_warning("Missing PR event", "GITHUB_EVENT_PATH is not set; skipping PR body lint.")
return 1 if strict else 0
try:
body = _load_pr_body(event_path)
except (OSError, json.JSONDecodeError) as exc:
_warning("Unreadable PR event", f"Unable to read pull request event: {exc}")
return 1 if strict else 0
missing = missing_fields(body)
if not missing:
print("PR body includes required governance fields.")
return 0
for field in missing:
_warning(
"Missing PR template field",
f"Add the `{field}:` field to make review intent explicit.",
)
print(
"PR body lint is warning-only. Maintainers may harden it by setting "
"PR_BODY_LINT_STRICT=1 after existing pull requests are migrated."
)
return 1 if strict else 0
if __name__ == "__main__":
raise SystemExit(main())
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "$#" -ne 2 ]]; then
echo "usage: $0 CANDIDATE_DMG LABEL" >&2
exit 2
fi
candidate_dmg="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
label="$2"
if [[ ! "${label}" =~ ^[A-Za-z0-9._-]{1,80}$ ]]; then
echo "label must contain only ASCII letters, digits, dot, underscore, or dash" >&2
exit 2
fi
sandbox="${RUNNER_TEMP}/opensquilla-release-preservation-${label}"
old_dir="${sandbox}/rc3"
old_mount="${sandbox}/rc3-mount"
candidate_mount="${sandbox}/candidate-mount"
install_root="${sandbox}/Applications"
user_data="${sandbox}/user-data/OpenSquilla"
profile="${user_data}/opensquilla"
probe="${GITHUB_WORKSPACE}/.github/scripts/verify-release-profile-preservation.py"
old_asset="OpenSquilla-0.5.0-rc3-mac-arm64.dmg"
mkdir -p "${old_dir}" "${old_mount}" "${candidate_mount}" "${install_root}" "${user_data}"
cleanup() {
hdiutil detach "${candidate_mount}" -quiet >/dev/null 2>&1 || true
hdiutil detach "${old_mount}" -quiet >/dev/null 2>&1 || true
if [[ -n "${app_pid:-}" ]]; then
kill "${app_pid}" >/dev/null 2>&1 || true
wait "${app_pid}" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
gh release download v0.5.0rc3 \
--repo opensquilla/opensquilla \
--pattern "${old_asset}" \
--dir "${old_dir}"
old_dmg="${old_dir}/${old_asset}"
test -f "${old_dmg}"
test -f "${candidate_dmg}"
hdiutil attach -nobrowse -readonly -mountpoint "${old_mount}" "${old_dmg}"
ditto "${old_mount}/OpenSquilla.app" "${install_root}/OpenSquilla.app"
hdiutil detach "${old_mount}" -quiet
python "${probe}" seed --home "${profile}" --label "${label}"
hdiutil attach -nobrowse -readonly -mountpoint "${candidate_mount}" "${candidate_dmg}"
mv "${install_root}/OpenSquilla.app" "${install_root}/OpenSquilla.rc3.app"
ditto "${candidate_mount}/OpenSquilla.app" "${install_root}/OpenSquilla.app"
hdiutil detach "${candidate_mount}" -quiet
python "${probe}" verify --home "${profile}" --label "${label}"
app_binary="${install_root}/OpenSquilla.app/Contents/MacOS/OpenSquilla"
test -x "${app_binary}"
OPENSQUILLA_DESKTOP_DISABLE_AUTO_UPDATE=1 \
"${app_binary}" --use-mock-keychain "--user-data-dir=${user_data}" \
>"${sandbox}/candidate-desktop.log" 2>&1 &
app_pid=$!
sleep 8
kill -0 "${app_pid}"
kill "${app_pid}" || true
wait "${app_pid}" || true
app_pid=""
gateway_binary="$(find \
"${install_root}/OpenSquilla.app/Contents/Resources/runtime/gateway" \
-type f -name opensquilla-gateway -perm -111 -print -quit)"
test -x "${gateway_binary}"
OPENSQUILLA_RECOVERY_OFFLINE=1 "${gateway_binary}" recovery inspect \
--home "${profile}" --json >"${sandbox}/candidate-inspect.json"
python - "${profile}" "${sandbox}/candidate-inspect.json" <<'PY'
import json
from pathlib import Path
import sys
home = Path(sys.argv[1]).resolve()
report = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8"))
assert report["outcome"] in {"ready", "attention"}, report
assert Path(report["primary_home"]).resolve() == home, report
assert Path(report["effective_workspace"]).resolve() == home / "workspace", report
configured_state = [
candidate
for candidate in report["candidates"]
if candidate["kind"] == "state" and candidate["configured"] and candidate["valid"]
]
assert len(configured_state) == 1, report
assert Path(configured_state[0]["path"]).resolve() == home / "state", report
PY
python "${probe}" verify --home "${profile}" --label "${label}"
python - "${install_root}/OpenSquilla.app" "${install_root}/OpenSquilla.rc3.app" <<'PY'
import shutil
import sys
for app_path in sys.argv[1:]:
shutil.rmtree(app_path)
PY
test ! -e "${install_root}/OpenSquilla.app"
test ! -e "${install_root}/OpenSquilla.rc3.app"
python "${probe}" verify --home "${profile}" --label "${label}"
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Seed and verify synthetic Desktop profile data around installer operations."""
from __future__ import annotations
import argparse
import json
import re
import sqlite3
import sys
from pathlib import Path
_LABEL_PATTERN = re.compile(r"[A-Za-z0-9._-]{1,80}")
def _validated_label(value: str) -> str:
if _LABEL_PATTERN.fullmatch(value) is None:
raise ValueError("label must contain only ASCII letters, digits, dot, underscore, or dash")
return value
def _workspace_files(label: str) -> dict[str, str]:
return {
"IDENTITY.md": f"# Synthetic {label} identity sentinel\n",
"USER.md": f"# Synthetic {label} user\n",
"SOUL.md": f"# Synthetic {label} soul\n",
"MEMORY.md": f"# Synthetic {label} memory\n",
}
def _config_text(home: Path, label: str) -> str:
return (
f"# Synthetic {label} release-preservation profile\n"
f"state_dir = {json.dumps(str(home / 'state'))}\n"
f"workspace_dir = {json.dumps(str(home / 'workspace'))}\n"
)
def seed_profile(home: Path, label: str) -> None:
"""Create a minimal synthetic RC3-shaped profile without replacing any file."""
home = home.resolve()
workspace = home / "workspace"
state = home / "state"
protected = [home / "config.toml", state / "sessions.db"] + [
workspace / name for name in _workspace_files(label)
]
existing = [path for path in protected if path.exists()]
if existing:
raise FileExistsError(f"refusing to overwrite preservation fixture: {existing[0]}")
workspace.mkdir(parents=True, exist_ok=True)
state.mkdir(parents=True, exist_ok=True)
for name, expected in _workspace_files(label).items():
(workspace / name).write_text(expected, encoding="utf-8", newline="")
(home / "config.toml").write_text(_config_text(home, label), encoding="utf-8", newline="")
with sqlite3.connect(state / "sessions.db") as connection:
connection.execute(
"CREATE TABLE release_preservation_chat (id TEXT PRIMARY KEY, body TEXT NOT NULL)"
)
connection.execute(
"INSERT INTO release_preservation_chat (id, body) VALUES (?, ?)",
(f"{label}-session", f"synthetic retained chat ({label})"),
)
result = connection.execute("PRAGMA quick_check").fetchone()
if result != ("ok",):
raise RuntimeError(f"seeded sessions.db failed PRAGMA quick_check: {result!r}")
def verify_profile(home: Path, label: str) -> None:
"""Verify exact fixture bytes and a read-only SQLite integrity probe."""
home = home.resolve()
workspace = home / "workspace"
state = home / "state"
for name, expected in _workspace_files(label).items():
actual = (workspace / name).read_text(encoding="utf-8")
if actual != expected:
raise AssertionError(f"{name} changed while installing or uninstalling Desktop")
actual_config = (home / "config.toml").read_text(encoding="utf-8")
if actual_config != _config_text(home, label):
raise AssertionError("config.toml changed while installing or uninstalling Desktop")
database = state / "sessions.db"
with sqlite3.connect(f"{database.as_uri()}?mode=ro", uri=True) as connection:
quick_check = connection.execute("PRAGMA quick_check").fetchone()
if quick_check != ("ok",):
raise AssertionError(f"sessions.db failed PRAGMA quick_check: {quick_check!r}")
row = connection.execute("SELECT id, body FROM release_preservation_chat").fetchone()
expected_row = (f"{label}-session", f"synthetic retained chat ({label})")
if row != expected_row:
raise AssertionError(f"sessions.db retained-chat row changed: {row!r}")
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser()
parser.add_argument("operation", choices=("seed", "verify"))
parser.add_argument("--home", type=Path, required=True)
parser.add_argument("--label", type=_validated_label, required=True)
return parser
def main() -> int:
args = _parser().parse_args()
try:
if args.operation == "seed":
seed_profile(args.home, args.label)
print(f"profile preservation fixture seeded: {args.home}")
else:
verify_profile(args.home, args.label)
print(f"profile preservation verified: {args.home}")
except (
AssertionError,
FileExistsError,
OSError,
RuntimeError,
sqlite3.Error,
ValueError,
) as exc:
print(str(exc), file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,126 @@
param(
[Parameter(Mandatory = $true)]
[string]$CandidateInstaller,
[Parameter(Mandatory = $true)]
[ValidatePattern('^[A-Za-z0-9._-]{1,80}$')]
[string]$Label
)
$ErrorActionPreference = 'Stop'
$repository = 'opensquilla/opensquilla'
$oldTag = 'v0.5.0rc3'
$oldAsset = 'OpenSquilla-0.5.0-rc3-win-x64.exe'
$candidate = (Resolve-Path -LiteralPath $CandidateInstaller).Path
$sandbox = Join-Path $env:RUNNER_TEMP "opensquilla-release-preservation-$Label"
$oldDir = Join-Path $sandbox 'rc3'
$installDir = Join-Path $sandbox 'OpenSquilla'
$appData = Join-Path $sandbox 'appdata'
$userData = Join-Path $appData 'OpenSquilla'
$profile = Join-Path $userData 'opensquilla'
$probe = Join-Path $PWD '.github\scripts\verify-release-profile-preservation.py'
$env:APPDATA = $appData
$env:OPENSQUILLA_DESKTOP_DISABLE_AUTO_UPDATE = '1'
$env:OPENSQUILLA_RECOVERY_OFFLINE = '1'
New-Item -ItemType Directory -Force -Path $oldDir, $appData | Out-Null
gh release download $oldTag --repo $repository --pattern $oldAsset --dir $oldDir
if ($LASTEXITCODE -ne 0) { throw 'Failed to download the RC3 Windows installer.' }
$oldInstaller = Join-Path $oldDir $oldAsset
function Stop-InstalledProcesses {
Get-Process -Name 'OpenSquilla', 'opensquilla-gateway' -ErrorAction SilentlyContinue |
ForEach-Object {
try {
$path = if ($_.Path) { [IO.Path]::GetFullPath($_.Path) } else { '' }
$prefix = [IO.Path]::GetFullPath($installDir + [IO.Path]::DirectorySeparatorChar)
if ($path.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) {
& taskkill.exe /PID $_.Id /T /F 2>$null | Out-Null
}
} catch {
if ($_.Exception.Message -notmatch 'exited|cannot find|No process') { throw }
}
}
}
try {
$old = Start-Process -FilePath $oldInstaller -ArgumentList @('/S', "/D=$installDir") `
-Wait -PassThru
if ($old.ExitCode -ne 0) { throw "RC3 installer failed with exit code $($old.ExitCode)." }
python $probe seed --home $profile --label $Label
if ($LASTEXITCODE -ne 0) { throw 'Failed to seed the synthetic RC3 profile.' }
$installed = Start-Process -FilePath $candidate -ArgumentList @('/S', "/D=$installDir") `
-Wait -PassThru
if ($installed.ExitCode -ne 0) {
throw "Candidate installer failed with exit code $($installed.ExitCode)."
}
python $probe verify --home $profile --label $Label
if ($LASTEXITCODE -ne 0) { throw 'Candidate installation changed RC3 profile data.' }
$app = Join-Path $installDir 'OpenSquilla.exe'
if (-not (Test-Path -LiteralPath $app -PathType Leaf)) {
throw 'Candidate installation did not publish OpenSquilla.exe.'
}
$launched = Start-Process -FilePath $app `
-ArgumentList @('--use-mock-keychain', "--user-data-dir=$userData") -PassThru
Start-Sleep -Seconds 8
if ($launched.HasExited) {
throw "Candidate Desktop exited during launch verification: $($launched.ExitCode)"
}
Stop-InstalledProcesses
$gateway = Get-ChildItem -Path (Join-Path $installDir 'resources\runtime\gateway') `
-Filter 'opensquilla-gateway.exe' -File -Recurse | Select-Object -First 1
if (-not $gateway) { throw 'Packaged recovery CLI was not found.' }
$inspectionRaw = & $gateway.FullName recovery inspect --home $profile --json
if ($LASTEXITCODE -ne 0) { throw 'Packaged recovery inspection failed.' }
$inspection = $inspectionRaw | ConvertFrom-Json
if ($inspection.outcome -notin @('ready', 'attention')) {
throw "Unsafe packaged profile inspection: $inspectionRaw"
}
if ([IO.Path]::GetFullPath($inspection.primary_home) -ne [IO.Path]::GetFullPath($profile)) {
throw 'Candidate selected a different primary profile after upgrade.'
}
if (
[IO.Path]::GetFullPath($inspection.effective_workspace) -ne
[IO.Path]::GetFullPath((Join-Path $profile 'workspace'))
) {
throw 'Candidate selected a different workspace after upgrade.'
}
$configuredState = @($inspection.candidates | Where-Object {
$_.kind -eq 'state' -and $_.configured -and $_.valid
})
if (
$configuredState.Count -ne 1 -or
[IO.Path]::GetFullPath($configuredState[0].path) -ne
[IO.Path]::GetFullPath((Join-Path $profile 'state'))
) {
throw 'Candidate selected a different state directory after upgrade.'
}
python $probe verify --home $profile --label $Label
if ($LASTEXITCODE -ne 0) { throw 'Candidate launch changed RC3 profile data.' }
$uninstaller = Get-ChildItem -LiteralPath $installDir -Filter 'Uninstall*.exe' -File |
Select-Object -First 1
if (-not $uninstaller) { throw 'Candidate Windows uninstaller was not found.' }
$uninstall = Start-Process -FilePath $uninstaller.FullName -ArgumentList @('/S') `
-Wait -PassThru
if ($uninstall.ExitCode -ne 0) {
throw "Candidate uninstaller failed with exit code $($uninstall.ExitCode)."
}
$deadline = [DateTime]::UtcNow.AddSeconds(30)
while (
(Test-Path -LiteralPath $app -PathType Leaf) -and
[DateTime]::UtcNow -lt $deadline
) {
Start-Sleep -Seconds 1
}
if (Test-Path -LiteralPath $app -PathType Leaf) {
throw 'Candidate uninstaller did not remove OpenSquilla.exe.'
}
python $probe verify --home $profile --label $Label
if ($LASTEXITCODE -ne 0) { throw 'Candidate uninstaller changed RC3 profile data.' }
} finally {
Stop-InstalledProcesses
}
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""Route offline pytest files into stable Windows CI responsibility shards."""
from __future__ import annotations
import argparse
import sys
import tomllib
import xml.etree.ElementTree as ET
from pathlib import Path, PurePosixPath
from typing import Final
SHARD_NAMES: Final[tuple[str, ...]] = (
"core",
"gateway-sqlite",
"recovery-migration",
"desktop-installer-contracts",
)
_GATEWAY_SQLITE_PREFIXES: Final[tuple[str, ...]] = (
"tests/test_gateway/",
"tests/test_health/",
"tests/test_observability/",
"tests/test_persistence/",
"tests/test_scheduler/",
"tests/test_search/",
"tests/test_session/",
)
_RECOVERY_MIGRATION_PREFIXES: Final[tuple[str, ...]] = (
"tests/test_migration/",
"tests/test_migrations/",
"tests/test_recovery/",
)
_DESKTOP_INSTALLER_PREFIXES: Final[tuple[str, ...]] = (
"tests/test_desktop/",
"tests/test_dist/",
"tests/test_packaging/",
"tests/test_uninstall/",
)
_GATEWAY_SQLITE_NAME_TOKENS: Final[tuple[str, ...]] = (
"database",
"gateway",
"memory",
"scheduler",
"session",
"sqlite",
)
_RECOVERY_MIGRATION_NAME_TOKENS: Final[tuple[str, ...]] = (
"legacy_config",
"migrate",
"migration",
"recovery",
)
_DESKTOP_INSTALLER_NAME_TOKENS: Final[tuple[str, ...]] = (
"artifact",
"desktop",
"install",
"release",
"uninstall",
"wheelhouse",
)
_DESKTOP_INSTALLER_EXACT: Final[frozenset[str]] = frozenset(
{
"tests/test_compose_yaml_shape.py",
"tests/test_root_start_scripts.py",
}
)
_CORE_EXACT: Final[frozenset[str]] = frozenset(
{
"tests/test_ci/test_router_artifact_manifest.py",
}
)
def discover_test_files(root: Path) -> tuple[str, ...]:
"""Return every pytest file below ``tests/`` as a repository-relative path."""
tests_root = root / "tests"
excluded = _pytest_excluded_prefixes(root)
relative_paths = (
path.relative_to(root).as_posix() for path in tests_root.rglob("test_*.py")
)
return tuple(
sorted(
relative
for relative in relative_paths
if not any(relative.startswith(prefix) for prefix in excluded)
)
)
def _pytest_excluded_prefixes(root: Path) -> tuple[str, ...]:
pyproject = root / "pyproject.toml"
try:
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
except (OSError, tomllib.TOMLDecodeError) as exc:
raise ValueError(f"cannot read pytest collection contract from {pyproject}") from exc
configured = data.get("tool", {}).get("pytest", {}).get("ini_options", {})
return tuple(
f"{PurePosixPath(path).as_posix().rstrip('/')}/"
for path in configured.get("norecursedirs", ())
if PurePosixPath(path).as_posix().startswith("tests/")
)
def matching_specialized_shards(path: str) -> tuple[str, ...]:
"""Return specialized shards whose responsibility rules match ``path``."""
normalized = PurePosixPath(path).as_posix()
if normalized in _CORE_EXACT:
return ()
name = PurePosixPath(normalized).name.casefold()
prefix_matches: list[str] = []
if normalized.startswith(_GATEWAY_SQLITE_PREFIXES):
prefix_matches.append("gateway-sqlite")
if normalized.startswith(_RECOVERY_MIGRATION_PREFIXES):
prefix_matches.append("recovery-migration")
if normalized.startswith(_DESKTOP_INSTALLER_PREFIXES):
prefix_matches.append("desktop-installer-contracts")
if prefix_matches:
return tuple(prefix_matches)
matches: list[str] = []
if any(token in name for token in _GATEWAY_SQLITE_NAME_TOKENS):
matches.append("gateway-sqlite")
if any(token in name for token in _RECOVERY_MIGRATION_NAME_TOKENS):
matches.append("recovery-migration")
if normalized in _DESKTOP_INSTALLER_EXACT or any(
token in name for token in _DESKTOP_INSTALLER_NAME_TOKENS
):
matches.append("desktop-installer-contracts")
return tuple(matches)
def shard_for_test(path: str) -> str:
"""Return the one responsibility shard for ``path`` or fail on ambiguity."""
matches = matching_specialized_shards(path)
if len(matches) > 1:
joined = ", ".join(matches)
raise ValueError(f"test file matches multiple Windows shards: {path} ({joined})")
return matches[0] if matches else "core"
def files_for_shard(root: Path, shard: str) -> tuple[str, ...]:
if shard not in SHARD_NAMES:
raise ValueError(f"unknown Windows shard: {shard}")
return tuple(path for path in discover_test_files(root) if shard_for_test(path) == shard)
def _write_failure_summary(junit_path: Path, summary_path: Path, exit_code: int) -> None:
summary_path.parent.mkdir(parents=True, exist_ok=True)
lines = [f"pytest_exit_code={exit_code}"]
if not junit_path.is_file():
lines.append("junit_status=unavailable")
summary_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return
try:
root = ET.parse(junit_path).getroot()
except (ET.ParseError, OSError) as exc:
lines.extend(("junit_status=unreadable", f"detail={type(exc).__name__}"))
summary_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return
for testcase in root.iter("testcase"):
failure = testcase.find("failure")
if failure is None:
failure = testcase.find("error")
if failure is None:
continue
class_name = testcase.get("classname", "")
test_name = testcase.get("name", "unknown")
node = f"{class_name}::{test_name}" if class_name else test_name
detail = (failure.text or failure.get("message") or "failure details unavailable").strip()
lines.extend(
(
"junit_status=failed",
f"first_failure={node}",
"detail:",
detail[:12_000],
)
)
break
else:
lines.append("junit_status=passed" if exit_code == 0 else "junit_status=no-test-failure")
summary_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _run(args: argparse.Namespace) -> int:
import pytest
root = args.root.resolve()
files = files_for_shard(root, args.shard)
if not files:
print(f"Windows shard {args.shard!r} has no tests", file=sys.stderr)
return 2
args.junit.parent.mkdir(parents=True, exist_ok=True)
args.summary.parent.mkdir(parents=True, exist_ok=True)
args.summary.write_text("pytest_status=started\n", encoding="utf-8")
pytest_args = list(args.pytest_args)
if pytest_args[:1] == ["--"]:
pytest_args = pytest_args[1:]
pytest_args.extend(str(root / path) for path in files)
pytest_args.append(f"--junitxml={args.junit}")
print(f"Running {len(files)} test files in Windows shard {args.shard}")
exit_code = int(pytest.main(pytest_args))
_write_failure_summary(args.junit, args.summary, exit_code)
return exit_code
def _list(args: argparse.Namespace) -> int:
for path in files_for_shard(args.root.resolve(), args.shard):
print(path)
return 0
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
list_parser = subparsers.add_parser("list", help="list files assigned to one shard")
list_parser.add_argument("shard", choices=SHARD_NAMES)
list_parser.add_argument("--root", type=Path, default=Path.cwd())
list_parser.set_defaults(handler=_list)
run_parser = subparsers.add_parser("run", help="run one shard through pytest")
run_parser.add_argument("shard", choices=SHARD_NAMES)
run_parser.add_argument("--root", type=Path, default=Path.cwd())
run_parser.add_argument("--junit", type=Path, required=True)
run_parser.add_argument("--summary", type=Path, required=True)
run_parser.set_defaults(handler=_run)
return parser
def main() -> int:
parser = _parser()
args, pytest_args = parser.parse_known_args()
if args.command != "run" and pytest_args:
parser.error(f"unrecognized arguments: {' '.join(pytest_args)}")
args.pytest_args = pytest_args
return int(args.handler(args))
if __name__ == "__main__":
raise SystemExit(main())
+876
View File
@@ -0,0 +1,876 @@
name: CI
"on":
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
OPENSQUILLA_TESTING: "true"
jobs:
classify-changes:
name: Classify changed files
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
docs_only: ${{ steps.classify.outputs.docs_only }}
runtime_changed: ${{ steps.classify.outputs.runtime_changed }}
test_changed: ${{ steps.classify.outputs.test_changed }}
ci_changed: ${{ steps.classify.outputs.ci_changed }}
dependency_changed: ${{ steps.classify.outputs.dependency_changed }}
release_changed: ${{ steps.classify.outputs.release_changed }}
windows_full_required: ${{ steps.classify.outputs.windows_full_required }}
frontend_changed: ${{ steps.classify.outputs.frontend_changed }}
tui_changed: ${{ steps.classify.outputs.tui_changed }}
desktop_changed: ${{ steps.classify.outputs.desktop_changed }}
python_changed: ${{ steps.classify.outputs.python_changed }}
platform_sensitive_changed: ${{ steps.classify.outputs.platform_sensitive_changed }}
build_wheel_required: ${{ steps.classify.outputs.build_wheel_required }}
full_required: ${{ steps.classify.outputs.full_required }}
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: List changed files
shell: bash
run: |
set -euo pipefail
changed_files="${RUNNER_TEMP}/changed-files.txt"
case "${{ github.event_name }}" in
pull_request)
git diff --name-only \
"${{ github.event.pull_request.base.sha }}" \
"${{ github.event.pull_request.head.sha }}" > "${changed_files}"
;;
push)
before="${{ github.event.before }}"
after="${{ github.event.after }}"
if [[ -n "${before}" && "${before}" != "0000000000000000000000000000000000000000" ]]; then
git diff --name-only "${before}" "${after}" > "${changed_files}"
else
printf '.ci/run-all\n' > "${changed_files}"
fi
;;
*)
printf '.ci/run-all\n' > "${changed_files}"
;;
esac
echo "Changed files:"
sed 's/^/ /' "${changed_files}"
printf 'CHANGED_FILES=%s\n' "${changed_files}" >> "${GITHUB_ENV}"
- name: Classify changed files
id: classify
shell: bash
run: bash .github/scripts/classify-ci-changes.sh "${CHANGED_FILES}"
workflow-lint:
name: Validate GitHub Actions workflows
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Run actionlint
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 -color=false
frontend-check:
name: Frontend build and typecheck
needs: classify-changes
if: ${{ needs.classify-changes.outputs.frontend_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20.19.0'
cache: 'npm'
cache-dependency-path: opensquilla-webui/package-lock.json
- name: Install frontend dependencies
working-directory: opensquilla-webui
run: npm ci
- name: Run frontend unit tests
working-directory: opensquilla-webui
run: npm run test:unit
- name: Build frontend
working-directory: opensquilla-webui
run: npm run build
- name: Verify committed dist is fresh
run: |
if [ ! -f "src/opensquilla/gateway/static/dist/index.html" ]; then
echo "ERROR: Frontend dist not found"
exit 1
fi
# The build above regenerates src/opensquilla/gateway/static/dist. If
# the committed dist differs from a fresh build, a Web UI source change
# was made without rebuilding + committing the bundle it ships (the
# gateway serves the committed dist, not source). Fail so a stale
# bundle can never ship (e.g. an approval-button removal that only
# lands in source — the #413 residue).
if ! git diff --quiet -- src/opensquilla/gateway/static/dist; then
echo "ERROR: committed Web UI dist is stale — rebuild and commit it:"
echo " cd opensquilla-webui && npm ci && npm run build"
echo "Changed dist files:"
git --no-pager diff --stat -- src/opensquilla/gateway/static/dist
exit 1
fi
echo "Frontend dist verified fresh"
tui-check:
name: OpenTUI package tests
needs: classify-changes
if: ${{ needs.classify-changes.outputs.tui_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20.19.0'
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install OpenTUI host dependencies
working-directory: src/opensquilla/cli/tui/opentui/package
shell: bash
run: bun install --frozen-lockfile
- name: Run OpenTUI Node tests
working-directory: src/opensquilla/cli/tui/opentui/package
run: npm run test
- name: Run OpenTUI Bun tests
working-directory: src/opensquilla/cli/tui/opentui/package
run: bun run test:bun
desktop-check:
name: Desktop Electron unit tests
needs: classify-changes
if: ${{ needs.classify-changes.outputs.desktop_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 10
env:
# The pure-logic unit tests run against the compiled dist and never launch
# Electron, so skip the heavy Electron/Playwright binary downloads.
ELECTRON_SKIP_BINARY_DOWNLOAD: '1'
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20.19.0'
cache: 'npm'
cache-dependency-path: desktop/electron/package-lock.json
- name: Install desktop dependencies
working-directory: desktop/electron
run: npm ci
- name: Build desktop TypeScript
working-directory: desktop/electron
run: npm run build
- name: Run desktop unit tests
working-directory: desktop/electron
run: |
node scripts/test-secret-storage-policy.mjs
node scripts/test-update-resolver.mjs
node scripts/test-desktop-locale.mjs
node scripts/test-desktop-profile-substrate.mjs
node scripts/test-desktop-profile-context.mjs
node scripts/test-desktop-cleanup-contract.mjs
ubuntu-quality:
name: Lint, test, and build (ubuntu-latest, 3.12)
needs: classify-changes
if: ${{ needs.classify-changes.outputs.python_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
env:
UV_PYTHON: "3.12"
PYTHONPATH: ${{ github.workspace }}
OPENSQUILLA_TURN_CALL_LOG: "0"
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
lfs: ${{ needs.classify-changes.outputs.full_required == 'true' }}
- name: Configure runtime directories
shell: bash
run: |
printf 'OPENSQUILLA_STATE_DIR=%s/opensquilla-state\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Install dependencies
shell: bash
run: uv sync --extra dev --extra recommended --extra mcp --frozen
- name: Lint
shell: bash
run: uv run ruff check src tests
- name: Type check
shell: bash
run: uv run mypy src/opensquilla --show-error-codes
- name: Test
shell: bash
run: |
set -euo pipefail
markers="not llm and not live_search and not live_channel and not webui_browser and not tui_real_terminal and not local_golden and not agent_context_boundary and not llm_router_acc"
if [[ "${{ needs.classify-changes.outputs.full_required }}" == "true" ]]; then
uv run pytest tests -q -m "${markers}" --durations=50
else
uv run pytest \
tests/unit \
tests/test_ci \
--ignore=tests/test_ci/test_router_artifact_manifest.py \
tests/test_desktop/test_electron_startup_contract.py \
tests/test_recovery \
tests/test_migration/test_opensquilla_home_migration.py \
tests/test_engine/test_coding_mode.py \
tests/test_engine/test_stream_wrappers.py \
tests/test_tools/test_shell_process_isolation.py \
-q -m "${markers}" --durations=50
fi
- name: Verify metric counter names unchanged
shell: bash
run: |
set -e
for name in opensquilla_queue_depth in_flight_turns_total turn_cancellations_total queue_full_errors_total; do
count=$(grep -c "$name" src/opensquilla/gateway/task_runtime.py || echo 0)
if [ "$count" -lt 1 ]; then
echo "ERROR: metric name '$name' missing from task_runtime.py"
exit 1
fi
done
- name: tracemalloc leak smoke test
if: ${{ needs.classify-changes.outputs.full_required == 'true' }}
shell: bash
run: |
uv run python -m pytest tests/test_gateway/test_task_runtime_terminal_cleanup.py::test_no_leak_under_load -v
- name: Build wheel
if: ${{ needs.classify-changes.outputs.build_wheel_required == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
shell: bash
run: uv build --wheel
windows-compat:
name: Windows compatibility smoke (3.12)
needs: classify-changes
if: ${{ needs.classify-changes.outputs.python_changed == 'true' || needs.classify-changes.outputs.platform_sensitive_changed == 'true' || needs.classify-changes.outputs.dependency_changed == 'true' || needs.classify-changes.outputs.release_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
runs-on: windows-latest
timeout-minutes: 20
env:
UV_PYTHON: "3.12"
PYTHONPATH: ${{ github.workspace }}
OPENSQUILLA_TURN_CALL_LOG: "0"
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Configure runtime directories
shell: bash
run: |
printf 'OPENSQUILLA_STATE_DIR=%s/opensquilla-state\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Install dependencies
shell: bash
run: uv sync --extra dev --extra recommended --extra mcp --frozen
- name: Test Windows compatibility smoke
shell: bash
run: |
uv run pytest \
tests/test_artifacts.py \
tests/test_contrib/test_codetask \
tests/test_desktop/test_electron_startup_contract.py \
tests/test_engine/test_coding_mode.py \
tests/test_engine/test_stream_wrappers.py \
tests/test_tools/test_shell_process_isolation.py \
-q
windows-full:
name: Windows high-risk (${{ matrix.shard }})
needs: classify-changes
if: ${{ needs.classify-changes.outputs.windows_full_required == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
runs-on: windows-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
shard:
- core
- gateway-sqlite
- recovery-migration
- desktop-installer-contracts
env:
UV_PYTHON: "3.12"
PYTHONPATH: ${{ github.workspace }}
OPENSQUILLA_TURN_CALL_LOG: "0"
steps:
- name: Prepare diagnostic report
shell: bash
run: |
set -euo pipefail
report_dir="${RUNNER_TEMP}/ci-reports/${{ matrix.shard }}"
mkdir -p "${report_dir}"
printf 'CI_REPORT_DIR=%s\n' "${report_dir}" >> "${GITHUB_ENV}"
{
printf 'runner_os=%s\n' "${RUNNER_OS}"
printf 'runner_arch=%s\n' "${RUNNER_ARCH}"
printf 'commit_sha=%s\n' "${GITHUB_SHA}"
printf 'run_attempt=%s\n' "${GITHUB_RUN_ATTEMPT}"
printf 'shard=%s\n' "${{ matrix.shard }}"
} > "${report_dir}/environment.txt"
printf 'pytest_status=not_started\n' > "${report_dir}/first-failure.txt"
- name: Check out repository
uses: actions/checkout@v4
with:
lfs: ${{ matrix.shard == 'core' }}
- name: Configure runtime directories
shell: bash
run: |
printf 'OPENSQUILLA_STATE_DIR=%s/opensquilla-state\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Bun
if: ${{ matrix.shard == 'core' }}
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install OpenTUI host dependencies
if: ${{ matrix.shard == 'core' }}
shell: bash
run: bun install --frozen-lockfile --cwd=src/opensquilla/cli/tui/opentui/package
- name: Install dependencies
shell: bash
run: uv sync --extra dev --extra recommended --extra mcp --frozen
- name: Record tool versions
shell: bash
run: |
set -euo pipefail
{
python --version
uv --version
uv run pytest --version
} >> "${CI_REPORT_DIR}/environment.txt" 2>&1
- name: Provision distinct Windows test volumes
if: ${{ matrix.shard == 'recovery-migration' }}
shell: pwsh
run: |
$token = [guid]::NewGuid().ToString("N")
$volumeA = Join-Path -Path $env:RUNNER_TEMP -ChildPath "opensquilla-windows-volume-a-$token"
$volumeB = Join-Path -Path $env:LOCALAPPDATA -ChildPath "opensquilla-windows-volume-b-$token"
if (Test-Path -LiteralPath $volumeA) {
throw "Windows test volume root already exists: $volumeA"
}
if (Test-Path -LiteralPath $volumeB) {
throw "Windows test volume root already exists: $volumeB"
}
$driveA = [System.IO.Path]::GetPathRoot($volumeA)
$driveB = [System.IO.Path]::GetPathRoot($volumeB)
$systemDriveRoot = [System.IO.Path]::GetPathRoot("$($env:SystemDrive)\")
if (-not [string]::Equals($driveB, $systemDriveRoot, [StringComparison]::OrdinalIgnoreCase)) {
throw "Windows test volume B must use SystemDrive"
}
if ([string]::Equals($driveA, $driveB, [StringComparison]::OrdinalIgnoreCase)) {
throw "Windows test volume roots must use different drives"
}
$createdRoots = [System.Collections.Generic.List[string]]::new()
try {
New-Item -ItemType Directory -Path $volumeA -ErrorAction Stop | Out-Null
$createdRoots.Add($volumeA)
New-Item -ItemType Directory -Path $volumeB -ErrorAction Stop | Out-Null
$createdRoots.Add($volumeB)
"OPENSQUILLA_WINDOWS_TEST_VOLUME_A=$volumeA" |
Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"OPENSQUILLA_WINDOWS_TEST_VOLUME_B=$volumeB" |
Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
} catch {
foreach ($testRoot in $createdRoots) {
if (Test-Path -LiteralPath $testRoot) {
Remove-Item -LiteralPath $testRoot -Recurse -Force
}
}
throw
}
- name: Test Windows native recovery move primitives first
if: ${{ matrix.shard == 'recovery-migration' }}
shell: bash
run: |
set -euo pipefail
maxfail_args=()
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
maxfail_args+=(--maxfail=3)
fi
set +e
uv run pytest \
tests/test_recovery/test_atomic_and_locking.py::test_native_move_moves_a_regular_tree_between_real_parents \
tests/test_recovery/test_atomic_and_locking.py::test_windows_real_legacy_lock_survives_profile_move_and_rebind \
tests/test_recovery/test_atomic_and_locking.py::test_windows_real_replacement_locks_survive_two_profile_moves \
tests/test_recovery/test_atomic_and_locking.py::test_windows_real_recent_locked_profile_tree_moves_without_metadata_false_positive \
tests/test_recovery/test_atomic_and_locking.py::test_windows_primitive_collision_preserves_both_trees \
tests/test_recovery/test_atomic_and_locking.py::test_windows_native_move_refuses_real_cross_volume_move \
tests/test_recovery/test_atomic_and_locking.py::test_windows_native_move_handles_real_path_longer_than_260_characters \
tests/test_recovery/test_atomic_and_locking.py::test_windows_native_move_rejects_real_junction_in_source_tree \
tests/test_recovery/test_atomic_and_locking.py::test_windows_no_replace_pins_both_parents_during_real_mutation_window \
-q \
--durations=25 \
--junitxml="${CI_REPORT_DIR}/native-move-junit.xml" \
"${maxfail_args[@]}" \
2>&1 | tee "${CI_REPORT_DIR}/native-move.log"
status=${PIPESTATUS[0]}
set -e
if [[ "${status}" -ne 0 ]]; then
printf 'native_move_exit_code=%s\n' "${status}" \
> "${CI_REPORT_DIR}/first-failure.txt"
exit "${status}"
fi
- name: Test Windows shard
shell: bash
run: |
set -euo pipefail
markers="not llm and not live_search and not live_channel and not webui_browser and not tui_real_terminal and not local_golden and not agent_context_boundary and not llm_router_acc"
maxfail_args=()
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
maxfail_args+=(--maxfail=3)
fi
uv run python .github/scripts/windows_test_shards.py run \
"${{ matrix.shard }}" \
--junit "${CI_REPORT_DIR}/junit.xml" \
--summary "${CI_REPORT_DIR}/first-failure.txt" \
-- \
-q \
-m "${markers}" \
--durations=50 \
"${maxfail_args[@]}" \
2>&1 | tee "${CI_REPORT_DIR}/pytest.log"
- name: Clean up Windows test volumes
if: ${{ always() && matrix.shard == 'recovery-migration' }}
shell: pwsh
run: |
$testRoots = @(
$env:OPENSQUILLA_WINDOWS_TEST_VOLUME_A,
$env:OPENSQUILLA_WINDOWS_TEST_VOLUME_B
)
foreach ($testRoot in $testRoots) {
if ($testRoot -and (Test-Path -LiteralPath $testRoot)) {
Remove-Item -LiteralPath $testRoot -Recurse -Force
}
}
- name: Upload Windows shard report
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: windows-high-risk-${{ matrix.shard }}-attempt-${{ github.run_attempt }}
path: ${{ runner.temp }}/ci-reports/${{ matrix.shard }}
if-no-files-found: error
retention-days: 14
macos-recovery:
name: macOS profile recovery and native no-replace (3.12)
needs: classify-changes
if: ${{ needs.classify-changes.outputs.platform_sensitive_changed == 'true' || needs.classify-changes.outputs.desktop_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
runs-on: macos-latest
timeout-minutes: 30
env:
UV_PYTHON: "3.12"
PYTHONPATH: ${{ github.workspace }}
OPENSQUILLA_TURN_CALL_LOG: "0"
steps:
- name: Prepare macOS recovery report
shell: bash
run: |
set -euo pipefail
report_dir="${RUNNER_TEMP}/macos-recovery"
mkdir -p "${report_dir}"
printf 'CI_REPORT_DIR=%s\n' "${report_dir}" >> "${GITHUB_ENV}"
{
printf 'runner_os=%s\n' "${RUNNER_OS}"
printf 'runner_arch=%s\n' "${RUNNER_ARCH}"
printf 'commit_sha=%s\n' "${GITHUB_SHA}"
printf 'run_attempt=%s\n' "${GITHUB_RUN_ATTEMPT}"
} > "${report_dir}/environment.txt"
printf 'pytest_status=not_started\n' > "${report_dir}/first-failure.txt"
- name: Check out repository
uses: actions/checkout@v4
- name: Configure runtime directories
shell: bash
run: |
printf 'OPENSQUILLA_STATE_DIR=%s/opensquilla-state\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Install dependencies
shell: bash
run: uv sync --extra dev --extra recommended --extra mcp --frozen
- name: Test native profile recovery contracts
shell: bash
run: |
set -euo pipefail
maxfail_args=()
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
maxfail_args+=(--maxfail=3)
fi
set +e
uv run pytest \
tests/test_recovery \
tests/test_migration/test_opensquilla_home_migration.py \
tests/test_desktop/test_electron_startup_contract.py \
-q \
--durations=50 \
--junitxml="${CI_REPORT_DIR}/junit.xml" \
"${maxfail_args[@]}" \
2>&1 | tee "${CI_REPORT_DIR}/pytest.log"
status=${PIPESTATUS[0]}
set -e
printf 'pytest_exit_code=%s\n' "${status}" > "${CI_REPORT_DIR}/first-failure.txt"
exit "${status}"
- name: Upload macOS recovery report
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: macos-recovery-attempt-${{ github.run_attempt }}
path: ${{ runner.temp }}/macos-recovery
if-no-files-found: error
retention-days: 14
desktop-recovery-e2e:
name: Desktop recovery E2E (${{ matrix.os }})
needs: classify-changes
if: ${{ needs.classify-changes.outputs.platform_sensitive_changed == 'true' || needs.classify-changes.outputs.desktop_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 45
env:
UV_PYTHON: "3.12"
PYTHONPATH: ${{ github.workspace }}
OPENSQUILLA_TURN_CALL_LOG: "0"
steps:
- name: Prepare Desktop recovery report
shell: bash
run: |
set -euo pipefail
report_dir="${RUNNER_TEMP}/desktop-recovery-e2e"
mkdir -p "${report_dir}"
printf 'CI_REPORT_DIR=%s\n' "${report_dir}" >> "${GITHUB_ENV}"
{
printf 'runner_os=%s\n' "${RUNNER_OS}"
printf 'runner_arch=%s\n' "${RUNNER_ARCH}"
printf 'commit_sha=%s\n' "${GITHUB_SHA}"
printf 'run_attempt=%s\n' "${GITHUB_RUN_ATTEMPT}"
} > "${report_dir}/environment.txt"
printf 'e2e_status=not_started\n' > "${report_dir}/first-failure.txt"
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20.19.0'
cache: 'npm'
cache-dependency-path: desktop/electron/package-lock.json
- name: Install Python dependencies
shell: bash
run: uv sync --extra dev --extra recommended --extra mcp --frozen
- name: Install Desktop dependencies
working-directory: desktop/electron
shell: bash
run: npm ci
- name: Record Desktop recovery tool versions
shell: bash
run: |
set -euo pipefail
{
python --version
uv --version
node --version
npm --version
} >> "${CI_REPORT_DIR}/environment.txt" 2>&1
- name: Build Desktop TypeScript
working-directory: desktop/electron
shell: bash
run: npm run build
- name: Run compiled Desktop recovery flows
working-directory: desktop/electron
shell: bash
env:
OPENSQUILLA_DESKTOP_RECOVERY_SCREENSHOT: ${{ runner.temp }}/desktop-recovery-e2e/recovery-page.png
run: |
set -euo pipefail
run_case() {
local name="$1"
local script="$2"
if [[ "${RUNNER_OS}" == "Linux" ]]; then
xvfb-run -a node "${script}" 2>&1 | tee "${CI_REPORT_DIR}/${name}.log"
else
node "${script}" 2>&1 | tee "${CI_REPORT_DIR}/${name}.log"
fi
}
for entry in \
'profile-recovery-flow:scripts/test-profile-recovery-flow.mjs' \
'profile-recovery-accessibility:scripts/test-profile-recovery-accessibility.mjs' \
'profile-import-flow:scripts/test-profile-import-flow.mjs' \
'unsafe-profile-no-write:scripts/test-unsafe-profile-no-write.mjs'
do
name="${entry%%:*}"
script="${entry#*:}"
if ! run_case "${name}" "${script}"; then
printf 'failed_case=%s\n' "${name}" > "${CI_REPORT_DIR}/first-failure.txt"
exit 1
fi
done
printf 'e2e_status=passed\n' > "${CI_REPORT_DIR}/first-failure.txt"
- name: Upload Desktop recovery report
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: desktop-recovery-e2e-${{ matrix.os }}-attempt-${{ github.run_attempt }}
path: ${{ runner.temp }}/desktop-recovery-e2e
if-no-files-found: error
retention-days: 14
release-packaging:
name: Release packaging contracts
needs: classify-changes
if: ${{ needs.classify-changes.outputs.release_changed == 'true' || needs.classify-changes.outputs.full_required == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 20
env:
UV_PYTHON: "3.12"
PYTHONPATH: ${{ github.workspace }}
OPENSQUILLA_TURN_CALL_LOG: "0"
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
lfs: true
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Install dependencies
shell: bash
run: uv sync --extra dev --extra recommended --extra mcp --frozen
- name: Run release packaging contract tests
shell: bash
run: |
uv run pytest \
tests/test_scripts/test_build_wheelhouse_zip.py \
tests/test_install_scripts.py \
tests/test_root_start_scripts.py \
tests/test_release_consistency.py \
tests/test_public_release_hygiene.py \
-q
readme-locale-check:
name: README locale parity
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20.19.0'
- name: Check README locale parity
working-directory: opensquilla-webui
run: node scripts/check-readme-locales.mjs
ci-result:
name: CI result
needs:
- classify-changes
- workflow-lint
- readme-locale-check
- frontend-check
- tui-check
- desktop-check
- ubuntu-quality
- windows-compat
- windows-full
- macos-recovery
- desktop-recovery-e2e
- release-packaging
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Check required CI results
env:
RESULT_CLASSIFY: ${{ needs.classify-changes.result }}
RESULT_WORKFLOW_LINT: ${{ needs.workflow-lint.result }}
RESULT_README_LOCALE: ${{ needs.readme-locale-check.result }}
RESULT_FRONTEND: ${{ needs.frontend-check.result }}
RESULT_TUI: ${{ needs.tui-check.result }}
RESULT_DESKTOP: ${{ needs.desktop-check.result }}
RESULT_UBUNTU: ${{ needs.ubuntu-quality.result }}
RESULT_WINDOWS_SMOKE: ${{ needs.windows-compat.result }}
RESULT_WINDOWS_FULL: ${{ needs.windows-full.result }}
RESULT_MACOS_RECOVERY: ${{ needs.macos-recovery.result }}
RESULT_DESKTOP_RECOVERY_E2E: ${{ needs.desktop-recovery-e2e.result }}
RESULT_RELEASE: ${{ needs.release-packaging.result }}
FLAG_DOCS_ONLY: ${{ needs.classify-changes.outputs.docs_only }}
FLAG_RUNTIME_CHANGED: ${{ needs.classify-changes.outputs.runtime_changed }}
FLAG_TEST_CHANGED: ${{ needs.classify-changes.outputs.test_changed }}
FLAG_CI_CHANGED: ${{ needs.classify-changes.outputs.ci_changed }}
FLAG_DEPENDENCY_CHANGED: ${{ needs.classify-changes.outputs.dependency_changed }}
FLAG_RELEASE_CHANGED: ${{ needs.classify-changes.outputs.release_changed }}
FLAG_WINDOWS_FULL_REQUIRED: ${{ needs.classify-changes.outputs.windows_full_required }}
FLAG_FRONTEND_CHANGED: ${{ needs.classify-changes.outputs.frontend_changed }}
FLAG_TUI_CHANGED: ${{ needs.classify-changes.outputs.tui_changed }}
FLAG_DESKTOP_CHANGED: ${{ needs.classify-changes.outputs.desktop_changed }}
FLAG_PYTHON_CHANGED: ${{ needs.classify-changes.outputs.python_changed }}
FLAG_PLATFORM_SENSITIVE_CHANGED: ${{ needs.classify-changes.outputs.platform_sensitive_changed }}
FLAG_BUILD_WHEEL_REQUIRED: ${{ needs.classify-changes.outputs.build_wheel_required }}
FLAG_FULL_REQUIRED: ${{ needs.classify-changes.outputs.full_required }}
run: python .github/scripts/check_ci_results.py
+234
View File
@@ -0,0 +1,234 @@
name: Container Images
# Builds the multi-arch (linux/amd64 + linux/arm64) gateway image and
# publishes it to GHCR so home-server/NAS users can `docker pull` a release
# instead of building from a source checkout. See docs/docker.md.
#
# Publishing model:
# * Pushing a release tag (v0.5.0rc3, v0.6.0, ...) first publishes the
# immutable ghcr.io/<owner>/<repo>:<tag> image. The workflow verifies its
# amd64/arm64 manifest and container HEALTHCHECK before moving :latest.
# :latest tracks the most recently pushed release tag — including
# prereleases and backports; if a backport repoints it, re-run this
# workflow from the newest tag.
# * workflow_dispatch validates the full multi-arch build without
# publishing; set publish=true to push the result as :edge.
#
# The GITHUB_TOKEN with job-level `packages: write` is the only credential.
# First-time note for maintainers: a new GHCR package is created private —
# flip ghcr.io/<owner>/<repo> to public in the package settings once.
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
publish:
description: Push the built image to GHCR as the edge tag (leave off to only validate the build)
type: boolean
required: false
default: false
permissions:
contents: read
concurrency:
group: container-images-${{ github.ref }}
cancel-in-progress: false
jobs:
build-and-publish:
name: Build multi-arch gateway image
runs-on: ubuntu-latest
# The arm64 leg runs under QEMU emulation. All compiled dependencies
# install from prebuilt aarch64 wheels, so this is emulated pip time,
# not source builds — slow but bounded.
timeout-minutes: 120
permissions:
contents: read
packages: write
steps:
- name: Validate release tag
if: github.event_name == 'push'
run: |
if [[ ! "${GITHUB_REF_NAME}" =~ ^v[0-9]+[.][0-9]+[.][0-9]+.*$ ]]; then
echo "Release tag must look like v0.2.0rc1 or v0.2.0: ${GITHUB_REF_NAME}" >&2
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
with:
lfs: true
persist-credentials: false
- name: Check tag version
if: github.event_name == 'push'
run: |
version="$(python3 - <<'PY'
import tomllib
from pathlib import Path
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
print(project["project"]["version"])
PY
)"
tag_version="${GITHUB_REF_NAME#v}"
if [[ "${tag_version}" != "${version}" ]]; then
echo "Tag ${GITHUB_REF_NAME} does not match project version ${version}" >&2
exit 1
fi
- name: Hydrate router assets
run: git lfs pull --include="src/opensquilla/squilla_router/models/**"
# The Dockerfile hard-fails on LFS pointer files; failing here first
# gives a clearer error than a mid-build SystemExit.
- name: Verify router assets are hydrated
run: |
python3 - <<'PY'
from pathlib import Path
root = Path("src/opensquilla/squilla_router/models/v4.2_phase3_inference")
required = [
root / "PROVENANCE.md",
root / "artifact_manifest.json",
root / "bge_onnx" / "model.onnx",
root / "features" / "tfidf.pkl",
root / "lgbm_main.bin",
root / "mlp" / "model.onnx",
root / "router.runtime.yaml",
]
for path in required:
assert path.is_file(), f"missing router asset: {path}"
prefix = path.read_bytes()[:80]
assert not prefix.startswith(b"version https://git-lfs.github.com/spec/v1"), (
f"Git LFS pointer file is not hydrated: {path}"
)
PY
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: ${{ github.event_name == 'push' || inputs.publish == true }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Release tags are PEP 440 (v0.5.0rc3), not strict semver, so the git
# tag is mapped through as-is instead of via type=semver.
- name: Compute image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=tag,enable=${{ github.event_name == 'push' }}
type=raw,value=edge,enable=${{ github.event_name == 'workflow_dispatch' }}
flavor: |
latest=false
- name: Build multi-arch image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' || inputs.publish == true }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Keep the pushed artifact a plain two-entry manifest list; the
# default provenance attestation adds an unknown/unknown entry that
# confuses registry UIs and `docker pull` inspection.
provenance: false
- name: Select pushed image
if: ${{ github.event_name == 'push' || inputs.publish == true }}
id: pushed_image
shell: bash
run: |
if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then
image_tag="${GITHUB_REF_NAME}"
else
image_tag="edge"
fi
echo "ref=ghcr.io/${GITHUB_REPOSITORY}:${image_tag}" >> "${GITHUB_OUTPUT}"
- name: Verify pushed manifest platforms
if: ${{ github.event_name == 'push' || inputs.publish == true }}
env:
IMAGE_REF: ${{ steps.pushed_image.outputs.ref }}
run: |
python3 - <<'PY'
import json
import os
import subprocess
image_ref = os.environ["IMAGE_REF"]
manifest = json.loads(
subprocess.check_output(
["docker", "buildx", "imagetools", "inspect", image_ref, "--raw"],
text=True,
)
)
platforms = {
f'{item["platform"]["os"]}/{item["platform"]["architecture"]}'
for item in manifest.get("manifests", [])
}
expected = {"linux/amd64", "linux/arm64"}
if platforms != expected:
raise SystemExit(
f"{image_ref} has platforms {sorted(platforms)}; "
f"expected {sorted(expected)}"
)
print(f"Verified {image_ref}: {sorted(platforms)}")
PY
- name: Smoke pushed image HEALTHCHECK
if: ${{ github.event_name == 'push' || inputs.publish == true }}
env:
IMAGE_REF: ${{ steps.pushed_image.outputs.ref }}
shell: bash
run: |
set -euo pipefail
container_id="$(docker run --detach --pull=always "${IMAGE_REF}")"
trap 'docker rm --force "${container_id}" >/dev/null 2>&1 || true' EXIT
deadline=$((SECONDS + 180))
while (( SECONDS < deadline )); do
running="$(docker inspect --format '{{.State.Running}}' "${container_id}")"
health="$(docker inspect \
--format '{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' \
"${container_id}")"
echo "Container health: ${health}"
if [[ "${health}" == "healthy" ]]; then
exit 0
fi
if [[ "${running}" != "true" || "${health}" == "unhealthy" || "${health}" == "missing" ]]; then
docker logs "${container_id}"
exit 1
fi
sleep 5
done
echo "Container did not become healthy within 180 seconds" >&2
docker logs "${container_id}"
exit 1
# Promotion happens only after the immutable release image passes both
# remote-manifest verification and its image-defined HEALTHCHECK.
- name: Promote verified release image to latest
if: github.event_name == 'push'
env:
IMAGE_REF: ${{ steps.pushed_image.outputs.ref }}
LATEST_REF: ghcr.io/${{ github.repository }}:latest
run: |
docker buildx imagetools create \
--tag "${LATEST_REF}" \
"${IMAGE_REF}"
+35
View File
@@ -0,0 +1,35 @@
name: Issue Link Sync
"on":
pull_request_target:
types: [opened, reopened, edited, closed]
branches: [main]
permissions:
contents: read
issues: write
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
sync-linked-issues:
name: Sync linked issue state
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out trusted base branch scripts
uses: actions/checkout@v4
with:
# pull_request_target has write-capable tokens. Run scripts from the
# pre-merge base commit, never from the pull request head.
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- name: Sync issue labels and comments
env:
GITHUB_EVENT_PATH: ${{ github.event_path }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_TOKEN: ${{ github.token }}
run: python3 .github/scripts/issue_link_sync.py
+98
View File
@@ -0,0 +1,98 @@
name: Live Release E2E
"on":
workflow_dispatch:
inputs:
run_telegram_channel:
description: "Send one real Telegram smoke message"
required: false
type: boolean
default: false
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
live-release-e2e:
name: Maintainer live release gates
runs-on: ubuntu-latest
timeout-minutes: 30
env:
PYTHONPATH: ${{ github.workspace }}
OPENSQUILLA_TURN_CALL_LOG: "0"
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENROUTER_BASE_URL: ${{ vars.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1' }}
LLM_TEST_MODEL: ${{ vars.LLM_TEST_MODEL || 'openai/gpt-4o-mini' }}
OPENSQUILLA_LIVE_TELEGRAM_BOT_TOKEN: ${{ secrets.OPENSQUILLA_LIVE_TELEGRAM_BOT_TOKEN }}
OPENSQUILLA_LIVE_TELEGRAM_CHAT_ID: ${{ secrets.OPENSQUILLA_LIVE_TELEGRAM_CHAT_ID }}
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Configure runtime directories
shell: bash
run: |
printf 'OPENSQUILLA_STATE_DIR=%s/opensquilla-state\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Set up uv
uses: astral-sh/setup-uv@v5
- name: Install dependencies
run: uv sync --extra dev --extra recommended --frozen
- name: Install Playwright browser
run: npx --yes playwright install chromium
- name: Fail if OpenRouter secret is missing
shell: bash
run: |
if [ -z "$OPENROUTER_API_KEY" ]; then
echo "OPENROUTER_API_KEY GitHub secret is required"
exit 1
fi
- name: Fail if Telegram secrets are missing when channel smoke is enabled
if: ${{ inputs.run_telegram_channel }}
shell: bash
run: |
if [ -z "$OPENSQUILLA_LIVE_TELEGRAM_BOT_TOKEN" ]; then
echo "OPENSQUILLA_LIVE_TELEGRAM_BOT_TOKEN GitHub secret is required when Telegram smoke is enabled"
exit 1
fi
if [ -z "$OPENSQUILLA_LIVE_TELEGRAM_CHAT_ID" ]; then
echo "OPENSQUILLA_LIVE_TELEGRAM_CHAT_ID GitHub secret is required when Telegram smoke is enabled"
exit 1
fi
- name: Run gateway LLM e2e
env:
OPENSQUILLA_GATEWAY_LLM_E2E: "1"
run: uv run pytest tests/functional/test_gateway_llm_e2e.py -q -s
- name: Run real-browser chat e2e
env:
OPENSQUILLA_WEBUI_BROWSER_CHAT_E2E: "1"
run: uv run pytest tests/functional/test_webui_browser_chat_e2e.py -q -s
- name: Run live Telegram channel smoke
if: ${{ inputs.run_telegram_channel }}
env:
OPENSQUILLA_LIVE_TELEGRAM_E2E: "1"
run: uv run pytest tests/functional/test_live_channel_telegram_smoke.py -q -s
+101
View File
@@ -0,0 +1,101 @@
name: Live Search E2E
"on":
workflow_dispatch:
inputs:
run_extended_matrix:
description: "Run the broader live provider/API matrix"
required: false
type: boolean
default: false
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
live-search-e2e:
name: Live search provider and agent gates
runs-on: ubuntu-latest
timeout-minutes: 30
env:
PYTHONPATH: ${{ github.workspace }}
OPENSQUILLA_TURN_CALL_LOG: "0"
OPENSQUILLA_LIVE_SEARCH: "1"
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENROUTER_BASE_URL: ${{ vars.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1' }}
LLM_TEST_MODEL: ${{ vars.LLM_TEST_MODEL || 'openai/gpt-4o-mini' }}
TAVILY_API_KEY: ${{ secrets.TAVILY_API_KEY }}
BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }}
EXA_API_KEY: ${{ secrets.EXA_API_KEY }}
FIRECRAWL_API_KEY: ${{ secrets.FIRECRAWL_API_KEY }}
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Configure runtime directories
shell: bash
run: |
printf 'OPENSQUILLA_STATE_DIR=%s/opensquilla-state\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
- name: Install dependencies
run: uv sync --extra dev --extra recommended --frozen
- name: Fail if required live search secrets are missing
shell: bash
run: |
if [ -z "$OPENROUTER_API_KEY" ]; then
echo "OPENROUTER_API_KEY GitHub secret is required"
exit 1
fi
if [ -z "$TAVILY_API_KEY" ]; then
echo "TAVILY_API_KEY GitHub secret is required"
exit 1
fi
if [ "${{ inputs.run_extended_matrix }}" = "true" ] \
&& [ -z "$BRAVE_SEARCH_API_KEY" ]; then
echo "BRAVE_SEARCH_API_KEY GitHub secret is required for the extended matrix"
exit 1
fi
if [ "${{ inputs.run_extended_matrix }}" = "true" ] \
&& [ -z "$EXA_API_KEY" ]; then
echo "EXA_API_KEY GitHub secret is required for the extended matrix"
exit 1
fi
if [ "${{ inputs.run_extended_matrix }}" = "true" ] \
&& [ -z "$FIRECRAWL_API_KEY" ]; then
echo "FIRECRAWL_API_KEY GitHub secret is required for the extended matrix"
exit 1
fi
- name: Run core live search gates
run: |
timeout 180s uv run pytest \
tests/live/test_search_retrieval_live.py \
tests/live/test_web_search_agent_e2e.py \
-m live_search \
-q -s
- name: Run extended live search matrix
if: ${{ inputs.run_extended_matrix }}
env:
OPENSQUILLA_LIVE_SEARCH_MATRIX: "1"
run: |
timeout 240s uv run pytest \
tests/live/test_search_api_matrix_live.py \
-m live_search \
-q -s
+42
View File
@@ -0,0 +1,42 @@
name: LLM Smoke
"on":
workflow_dispatch:
permissions:
contents: read
jobs:
llm-smoke:
name: Live LLM smoke
runs-on: ubuntu-latest
timeout-minutes: 20
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENROUTER_BASE_URL: ${{ vars.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1' }}
LLM_TEST_MODEL: ${{ vars.LLM_TEST_MODEL || 'openai/gpt-4o-mini' }}
OPENSQUILLA_TURN_CALL_LOG: "0"
TMPDIR: /tmp
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Configure runtime directories
shell: bash
run: printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
- name: Install Python dependencies
run: uv sync --extra dev --frozen
- name: Run smoke
run: uv run pytest tests/functional/test_llm_smoke.py -q -s
+78
View File
@@ -0,0 +1,78 @@
name: Models.dev Snapshot Refresh
"on":
schedule:
# Monthly, 06:00 UTC on the 1st. Data-only refresh of the vendored
# models.dev snapshot; the PR it opens still goes through normal review.
- cron: "0 6 1 * *"
workflow_dispatch:
# The job fetches public catalog data and opens a PR with the diff. It must
# never receive provider API keys or other live-test secrets — only the
# workflow GITHUB_TOKEN, scoped to branch + PR creation.
permissions:
contents: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
jobs:
refresh-snapshot:
name: Refresh vendored models.dev snapshot
runs-on: ubuntu-latest
timeout-minutes: 10
env:
UV_PYTHON: "3.12"
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Install dependencies
shell: bash
# Base dependencies only: the refresh script needs the provider
# registry and httpx, nothing from the dev/recommended extras.
run: uv sync --frozen
- name: Refresh models.dev snapshot
shell: bash
run: uv run python scripts/refresh_models_dev_snapshot.py
- name: Open data-refresh pull request
uses: peter-evans/create-pull-request@v7
with:
branch: data/models-dev-snapshot-refresh
delete-branch: true
add-paths: src/opensquilla/provider/models_dev_snapshot.json
commit-message: Refresh vendored models.dev snapshot
title: Refresh vendored models.dev snapshot (scheduled data refresh)
body: |
Scheduled data-only refresh of the vendored models.dev snapshot
(`src/opensquilla/provider/models_dev_snapshot.json`), produced by
`uv run python scripts/refresh_models_dev_snapshot.py`.
- Scope: vendored model-metadata snapshot only; no runtime code
changes.
- Branch target: main
- Linked issue: None
- Release notes: not needed (data refresh).
- Tests: default CI on this PR revalidates the snapshot shape.
- Safety: **needs human review before merge** — upstream data
mistakes (context windows, output caps) poison context budgeting
at runtime. Review the diff line by line; the snapshot is kept
deliberately small for this reason.
- Third-party origin: models.dev api.json (MIT, maintained by the
SST team).
+33
View File
@@ -0,0 +1,33 @@
name: PR body lint
"on":
pull_request:
types: [opened, edited, synchronize, reopened, ready_for_review]
permissions:
contents: read
pull-requests: read
jobs:
validate-body:
name: Validate PR body fields
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out trusted workflow scripts
uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Check out bootstrap body linter before first merge
if: ${{ hashFiles('.github/scripts/validate_pr_body.py') == '' }}
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Validate PR body fields
env:
PR_BODY_LINT_STRICT: "0"
run: python3 .github/scripts/validate_pr_body.py
+37
View File
@@ -0,0 +1,37 @@
name: PR target branch
"on":
pull_request:
types: [opened, edited, synchronize, reopened, ready_for_review, labeled, unlabeled]
permissions:
contents: read
pull-requests: read
jobs:
validate-target:
name: Validate target branch
runs-on: ubuntu-latest
steps:
- name: Check out trusted workflow scripts
uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Check out bootstrap validator before first merge
if: ${{ hashFiles('.github/scripts/validate-pr-target-branch.sh') == '' }}
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Validate target branch
env:
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
GH_TOKEN: ${{ github.token }}
run: bash .github/scripts/validate-pr-target-branch.sh
+47
View File
@@ -0,0 +1,47 @@
name: Web UI Browser Smoke
"on":
workflow_dispatch:
permissions:
contents: read
jobs:
webui-browser-smoke:
name: Real browser Web UI smoke
runs-on: ubuntu-latest
timeout-minutes: 20
env:
OPENSQUILLA_WEBUI_BROWSER_E2E: "1"
OPENSQUILLA_TURN_CALL_LOG: "0"
TMPDIR: /tmp
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Configure runtime directories
shell: bash
run: printf 'OPENSQUILLA_LOG_DIR=%s/opensquilla-logs\n' "$RUNNER_TEMP" >> "$GITHUB_ENV"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Set up uv
uses: astral-sh/setup-uv@v5
- name: Install Python dependencies
run: uv sync --extra dev --frozen
- name: Install Playwright browser
run: npx --yes playwright install chromium
- name: Run Web UI browser smoke
run: uv run pytest tests/functional/test_webui_browser_e2e.py -q -s
+875
View File
@@ -0,0 +1,875 @@
name: Release Assets
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: Optional existing tag to upload artifacts to
required: false
default: ""
permissions:
contents: read
concurrency:
group: release-assets-${{ github.event_name == 'push' && github.ref_name || github.event.inputs.tag || github.run_id }}
cancel-in-progress: false
env:
RELEASE_PROFILE: recommended
RELEASE_TAG: ${{ github.event_name == 'push' && github.ref_name || github.event.inputs.tag }}
jobs:
build-release-assets:
name: Build Python release assets
runs-on: windows-latest
timeout-minutes: 90
steps:
- name: Validate workflow inputs
shell: bash
run: |
if [[ -n "${RELEASE_TAG}" && ! "${RELEASE_TAG}" =~ ^v[0-9]+[.][0-9]+[.][0-9]+.*$ ]]; then
echo "Release tag must look like v0.2.0rc1 or v0.2.0: ${RELEASE_TAG}" >&2
exit 1
fi
- name: Checkout
uses: actions/checkout@v4
with:
lfs: true
persist-credentials: false
ref: ${{ env.RELEASE_TAG != '' && env.RELEASE_TAG || github.ref }}
- name: Hydrate router assets
shell: bash
run: git lfs pull --include="src/opensquilla/squilla_router/models/**"
- name: Verify router assets are hydrated
shell: bash
run: |
python - <<'PY'
from pathlib import Path
root = Path("src/opensquilla/squilla_router/models/v4.2_phase3_inference")
required = [
root / "PROVENANCE.md",
root / "artifact_manifest.json",
root / "bge_onnx" / "model.onnx",
root / "features" / "tfidf.pkl",
root / "lgbm_main.bin",
root / "mlp" / "model.onnx",
root / "router.runtime.yaml",
]
for path in required:
assert path.is_file(), f"missing router asset: {path}"
prefix = path.read_bytes()[:80]
assert not prefix.startswith(b"version https://git-lfs.github.com/spec/v1"), (
f"Git LFS pointer file is not hydrated: {path}"
)
PY
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install uv
run: python -m pip install uv
- name: Check tag version
if: ${{ github.event_name == 'push' || github.event.inputs.tag != '' }}
shell: bash
run: |
version="$(python - <<'PY'
import tomllib
from pathlib import Path
pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
print(pyproject["project"]["version"])
PY
)"
tag="${RELEASE_TAG}"
tag_version="${tag#v}"
if [[ "${tag_version}" != "${version}" ]]; then
echo "Tag ${tag} does not match project version ${version}" >&2
exit 1
fi
- name: Test release asset contracts
run: uv run --extra dev pytest tests/test_scripts/test_build_wheelhouse_zip.py
- name: Build versioned wheel
shell: bash
run: |
rm -rf dist build/wheelhouse-zip
uv build --wheel --out-dir dist
- name: Smoke versioned release artifacts
shell: bash
run: |
python - <<'PY'
import tomllib
from pathlib import Path
from zipfile import ZipFile
version = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]["version"]
wheels = sorted(
path for path in Path("dist").glob("opensquilla-*.whl")
)
assert len(wheels) == 1, f"expected one versioned wheel, got {len(wheels)}"
assert wheels[0].name == f"opensquilla-{version}-py3-none-any.whl"
assert not list(Path("dist").glob("OpenSquilla-*portable*.zip")), (
"0.5+ release assets must not include Windows portable zips"
)
with ZipFile(wheels[0]) as archive:
for info in archive.infolist():
if not info.filename.endswith((".bin", ".onnx", ".pkl", ".joblib")):
continue
prefix = archive.read(info)[:80]
assert not prefix.startswith(b"version https://git-lfs.github.com/spec/v1"), (
f"Git LFS pointer leaked into wheel: {info.filename}"
)
PY
- name: Create release assets
shell: bash
run: |
python - <<'PY'
import hashlib
import tomllib
from pathlib import Path
dist = Path("dist")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
version = project["project"]["version"]
wheels = sorted(
path for path in dist.glob("opensquilla-*.whl")
)
assert len(wheels) == 1, f"expected one versioned wheel, got {len(wheels)}"
assert wheels[0].name == f"opensquilla-{version}-py3-none-any.whl"
assets = [wheels[0]]
lines = [
f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}"
for path in assets
]
(dist / "SHA256SUMS").write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: opensquilla-release-assets-python-${{ env.RELEASE_PROFILE }}
path: |
dist/*.whl
dist/SHA256SUMS
build-desktop-macos:
name: Build macOS Electron installer
runs-on: macos-latest
timeout-minutes: 150
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: true
persist-credentials: false
ref: ${{ env.RELEASE_TAG != '' && env.RELEASE_TAG || github.ref }}
- name: Hydrate router assets
run: git lfs pull --include="src/opensquilla/squilla_router/models/**"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22.12.0"
cache: npm
cache-dependency-path: |
opensquilla-webui/package-lock.json
desktop/electron/package-lock.json
- name: Install Web UI dependencies
working-directory: opensquilla-webui
run: npm ci
- name: Install Electron dependencies
working-directory: desktop/electron
run: npm ci
- name: Build signed macOS installer
working-directory: desktop/electron
env:
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
npm run build:web
npm run build:gateway
npm run build
npx electron-builder --mac --publish never
- name: Verify Electron package
working-directory: desktop/electron
run: npm run verify:package
- name: Smoke packaged gateway
working-directory: desktop/electron
env:
OPENSQUILLA_REQUIRE_PACKAGED_GATEWAY_SMOKE: "1"
OPENSQUILLA_GATEWAY_SMOKE_TIMEOUT_MS: "240000"
run: npm run verify:gateway-smoke
- name: Verify RC3-to-candidate macOS upgrade and uninstall preserve profile data
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
candidates=(dist/desktop-electron/OpenSquilla-*-mac-arm64.dmg)
if [[ "${#candidates[@]}" -ne 1 || ! -f "${candidates[0]}" ]]; then
echo "Expected exactly one candidate macOS DMG" >&2
exit 1
fi
.github/scripts/verify-release-macos-upgrade.sh \
"${candidates[0]}" built-candidate
- name: List macOS artifacts
run: find dist/desktop-electron -maxdepth 2 -type f -print
- name: Upload macOS Electron artifacts
uses: actions/upload-artifact@v4
with:
name: opensquilla-electron-macos
path: |
dist/desktop-electron/*.dmg
dist/desktop-electron/*.zip
dist/desktop-electron/*.blockmap
dist/desktop-electron/latest-mac.yml
build-desktop-windows:
name: Build unsigned Windows Electron installer
runs-on: windows-latest
timeout-minutes: 150
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: true
persist-credentials: false
ref: ${{ env.RELEASE_TAG != '' && env.RELEASE_TAG || github.ref }}
- name: Hydrate router assets
shell: bash
run: git lfs pull --include="src/opensquilla/squilla_router/models/**"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22.12.0"
cache: npm
cache-dependency-path: |
opensquilla-webui/package-lock.json
desktop/electron/package-lock.json
- name: Install Web UI dependencies
working-directory: opensquilla-webui
run: npm ci
- name: Install Electron dependencies
working-directory: desktop/electron
run: npm ci
- name: Build unsigned Windows installer
working-directory: desktop/electron
shell: bash
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: |
set -euo pipefail
npm run build:web
npm run build:gateway
npm run build
npx electron-builder --win --publish never
- name: Verify Electron package
working-directory: desktop/electron
run: npm run verify:package
- name: Smoke packaged gateway
working-directory: desktop/electron
env:
OPENSQUILLA_REQUIRE_PACKAGED_GATEWAY_SMOKE: "1"
run: npm run verify:gateway-smoke
- name: Verify RC3-to-candidate Windows upgrade and uninstall preserve profile data
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
run: |
$ErrorActionPreference = 'Stop'
$candidates = @(Get-ChildItem 'dist/desktop-electron/OpenSquilla-*-win-x64.exe' -File)
if ($candidates.Count -ne 1) {
throw "Expected exactly one candidate Windows installer; got $($candidates.Count)."
}
.github/scripts/verify-release-windows-upgrade.ps1 `
-CandidateInstaller $candidates[0].FullName `
-Label built-candidate
- name: List Windows artifacts
shell: bash
run: find dist/desktop-electron -maxdepth 2 -type f -print
- name: Upload Windows Electron artifacts
uses: actions/upload-artifact@v4
with:
name: opensquilla-electron-windows
path: |
dist/desktop-electron/*.exe
dist/desktop-electron/*.blockmap
dist/desktop-electron/latest.yml
publish-release:
name: Publish release assets
needs:
- build-release-assets
- build-desktop-macos
- build-desktop-windows
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: write
steps:
- name: Checkout release notes
if: ${{ github.event_name == 'push' || github.event.inputs.tag != '' }}
uses: actions/checkout@v4
with:
persist-credentials: false
ref: ${{ env.RELEASE_TAG }}
- name: Download release assets
uses: actions/download-artifact@v4
with:
pattern: opensquilla-release-assets-python-${{ env.RELEASE_PROFILE }}
path: dist
merge-multiple: true
- name: Download macOS Electron assets
uses: actions/download-artifact@v4
with:
name: opensquilla-electron-macos
path: dist
- name: Download Windows Electron assets
uses: actions/download-artifact@v4
with:
name: opensquilla-electron-windows
path: dist
- name: Regenerate release checksums
shell: bash
run: |
python - <<'PY'
import hashlib
import os
import re
from pathlib import Path
def desktop_asset_version(version: str) -> str:
return re.sub(r"(?<=\d)(a|b|rc)(\d+)$", r"-\1\2", version)
dist = Path("dist")
tag = os.environ["RELEASE_TAG"]
if tag:
version = tag.removeprefix("v")
else:
wheels = sorted(dist.glob("opensquilla-*-py3-none-any.whl"))
assert len(wheels) == 1, f"expected one wheel when RELEASE_TAG is empty, got {len(wheels)}"
version = wheels[0].name.removeprefix("opensquilla-").removesuffix("-py3-none-any.whl")
desktop_version = desktop_asset_version(version)
asset_names = [
f"OpenSquilla-{desktop_version}-mac-arm64.dmg",
f"OpenSquilla-{desktop_version}-mac-arm64.zip",
f"OpenSquilla-{desktop_version}-mac-arm64.dmg.blockmap",
f"OpenSquilla-{desktop_version}-mac-arm64.zip.blockmap",
"latest-mac.yml",
f"OpenSquilla-{desktop_version}-win-x64.exe",
f"OpenSquilla-{desktop_version}-win-x64.exe.blockmap",
"latest.yml",
f"opensquilla-{version}-py3-none-any.whl",
]
lines = []
for name in asset_names:
path = dist / name
assert path.is_file(), f"missing release asset for checksum: {name}"
lines.append(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {name}")
(dist / "SHA256SUMS").write_text("\n".join(lines) + "\n", encoding="utf-8")
PY
- name: Verify release asset set
shell: bash
run: |
python - <<'PY'
import hashlib
import os
import re
from pathlib import Path
def desktop_asset_version(version: str) -> str:
return re.sub(r"(?<=\d)(a|b|rc)(\d+)$", r"-\1\2", version)
dist = Path("dist")
tag = os.environ["RELEASE_TAG"]
if tag:
version = tag.removeprefix("v")
else:
wheels = sorted(dist.glob("opensquilla-*-py3-none-any.whl"))
assert len(wheels) == 1, f"expected one wheel when RELEASE_TAG is empty, got {len(wheels)}"
version = wheels[0].name.removeprefix("opensquilla-").removesuffix("-py3-none-any.whl")
desktop_version = desktop_asset_version(version)
sha256s = dist / "SHA256SUMS"
asset_names = [
f"OpenSquilla-{desktop_version}-mac-arm64.dmg",
f"OpenSquilla-{desktop_version}-mac-arm64.zip",
f"OpenSquilla-{desktop_version}-mac-arm64.dmg.blockmap",
f"OpenSquilla-{desktop_version}-mac-arm64.zip.blockmap",
"latest-mac.yml",
f"OpenSquilla-{desktop_version}-win-x64.exe",
f"OpenSquilla-{desktop_version}-win-x64.exe.blockmap",
"latest.yml",
f"opensquilla-{version}-py3-none-any.whl",
]
assets = [dist / name for name in asset_names]
assert sha256s.is_file(), "missing SHA256SUMS"
for path in assets:
assert path.is_file(), f"missing release asset: {path.name}"
expected = [
f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.name}"
for path in assets
]
assert sha256s.read_text(encoding="utf-8").splitlines() == expected
PY
- name: Upload aggregate workflow artifact
uses: actions/upload-artifact@v4
with:
name: opensquilla-release-assets-${{ env.RELEASE_PROFILE }}
path: dist/*
- name: Upload to GitHub Release
if: ${{ github.event_name == 'push' || github.event.inputs.tag != '' }}
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ env.RELEASE_TAG }}
shell: bash
run: |
set -euo pipefail
IS_PRERELEASE="$(python - <<'PY'
import os
import re
tag = os.environ["TAG"]
preview = re.search(
r"(?:[-.]?(?:a|alpha|b|beta|rc|pre|preview))[0-9]+$",
tag,
flags=re.IGNORECASE,
)
print("true" if preview else "false")
PY
)"
TITLE="OpenSquilla ${TAG#v}"
NOTES_FILE="docs/releases/${TAG#v}.md"
create_args=("${TAG}" --draft --verify-tag)
if [[ "${IS_PRERELEASE}" == "true" ]]; then
TITLE="$(python - <<'PY'
import os
import re
version = os.environ["TAG"].removeprefix("v")
match = re.fullmatch(r"([0-9]+[.][0-9]+[.][0-9]+)(?:a|b|rc)([0-9]+)", version)
if match:
print(f"OpenSquilla {match.group(1)} Preview {match.group(2)}")
else:
print(f"OpenSquilla {version} Preview")
PY
)"
create_args+=(--prerelease)
fi
create_args+=(--title "${TITLE}")
if [[ -f "${NOTES_FILE}" ]]; then
create_args+=(--notes-file "${NOTES_FILE}")
fi
assert_release_is_safe_draft() {
local release_state
release_state="$(gh release view "${TAG}" --json isDraft,isPrerelease)"
RELEASE_STATE="${release_state}" EXPECTED_PRERELEASE="${IS_PRERELEASE}" python - <<'PY'
import json
import os
state = json.loads(os.environ["RELEASE_STATE"])
expected_prerelease = os.environ["EXPECTED_PRERELEASE"] == "true"
if state.get("isDraft") is not True:
raise SystemExit("Refusing to mutate an existing non-Draft GitHub Release")
if state.get("isPrerelease") is not expected_prerelease:
raise SystemExit(
"Refusing to mutate a GitHub Release with an unexpected prerelease state"
)
PY
}
if gh release view "${TAG}" --json isDraft,isPrerelease >/dev/null 2>&1; then
assert_release_is_safe_draft
else
gh release create "${create_args[@]}"
assert_release_is_safe_draft
fi
if [[ -f "${NOTES_FILE}" ]]; then
gh release edit "${TAG}" --title "${TITLE}" --notes-file "${NOTES_FILE}"
fi
assert_release_is_safe_draft
python - <<'PY'
import json
import os
import subprocess
tag = os.environ["TAG"]
raw = subprocess.check_output(
["gh", "release", "view", tag, "--json", "assets"],
text=True,
)
data = json.loads(raw)
for asset in data.get("assets", []):
name = asset["name"]
managed = (
name == "SHA256SUMS"
or name == "latest-mac.yml"
or name == "latest.yml"
or name.startswith("OpenSquilla-")
or name.startswith("opensquilla-")
or name.endswith(".sha256")
)
if managed:
subprocess.run(
["gh", "release", "delete-asset", tag, name, "--yes"],
check=True,
)
PY
assert_release_is_safe_draft
gh release upload "${TAG}" dist/* --clobber
assert_release_is_safe_draft
- name: Verify GitHub Release assets
if: ${{ github.event_name == 'push' || github.event.inputs.tag != '' }}
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ env.RELEASE_TAG }}
shell: bash
run: |
python - <<'PY'
import json
import os
import re
import subprocess
import sys
def desktop_asset_version(version: str) -> str:
return re.sub(r"(?<=\d)(a|b|rc)(\d+)$", r"-\1\2", version)
tag = os.environ["TAG"]
version = tag.removeprefix("v")
desktop_version = desktop_asset_version(version)
expected = {
f"OpenSquilla-{desktop_version}-mac-arm64.dmg",
f"OpenSquilla-{desktop_version}-mac-arm64.zip",
f"OpenSquilla-{desktop_version}-mac-arm64.dmg.blockmap",
f"OpenSquilla-{desktop_version}-mac-arm64.zip.blockmap",
"latest-mac.yml",
f"OpenSquilla-{desktop_version}-win-x64.exe",
f"OpenSquilla-{desktop_version}-win-x64.exe.blockmap",
"latest.yml",
f"opensquilla-{version}-py3-none-any.whl",
"SHA256SUMS",
}
raw = subprocess.check_output(
[
"gh",
"release",
"view",
tag,
"--json",
"assets,isDraft,isPrerelease",
],
text=True,
)
data = json.loads(raw)
preview = re.search(
r"(?:[-.]?(?:a|alpha|b|beta|rc|pre|preview))[0-9]+$",
tag,
flags=re.IGNORECASE,
)
if data.get("isDraft") is not True:
print("Release asset verification requires a Draft release", file=sys.stderr)
raise SystemExit(1)
if data.get("isPrerelease") is not bool(preview):
print("Release prerelease state does not match the tag", file=sys.stderr)
raise SystemExit(1)
names = {asset["name"] for asset in data.get("assets", [])}
missing = sorted(expected - names)
unexpected = sorted(names - expected)
if missing or unexpected:
print(
"Unexpected GitHub Release assets:",
{
"missing": missing,
"unexpected": unexpected,
"names": sorted(names),
},
file=sys.stderr,
)
raise SystemExit(1)
PY
audit-downloaded-macos-release:
name: Audit downloaded macOS draft assets
if: ${{ github.event_name == 'push' || github.event.inputs.tag != '' }}
needs: publish-release
runs-on: macos-latest
timeout-minutes: 75
permissions:
contents: read
steps:
- name: Checkout release verification helpers
uses: actions/checkout@v4
with:
lfs: false
persist-credentials: false
ref: ${{ env.RELEASE_TAG }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Node.js for app.asar verification
uses: actions/setup-node@v4
with:
node-version: "22.12.0"
- name: Download actual draft release assets
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ env.RELEASE_TAG }}
shell: bash
run: |
set -euo pipefail
state="$(gh release view "${TAG}" --json isDraft)"
RELEASE_STATE="${state}" python - <<'PY'
import json
import os
assert json.loads(os.environ["RELEASE_STATE"])["isDraft"] is True, (
"downloaded release audit may only inspect a Draft release"
)
PY
mkdir -p release-audit
gh release download "${TAG}" --dir release-audit
- name: Verify checksums, signing, notarization, and packaged version
env:
TAG: ${{ env.RELEASE_TAG }}
shell: bash
run: |
set -euo pipefail
cd release-audit
python - <<'PY'
from hashlib import sha256
from pathlib import Path
lines = Path("SHA256SUMS").read_text(encoding="utf-8").splitlines()
assert lines, "SHA256SUMS is empty"
for line in lines:
digest, name = line.split(" ", 1)
path = Path(name)
assert path.is_file(), f"downloaded release asset is missing: {name}"
assert sha256(path.read_bytes()).hexdigest() == digest, (
f"checksum mismatch for downloaded asset: {name}"
)
PY
dmg_candidates=(OpenSquilla-*-mac-arm64.dmg)
zip_candidates=(OpenSquilla-*-mac-arm64.zip)
if [[ "${#dmg_candidates[@]}" -ne 1 || ! -f "${dmg_candidates[0]}" ]]; then
echo "Expected exactly one downloaded macOS DMG" >&2
exit 1
fi
if [[ "${#zip_candidates[@]}" -ne 1 || ! -f "${zip_candidates[0]}" ]]; then
echo "Expected exactly one downloaded macOS ZIP" >&2
exit 1
fi
expected_version="$(python - "${TAG#v}" <<'PY'
import re
import sys
print(re.sub(r"(?<=\d)(a|b|rc)(\d+)$", r"-\1\2", sys.argv[1]))
PY
)"
validate_app() {
local app_path plist actual_version asar_dir
app_path="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
plist="${app_path}/Contents/Info.plist"
actual_version="$(/usr/libexec/PlistBuddy \
-c 'Print :CFBundleShortVersionString' "${plist}")"
test "${actual_version}" = "${expected_version}"
asar_dir="$(mktemp -d)"
(
cd "${asar_dir}"
npx --yes @electron/asar@3.4.1 extract-file \
"${app_path}/Contents/Resources/app.asar" package.json
python - "${expected_version}" <<'PY'
import json
import sys
from pathlib import Path
packaged = json.loads(Path("package.json").read_text(encoding="utf-8"))
assert packaged["version"] == sys.argv[1], (
f"app.asar version {packaged['version']!r} != expected {sys.argv[1]!r}"
)
PY
)
python - "${asar_dir}" <<'PY'
import shutil
import sys
shutil.rmtree(sys.argv[1])
PY
codesign --verify --deep --strict --verbose=2 "${app_path}"
spctl -a -vv -t exec "${app_path}"
xcrun stapler validate "${app_path}"
}
extracted="$(mktemp -d)"
ditto -x -k "${zip_candidates[0]}" "${extracted}"
validate_app "${extracted}/OpenSquilla.app"
mounted="$(mktemp -d)"
cleanup_mount() {
hdiutil detach "${mounted}" -quiet >/dev/null 2>&1 || true
}
trap cleanup_mount EXIT
hdiutil attach -nobrowse -readonly -mountpoint "${mounted}" "${dmg_candidates[0]}"
validate_app "${mounted}/OpenSquilla.app"
hdiutil detach "${mounted}" -quiet
- name: Verify downloaded RC3-to-candidate macOS upgrade and uninstall
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
candidates=(release-audit/OpenSquilla-*-mac-arm64.dmg)
if [[ "${#candidates[@]}" -ne 1 || ! -f "${candidates[0]}" ]]; then
echo "Expected exactly one downloaded candidate macOS DMG" >&2
exit 1
fi
.github/scripts/verify-release-macos-upgrade.sh \
"${candidates[0]}" downloaded-asset
audit-downloaded-windows-release:
name: Audit downloaded Windows draft asset
if: ${{ github.event_name == 'push' || github.event.inputs.tag != '' }}
needs: publish-release
runs-on: windows-latest
timeout-minutes: 75
permissions:
contents: read
steps:
- name: Checkout release verification helpers
uses: actions/checkout@v4
with:
lfs: false
persist-credentials: false
ref: ${{ env.RELEASE_TAG }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Download actual draft release assets
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ env.RELEASE_TAG }}
run: |
$ErrorActionPreference = 'Stop'
$state = gh release view $env:TAG --json isDraft | ConvertFrom-Json
if (-not $state.isDraft) {
throw 'Downloaded release audit may only inspect a Draft release.'
}
New-Item -ItemType Directory -Force -Path release-audit | Out-Null
gh release download $env:TAG --dir release-audit
if ($LASTEXITCODE -ne 0) { throw 'Failed to download Draft release assets.' }
- name: Verify downloaded checksums
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$checksumLines = Get-Content -LiteralPath 'release-audit/SHA256SUMS'
if ($checksumLines.Count -eq 0) { throw 'SHA256SUMS is empty.' }
foreach ($line in $checksumLines) {
$parts = $line -split ' ', 2
if ($parts.Count -ne 2) { throw "Malformed checksum line: $line" }
$path = Join-Path 'release-audit' $parts[1]
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "Downloaded release asset is missing: $($parts[1])"
}
$actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $path).Hash.ToLowerInvariant()
if ($actual -ne $parts[0].ToLowerInvariant()) {
throw "Checksum mismatch for downloaded asset: $($parts[1])"
}
}
- name: Verify downloaded RC3-to-candidate Windows upgrade and uninstall
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
run: |
$ErrorActionPreference = 'Stop'
$candidates = @(Get-ChildItem 'release-audit/OpenSquilla-*-win-x64.exe' -File)
if ($candidates.Count -ne 1) {
throw "Expected exactly one downloaded Windows installer; got $($candidates.Count)."
}
.github/scripts/verify-release-windows-upgrade.ps1 `
-CandidateInstaller $candidates[0].FullName `
-Label downloaded-asset
+126
View File
@@ -0,0 +1,126 @@
__pycache__/
*.py[cod]
*.egg-info/
/dist/
/build/
.venv/
.env
.env.test
opensquilla.toml
.gateway.log
.gateway-test.html
# Locally rendered review/explainer/plan pages — never published.
*.local.html
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite-shm
*.sqlite-wal
# Pre-migration database snapshots written by the schema migrator
# (<dbname>.pre-<migration_id>.bak) — same runtime-data class as *.db.
*.db.pre-*.bak
*.sqlite.pre-*.bak
.DS_Store
.*/
!.github/
!.github/workflows/
!.github/workflows/*.yml
data/
# Packaged data for the SWE-bench contrib harness must ship with the wheel.
!src/opensquilla/contrib/swebench/data/
!src/opensquilla/contrib/swebench/data/**
!src/opensquilla/contrib/codetask/data/
!src/opensquilla/contrib/codetask/data/**
.opensquilla/
benchmarks/
specs/
/reports/
# Local agent/editor instructions and runtime state.
AGENTS.md
!docs/agents.md
!src/opensquilla/identity/templates/bootstrap/AGENTS.md
CLAUDE.md
GEMINI.md
.codex
.codex/
.claude/
.omc/
.omx/
# One-off planning artifacts kept local, not published.
/PLAN.md
/SPEC.md
/prd.json
/progress.txt
/plans/
/docs/plans/
/docs/proposals/
/docs/superpowers/
# Local-only tree. To publish one doc, force-add it once:
# git add -f docs/superpowers/specs/<filename>.md
# (A bare "!" re-include can't override an excluded parent directory.)
*.png
# Brand assets are intentionally tracked: top-level assets/ for branding,
# and the WebUI static asset path so the gateway page renders the mark.
!/assets/*.png
!/src/opensquilla/gateway/static/img/*.png
!/opensquilla-webui/public/opensquilla-mark.png
!/src/opensquilla/gateway/static/dist/opensquilla-mark.png
site/
/plugins/
crontab-*
~/
/reference-*
/reference-mem
/bench-mem
.omx/
tests/functional/reports/
# Frontend build artifacts
opensquilla-webui/node_modules/
opensquilla-webui/dist/
# Local-only release-hygiene tests that reference legacy artifact names
# kept off the public tree.
/tests/_private/
# Maintainer-local design / planning artifacts kept beside the repo but
# not part of the published source.
/meta-skills.pptx
/kb-index.xlsx
# Generated local outputs from document/meta-skill smoke runs.
/texput.log
/src/opensquilla/skills/bundled/multi-search-engine/Tokyo_5日出差行程.docx
# Local-only fetcher for the clawhub skills imported into bundled/.
# The bundled copies are tracked; the raw zip downloads and the fetch
# helper stay out of the published tree.
/_clawhub_skills/
/scripts/_fetch_clawhub_skills.py
# Playwright e2e output (reports, traces, screenshots) — regenerated per run.
test-results/
# Runtime config-write backups the gateway leaves when it rewrites config.toml.
opensquilla.toml.backup.*
# Background-music audio dropped in by the user for local builds. The manifest
# (playlist.json) and README stay tracked; copyrighted audio never does.
opensquilla-webui/public/music/**/*.mp3
opensquilla-webui/public/music/**/*.m4a
opensquilla-webui/public/music/**/*.ogg
opensquilla-webui/public/music/**/*.flac
opensquilla-webui/public/music/**/*.wav
opensquilla-webui/public/music/playlist.local.json
# The same user-supplied music files after the webui build copies them into
# the tracked gateway static dist: keep personal audio + manifest out of git
# while the built playlist.json/README.md stay tracked with the rest of dist.
src/opensquilla/gateway/static/dist/music/playlist.local.json
src/opensquilla/gateway/static/dist/music/**/*.mp3
src/opensquilla/gateway/static/dist/music/**/*.m4a
src/opensquilla/gateway/static/dist/music/**/*.ogg
src/opensquilla/gateway/static/dist/music/**/*.flac
src/opensquilla/gateway/static/dist/music/**/*.wav
+1
View File
@@ -0,0 +1 @@
3.12
+646
View File
@@ -0,0 +1,646 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.5.0rc3] - 2026-07-10
### Added
- Legacy home migration: `opensquilla migrate opensquilla` imports an
existing OpenSquilla home — a CLI `~/.opensquilla`, a retired Windows
portable data directory (enumerated with a chooser across
`%LOCALAPPDATA%\OpenSquilla\portable\*`), or an explicit `--source` path
(Docker volumes, relocated or restored homes) — into the current install.
Dry-run by default with a pinned machine-readable report; apply stages a whole-home
copy with WAL-safe database handling and commits transactionally, so an
interrupted import never leaves a partial target. Imported configs drop
stale absolute path pins, inline provider keys relocate to `.env`, and
imported scheduler jobs arrive paused. Entry points: the desktop
onboarding offers detected legacy data as a first step (with provider
prefill), Settings → Runtime gains an "Import legacy data" rescue action,
the Web UI setup flow shows a read-only legacy-data advisory
(`onboarding.status` gains the additive `legacyData` block), gateway boot
warns once when a fresh home coexists with legacy data, and doctor emits
a `migration.legacy_home_detected` finding with copyable fix commands.
- Golden legacy-config fixtures: every released line's default config dump
(0.1 through 0.5, portable and desktop shapes) is pinned as a fixture and
must load on current code, so old-config upgrades are tested mechanically.
- The Web UI gains an opt-in background-music player. Enabling it (Settings →
Appearance, or the command palette action) reveals a topbar control that
loops tracks from a user-supplied library: `public/music/playlist.local.json`
lists bundled filenames or HTTPS URLs, and a session-only "Choose local
file…" picker plays ad-hoc audio. Track choice, volume, and play state
persist per browser; the feature is off by default and no audio files ship
with the repository (see `opensquilla-webui/public/music/README.md`).
- Attachment resource ceilings: the in-memory staged-upload store now has an
aggregate RAM cap (`attachments.upload_store_max_total_bytes`, default
300 MiB) — when reached, new uploads are rejected with the additive HTTP
507 `UPLOAD_STORE_FULL` (retryable; staged entries expire within the TTL)
instead of evicting staged uploads — and attachment copies materialized
into agent workspaces are bounded by a disk budget
(`attachments.workspace_attachment_disk_budget_bytes`, default 1 GiB) that
degrades new materializations to an unavailable marker without evicting
existing files.
- Provider connection tests now report latency: `onboarding.provider.probe`
returns an additive `latencyMs` field (0 on pre-network failures), the CLI
`models probe --json` rows carry `latency_ms`, and the Web UI setup panel
renders a verdict line — connected state with round-trip latency, live
model count, and sample model ids; failure pills include the latency so a
fast 401 rejection reads differently from a slow timeout.
- `[models.<provider_id>."<model_id>"].context_window` now governs context
budgeting as documented: the catalog resolver consults the per-model
override first (new `override` source), the turn budget ladder, compaction
window, usage pressure (`windowSource: "model_override"`), and router
capability facts all honor it, and the per-model value beats the global
`llm.context_window_tokens`. The Web UI setup panel gains a context-window
override field with an auto-detected/override/effective readout and a
small-window warning for local runtimes.
- Doctor now lints API-key shape: a key that looks like a URL or an
environment-variable name yields a `provider.active.api_key_shape` warning
(shape only — key material never leaves the gateway) with recovery steps.
`providers.status` rows carry the additive `apiKeyShape` field.
- The console Overview can copy the full doctor report as sanitized JSON
(home paths normalized) and hand it to a new agent chat ("Diagnose with
agent", hidden when the provider itself blocks readiness); findings for
provider/channel/router surfaces deep-link into the matching settings
section, and `/settings/provider#provider-<id>` preselects that provider.
- Passive provider latency stats: the agent loop records time-to-first-token
and call duration per provider call into an in-memory rolling window;
`providers.status` rows expose an additive `latency` snapshot
(`p50TtftMs`/`p95TtftMs`/`samples`, gated below minimum sample counts) and
the Overview shows a latency readout for the active provider.
- The chat view warns when a streaming turn produces no content events for
90 seconds (server heartbeats alone no longer look like progress): a
dismissible notice offers keep-waiting or interrupt, and stays quiet while
a tool is executing or an approval is pending.
- Added Tencent TokenHub providers for the Hunyuan hy3 family:
`tencent_tokenhub` (OpenAI-compatible mainland endpoint,
`TENCENT_TOKENHUB_API_KEY`), `tencent_tokenhub_anthropic` (the same
deployment's Anthropic Messages protocol with `x-api-key` auth), and
`tencent_tokenhub_intl` (the international deployment with its own
`TENCENT_TOKENHUB_INTL_API_KEY`). hy3/hy3-preview thinking maps onto the
documented `reasoning_effort` `low`/`high` values plus the thinking enable
object, and assistant `reasoning_content` is replayed across turns per the
hy3 interleaved-thinking contract. The Token Plan subscription is covered
too: `tencent_token_plan` (Chat Completions at
`api.lkeap.cloud.tencent.com/plan/v3`) and `tencent_token_plan_anthropic`
(Anthropic Messages at `/plan/anthropic`, bearer auth), both reading the
dedicated `TENCENT_TOKEN_PLAN_API_KEY` (`sk-tp-…`) plan credential.
- Attachments now accept **any file type** on every surface (Web UI, desktop,
CLI `/file`, channels, RPC). Rendered families (images, PDF, text, Office,
email) keep their extraction and anti-forgery behavior; everything else is
admitted as an *opaque* attachment staged into the agent workspace — the
bytes are never parsed, decompressed, or inlined into a provider prompt, and
the model receives an escaped metadata envelope plus the workspace path.
New config: `attachments.accept_opaque` (default `true`; `false` restores
the legacy fail-closed admission gate) and `attachments.opaque_max_bytes`
(default 30 MiB).
- Text attachments above the 2 MB inline threshold now stage through the
upload endpoint up to 30 MiB when the whole payload is proven UTF-8, so
large LaTeX sources and logs no longer dead-end at "file too large".
- Channel file downloads (Telegram, Discord, Feishu, Matrix) now use staged
per-category ceilings, so archives, voice notes, and videos up to 30 MiB
ingest instead of being silently skipped at a flat 5 MiB unknown-type cap.
- Added Alibaba Cloud IQS (`iqs`) as a runtime-supported web search provider:
unified-search endpoint with freshness, site include/exclude filters, inline
main-text content, and rerank scores, configured via `IQS_SEARCH_API_KEY`.
- Added a runtime development branch sync: request-proof budgeting with
deterministic compaction, DashScope provider profile with prompt-cache
markers and thinking-mode plumbing, an optional LLM trace recorder,
tool-result store compression, sandbox-descriptor integration for
filesystem tools, and a family of default-off, env-lever-controlled
runtime recovery modules.
- Prebuilt multi-arch (`linux/amd64` + `linux/arm64`) container images are
published to GHCR (`ghcr.io/opensquilla/opensquilla`) on release tags,
with a manual dispatch mode that validates the build without publishing.
- `docs/docker.md`: a container deployment guide for home servers and NAS
(Debian 12 walkthrough, prebuilt images, LAN exposure with token auth,
bind-mount ownership, upgrades and rollback).
### Changed
- The per-turn `[Current user request reminder]` message appended after
tool-result turns is now off by default. Re-presenting the objective as a
fresh user message every turn re-triggered model reasoning on nearly every
request (observed 9699% of requests emitting reasoning tokens, vs 1431%
without it) and broke the provider prompt-cache suffix each turn, which in
internal evaluation cut agent throughput roughly 2.5× on long tool-loop
tasks without improving outcomes. Set
`OPENSQUILLA_TURN_OBJECTIVE_REMINDER=on` to restore the previous behavior
byte-identically, or `trim:<chars>` for a shortened variant.
- An explicitly configured `llm.base_url` now wins over the provider's
derived environment variable (`OPENAI_BASE_URL`, `OPENROUTER_BASE_URL`, …),
mirroring the existing api_key rule. Previously the env var silently
overrode a custom endpoint saved in the Web UI or via `config.set` on
every boot/reload, reverting it to the env value (#484). Endpoints that
were never explicitly chosen — a config without `base_url`, or one holding
the provider's default URL — still follow the env var, so fleet-wide
`*_BASE_URL` overrides keep working, and `OPENSQUILLA_LLM_BASE_URL`
(the settings layer) still fills an unset `base_url` and then counts as
explicit. As a side effect, a minimal config such as
`provider = "openai"` without `base_url` now resolves to that provider's
own default endpoint instead of leaking the OpenRouter URL from the model
field default.
- **Security:** the gateway no longer emits CORS headers by default —
`cors.allowed_origins` now defaults to `[]` instead of `"*"`. The Web UI
(served same-origin from the gateway), the CLI, curl, and the desktop app
are unaffected. Deployments that serve a separate frontend from another
origin must list that origin explicitly in `cors.allowed_origins` to
restore the previous behavior; configuring `"*"` together with
`cors.allow_credentials` now logs a boot-time warning.
- **Security:** state-changing gateway HTTP routes (chat send, system
shutdown, approvals settings/resolve, elevated mode, channel logout, file
upload, audio transcription, artifact native open, diagnostics bundle) now
reject browser requests whose `Origin` is not the gateway itself with
`403 FORBIDDEN_ORIGIN`, extending the diagnostics-bundle same-origin guard
to the whole HTTP surface. Requests without an `Origin` header (curl, the
desktop client) and origins listed in `cors.allowed_origins` still pass.
- `compose.yaml` now documents prebuilt-image selection, safe LAN exposure,
and Web UI token auth, and the troubleshooting guide covers common Docker
deployment failures.
- The Web UI composer's file picker no longer sets an `accept=` filter, so
native file dialogs (notably on Windows) show all files instead of hiding
types like `.tex` (#472). The attach-button tooltip in all six locales now
describes the any-type policy.
- Under the default `attachments.accept_opaque = true`, the upload endpoint
no longer returns HTTP 415 `UNSUPPORTED_MEDIA_TYPE` for type reasons and
`sessions.send` no longer raises `unsupported_mime` for unrendered types;
the codes and message formats are unchanged for strict deployments that
disable the flag. Clients that string-matched the "must be one of [...]"
detail should rely on the typed codes instead.
- Transcripts written by this version may contain opaque attachment
envelopes; older builds replay such history with the attachment silently
omitted (current builds emit an omission marker).
- Common non-canonical MIME spellings (`image/jpg`,
`application/x-zip-compressed`, `application/x-gzip`) now normalize to
their canonical types in every mode, so an `image/jpg` upload is accepted
as JPEG even under `accept_opaque = false` where it previously drew a 415.
- Provider retry handling: responses that stop at the length limit without
visible text or tool calls now enter the reasoning-only retry path instead
of the length-capped continuation path, and a thinking-related provider
stream error now disables thinking for the next call only (one-shot)
instead of the rest of the turn.
- Context compaction: `read_file` and `git_diff` results are preserved
verbatim (exempt from semantic projection and aggregate compaction), tool
results already shown in full are no longer retroactively compacted under
context pressure, and compaction placeholders gained `preview_complete`
plus retrieval hints.
- Tool dispatch: a preflight validation pipeline now rejects malformed tool
calls before execution and reports invalid tool arguments as retryable;
`write_file` refuses destructive overwrites that would shrink an existing
large workspace file by more than half; `grep_search` output gained a
header, offset paging, VCS-directory exclusion, and binary-file skipping;
`edit_file` accepts single-edit shorthand and recovers from near-miss
matches by default; `apply_patch` accepts `@@` hunks with optional counts.
- The `coding` tool profile now enforces fresh workspace reads before edits.
- `match_workspace_write_deny` deny patterns now apply only to
workspace-contained paths (previously they could match paths outside the
workspace).
- Request-proof compaction marks compacted tool arguments with inline
`[provider_request_tool_input_compacted: ...]` markers instead of a JSON
envelope.
### Fixed
- The desktop gateway now receives the OpenSquilla home root (not the state
subdirectory) in `OPENSQUILLA_STATE_DIR`; a one-time relocation moves the
previously nested skills, workspace, session archive, router data, and
`.env` up to their intended locations without touching the databases.
- Legacy configs no longer hard-fail strict validation: stale
`memory.dream.model_override` keys are stripped, channel entries with
unregistered types are parked with a warning instead of rejecting the
file, a `squilla_router.tier_profile` that no longer matches
`llm.provider` is cleared, and an unwritable config location degrades the
post-migration rewrite to a warning instead of failing boot.
- The desktop shell resolves the UI language from the OS preference list
correctly: English tags now match in place instead of falling through (a
Hong Kong list like `en-HK, zh-Hans-HK, …, fr-HK` previously landed on
French), and an explicit `Hans` script subtag wins over a
Traditional-default region, so `zh-Hans-HK`/`zh-Hans-TW` readers get
Simplified Chinese instead of the English fallback. The resolver is
extracted to `desktop/electron/src/desktop-locale.ts` with a regression
suite (`npm run test:desktop-locale`).
- Dream (memory consolidation) now resolves its provider credentials through
the shared explicit-config-first resolver: previously `OPENROUTER_API_KEY` /
`OPENROUTER_BASE_URL` in the gateway environment unconditionally overrode
the configured key and endpoint — even when the configured provider was not
OpenRouter — silently redirecting Dream turns away from the operator's
endpoint.
- Fixed managed-network sandbox domain grants missing the Bocha, Tavily, and
Exa search API hosts: `web_search` runs with those providers active were
blocked under the managed-network sandbox. All runtime search providers now
have system domain grants and default search-allowlist entries, enforced by
a contract test.
- Byte-level text sniffing no longer misclassifies clean UTF-8 payloads as
binary when a multibyte character straddles the sniff peek window (affected
CJK plain-text uploads with unrendered MIME claims).
- Fixed secret redaction missing assignment values that start with a quote
(for example `password: "..."`); quoted values are now masked in memory
persistence and trace capture paths.
## [0.5.0rc2] - 2026-07-06
### Added
- Added clearer provider/router setup surfaces, config provenance RPC coverage,
preset registry behavior, custom provider substrate, and cross-provider router
settings for the 0.5 preview line.
### Changed
- Kept fresh installs on the direct single-model `squilla_router` path while
making front-end-enabled ensemble mode default to `static_openrouter_b5`.
- Refined desktop onboarding, session empty states, ensemble progress display,
and packaged Web UI assets for the Preview 2 release surface.
### Fixed
- Fixed main CI contract drift around default router mode, migration/provider
persistence, static-B5 doctor checks, and onboarding status wire fields.
- Fixed code-task scaffold prompts so build-mode scaffolding stays
non-interactive.
- Fixed expired staged Web UI uploads by refreshing file UUIDs before send when
the original file remains available.
- Fixed local HTML artifact opening, desktop reopen behavior, cancelled-turn
rollback, sandbox denial resume recovery, composer draft persistence, session
cleanup, attachment isolation, and several provider/router recovery paths.
## [0.5.0rc1] - 2026-07-04
### Added
- Added dynamic Model Ensemble routing, OpenAI/Codex-oriented provider support,
direct single-model routing defaults, and progressive reveal behavior so
preview users can test the new routing line before the next stable release.
- Added managed execution host-routing paths and sandbox/approval alignment for
safer terminal, desktop, and host-execution workflows.
- Added Control UI and desktop affordances for router/provider settings,
drag-and-drop attachments, history materialization, and image preview
navigation.
- Added OpenTUI preview improvements for terminal and gateway workflows.
### Changed
- Preview release assets now publish Electron desktop installers, updater
metadata, a versioned Python wheel, and `SHA256SUMS`; new 0.5 preview releases
no longer publish Windows portable zips or portable latest aliases.
- Sandbox run modes, approval boundaries, and managed host execution now share
clearer authorization and diagnostics across Windows, Linux, and desktop
sessions.
- Desktop update, privacy, code-signing, and release documentation now describe
the preview asset set and portable retirement path.
### Fixed
- Improved Windows subprocess encoding, process cleanup, gateway lifecycle
diagnostics, router timeout handling, and packaged desktop runtime checks.
- Fixed desktop/Web UI recovery cases around settings restore, refreshed
sessions, image preview movement, attachment handling, and provider/router
visibility.
## [0.4.1] - 2026-06-30
### Added
- `opensquilla uninstall` removes OpenSquilla across install methods (uv-tool,
pip, pipx, and source; portable removes its venv; Docker and desktop print
guided removal steps). It keeps your data by default — pass `--purge-state`,
`--purge-config`, or `--purge-all` to delete it (a total wipe requires a typed
confirmation phrase on every surface), and `--dry-run` / `--json` preview the
exact remove/keep/manual plan without touching anything. Deletion is contained
to the OpenSquilla home; a relocated or shared root is refused. The desktop
Settings → Runtime panel gains a matching "Danger zone".
- The Control UI and desktop client now ship first-class localization for
English, Simplified Chinese, Japanese, French, German, and Spanish, including
first-paint desktop boot text, settings surfaces, and persisted language
selection.
### Changed
- Stopping, restarting, or uninstalling the gateway now drains in-flight agent
turns and background completions before exit instead of cutting them off
mid-write. The force-kill deadline exceeds the drain budget (tunable via
`OPENSQUILLA_GATEWAY_GRACEFUL_TIMEOUT`); on Windows, an owner-only
`POST /api/system/shutdown` endpoint provides the same graceful stop where
POSIX signals are unavailable.
- OpenSquilla now treats `main` as the active development and release
integration branch, with release guidance and pull-request targeting language
aligned around `main`.
- Desktop packaging now fails fast when SquillaRouter assets are missing or
still Git LFS pointer files, and the packaged gateway smoke tests exercise
coding mode, `code-task`, and the router runtime before release assets are
uploaded.
### Fixed
- Install telemetry now skips CI and test environments before creating local
telemetry state or uploading install events, so GitHub Actions and pytest
runs do not count as user installs.
- The Windows Electron app, installer, and uninstaller now use the OpenSquilla
icon.
- Web UI fixes keep streaming turns, stale task events, session deletion,
settings restore, topbar connection state, status line breaks, and desktop
HTML artifact opening stable across refreshed sessions.
- Provider and router fixes improve Volcengine and BytePlus profiles, macOS
router runtime diagnostics, and the target labels shown for model requests.
## [0.4.0] - 2026-06-27
### Added
- The refreshed Control UI is now the default browser console, with a
conversation-first sidebar, Settings modal, Sessions ledger, artifact
previews, share export, deliverables drawer, turn trace, mobile tabs, and
clearer Skills, Usage, Cron, Logs, and Approvals surfaces.
- Signed desktop release assets are now available for the Vue/Electron desktop
shell: a notarized macOS Apple Silicon DMG/ZIP and a Windows x64 NSIS
installer.
- Coding mode and the `code-task` workflow provide a guarded path for code
changes: code work runs through an isolated run directory, uses trusted-host
confirmation, and verifies before persisting changes back to the source.
- `opensquilla swebench` adds an optional SWE-bench evaluation surface for
users who install `opensquilla[swebench]` and have Docker available.
- OpenTUI preview backend documentation now covers explicit opt-in usage,
dependency setup, replay benchmarks, and real-terminal harness evidence.
- Web search now supports DuckDuckGo, Bocha, Brave, Tavily, and Exa through the
runtime provider catalog, with source-backed `web_search` and lightweight
`web_discover` roles documented for agent workflows.
- `openai_responses` exposes OpenAI's native Responses API shape alongside
`openai`, sharing `OPENAI_API_KEY` and the default OpenAI base URL while
making Responses-specific behavior selectable by provider id.
### Changed
- MetaSkills are now **manual-only by default**. They no longer auto-trigger from
message keywords or appear in the runtime prompt; run them explicitly with the
new `/meta` command (`/meta` lists available MetaSkills, `/meta <name>` runs
one). To restore the previous automatic behavior, set
`meta_skill.auto_trigger = true`. Full list+run is available in web chat and
the CLI gateway TUI; channel and standalone CLI surfaces support `/meta`
listing only.
- Terminal chat documentation now distinguishes the stable Python-native default
backend from the opt-in OpenTUI preview backend.
- Search configuration now treats `search_provider` as the credential anchor for
a configured key rather than a hard routing promise for automatic searches.
- The old Windows portable zip remains published as a legacy compatibility
build, while new Windows desktop users should prefer the Electron installer.
### Fixed
- Provider stream parsing accepts no-space SSE data lines, and Gemini thought
signatures are preserved across tool turns so provider continuity metadata can
be replayed safely.
- The SSRF guard now gives operators actionable fake-IP DNS guidance while
keeping private, loopback, link-local, and internal ranges blocked by default.
- Runtime, gateway, and Web UI fixes improve session recovery, attachment and
artifact handling, approval event delivery, share-image export, router
visibility, and cross-platform test stability.
### Acknowledgements
- Thanks to the 0.4.0 contributors recorded in
[`CONTRIBUTORS.md`](CONTRIBUTORS.md), including PR work from @ab2ence,
@myz-ah, @nice-code-la, @openvictory, @weiconghe, @changquanyou, @nkgotcode,
@C1-BA-B1-F3, @BlueOcean223, @szdtzpj, @lose4578, and cwan0785
(GitHub @Anonymous-4427).
## [0.3.1] - 2026-06-03
### Added
- Slack Socket Mode support now covers app mentions, self-targeting replies,
channel metadata, and threaded response routing across onboarding and channel
runtime paths.
- Short-drama and video helper workflows remain available in the bundled
MetaSkill catalog, with stronger Windows-safe script handling and clearer
review pauses for generated media flows.
- CI impact-surface gates classify docs, runtime, dependency, release, and test
changes so pull requests can run the right checks without forcing the full
matrix for every documentation-only edit.
### Changed
- WebChat and the Skills view now make MetaSkill readiness, active runs, and
install visibility easier to inspect while workflows are being reviewed.
- Release install documentation and installer defaults now point to the 0.3.1
wheel and Windows portable asset names.
### Fixed
- User chat bubbles preserve multiline text and read like authored messages
instead of collapsing or visually blending with generated output.
- Slack onboarding and runtime paths now reject incomplete Socket Mode setup,
preserve existing secrets, enforce webhook signing secrets where needed, and
keep threaded reply channel context.
- Voice/audio workflows and clarification pauses are represented on the main
release line, so release users get the same usable handoff and resume
behavior already validated on integration branches.
- Provider request hardening keeps malformed tool-call history from reaching
providers as invalid request state.
### Acknowledgements
- Thanks @openvictory for #123, #133, and #137, which helped bring visible
running-state feedback plus short-drama and media helper workflows into the
0.3.1 release line.
- Thanks @freeaccount-create for #142, which helped bring Slack Socket Mode and
self-targeting replies into the channel workflow.
- Thanks @ruhook for #124, and thanks @qq712696307 for the authored commit in
that pull request, which preserved user message newlines in WebChat.
- Thanks @Cola-Alex for #143, which increased tokenjuice summarize and
failure-context windows for fallback tool-result projection.
- Thanks @nice-code-la for #165 and #166, which helped make voice workflows
usable end to end and clarification pauses resume cleanly.
## [0.3.0] - 2026-05-31
### Added
- MetaSkills are now first-class workflow capabilities: bundled stable
MetaSkills, composition parsing, step scheduling, pause/resume user-input
flows, proposal gates, runtime history, and authoring documentation let
repeatable multi-step work become reusable agent routines.
- `opensquilla doctor` and the WebUI Health view now provide actionable
readiness diagnostics across provider, gateway, memory, logs, search, image
generation, router, channels, sandbox, and embedding surfaces.
- Tokenjuice-backed tool-result projection now compacts large logs, diffs,
JSON, test output, package-manager output, and other known tool shapes before
they crowd out provider context.
- A task-oriented documentation set now covers quickstart, configuration,
WebUI, CLI, tools and sandboxing, sessions, providers, usage and cost,
memory, compaction, MetaSkills, tool compression, scheduling, channels, MCP,
troubleshooting, and contribution guidance.
### Changed
- Tool-output context management now separates durable runtime results from
provider-visible compact previews, records projection telemetry, and uses
provider request proof/compaction before oversized payloads reach an LLM.
- WebChat, CLI chat, and terminal TUI internals now share more runtime-backed
turn, stream, slash-command, artifact, attachment, and recovery behavior.
- Long-session memory and compaction flows now preserve raw archive evidence,
checkpoint receipts, repair queues, and WebUI-safe compaction status instead
of treating semantic memory quality and context safety as the same signal.
- Channel install extras now expose only real optional packages; Feishu,
Telegram, DingTalk, WeCom, and QQ are included in the base install instead
of being accepted as no-op extras.
### Fixed
- WebChat reliability fixes cover router replay, session restore gaps,
duplicate compaction status, attachment and pasted-text rendering, artifact
downloads, composer layout, model-router animation timing, and visible
recovery during long turns.
- Provider and runtime hardening reduces malformed tool-call fallout, preserves
configured model-switch intent, handles provider tool-choice requirements,
and keeps oversized current-turn tool payloads from surfacing as bare
internal failures.
- Cross-platform CI and Windows portability fixes stabilize CLI help rendering,
sqlite fallback behavior, UTF-8 subprocess handling, Windows-only test
fixtures, onboarding commands, and release-surface checks.
## [0.2.1] - 2026-05-21
### Changed
- WebUI diagnostics, transcript replay, and artifact presentation now retain
more turn-usage evidence while keeping generated-file markers out of normal
chat output.
- Long-running agent turns now expose softer recovery paths for exhausted tool
budgets, repeated tool failures, large file-write attempts, and artifact
delivery handoffs when the final model response degrades.
- Release metadata, installer defaults, and documented wheel URLs now point to
the 0.2.1 release line.
### Fixed
- Windows portable startup now includes a stronger Visual C++ runtime bootstrap
path for the bundled ONNX router.
- Memory semantic recall now normalizes stored and query embeddings before
sqlite-vec search, and high-confidence lexical matches are preserved even
when vector scoring is weak.
- Generated artifact placeholder text is removed from WebChat history and
channel-facing output after files have already been delivered.
- Tool dispatch and result budgeting reduce bare internal budget failures by
returning model-visible recovery context when a turn can still continue.
### Acknowledgements
- Thanks @nice-code-la for the portable Windows VC++ runtime bootstrap work in
#52.
## [0.2.0] - 2026-05-20
### Added
- `opensquilla migrate` imports existing OpenClaw/Hermes homes into OpenSquilla
with dry-run previews, explicit `--apply`, source auto-detection, migration
reports, memory/persona conflict handling, skill compatibility reporting, and
MCP/channel config mapping.
- `opensquilla chat` is now an early usable interactive CLI chat surface with a
persistent terminal UI, streaming output, queued input, slash-mode discovery,
prompt/status chrome, tool-call feedback, inline approval handling, and
deterministic live prompt output.
- Cron automation now spans CLI, WebUI, RPC, channel, and webhook surfaces:
structured schedule creation, timezone-aware cron/every/at schedules,
exact/jitter controls, manual runs, channel delivery, webhook delivery, and
failure destinations.
- Feishu and Discord channel support now includes capability manifests, safer
DM/group policy metadata, native file/artifact paths, attachment ingestion,
Feishu websocket/webhook handling, Discord thread/group handling, and clearer
channel health/status reporting.
- OpenSquilla can run as an inbound MCP server bridge for session workflows:
clients can list sessions, resolve/read conversation history, send messages,
and wait for session events through the gateway.
- Generated artifact delivery is more complete across WebUI and channels,
including traceable generated-file delivery, recovered fallback delivery,
channel-safe artifact text, and Unicode-safe PDF report rendering.
- Memory surfaces now separate curated memory from raw transcript search, recall
prior-session evidence, keep manual Dream runs on configured memory
workspaces, and let compaction continue when memory flush degrades.
- Release/install support now includes versioned release URLs,
latest-download aliases, reproducible wheel/portable release guidance,
source install scripts, Windows portable hardening, ONNX/router recovery
messaging, and Docker/compose alignment on the gateway port.
### Changed
- Release installation docs now use 0.2.0 release asset URLs and
`/releases/latest/download/` aliases for the current wheel and Windows
portable zip.
- Cron tool calls now require structured schedule input (`{kind, ...}`) instead
of backend natural-language schedule parsing. CLI cron flags still accept
standard user-facing forms such as `--cron`, `--every`, `--at`, and
`--expression` through RPC compatibility paths.
- Tool dispatch now runs through a policy pipeline with side-effect-aware
concurrency: safe/read-only tools can batch, while mutating or
side-effecting tools stay serialized, keyed, or capped.
- Long-running agent turns now use staged `TurnRunner` execution, provider
request-budget compaction, prompt-cache anchor preservation, bounded
tool-result storage, approval-aware retry handling, and recovery paths for
malformed or non-executable tool calls.
- Channel adapters now declare normalized capability and error-taxonomy
metadata so unsupported, degraded, retryable, and fatal channel behavior is
surfaced more consistently.
- WebUI chat, sessions, usage, setup, cron, and search surfaces now share more
runtime-backed state for recency ordering, per-turn token metrics, provider
badges, setup form behavior, and session cancellation/readback.
- The default gateway/release documentation now centers on port `18791`, and
release download paths use the current 0.2.0 assets.
### Fixed
- Failed or aborted turns are kept out of later provider context, reducing
cascading failures after a bad turn.
- Approval-gated tool retries wait for operator decisions instead of exposing
pending approval state as ordinary model-visible tool output.
- Provider-context tool markers are protected from becoming executable tool
state.
- High-volume tool and chat turns recover more reliably from request-tail bloat,
current-turn tool overflow, and provider payload budget pressure.
- Channel replies avoid leaking provider compaction markers, and channel cron
delivery now reports failures explicitly.
- WebUI reliability fixes cover recency ordering, table boundaries, mobile and
composer layout, duplicate visible toasts, setup form resilience, search
provider badges, and session cancellation counters.
- Generated files that were omitted from normal delivery can be recovered
through the artifact fallback path.
- Feishu-delivered PDF reports now render Unicode text instead of black or
placeholder glyph blocks.
## [0.1.0rc1] - 2026-05-12
### Added
- OOTB startup path: `compose.yaml` + `start.sh` + `start.ps1` + Quickstart section in README.
- Legacy `memory.*` config fields: 16 deprecated keys silently dropped with a single aggregated `DeprecationWarning`.
- Agent CLI no-key error: three-section actionable panel (Symptom / Cause / Next steps).
- Tool concurrency: same-turn safe `tool_calls` dispatched concurrently via `asyncio.gather` (22 safe tools enrolled in `_SAFE_TOOL_NAMES`; mutex tools remain serial).
- PID file lock to prevent two gateway instances from sharing the same state directory.
- Core observability counters: `opensquilla_queue_depth`, `in_flight_turns_total`, `turn_cancellations_total`, `queue_full_errors_total`.
- CI matrix on `ubuntu-latest` and `windows-latest` × Python 3.11/3.12, including a metric-name drift check and a tracemalloc leak smoke step.
- Per-channel-adapter in-flight reply cap (`_ChannelInFlightSet`) so a single channel cannot exhaust the global concurrency budget.
- Cross-session fair queueing: sessions sharing an `agent_id` round-robin available slots by completion count.
- Session epoch counter so events from a pre-reset turn are discarded by the frontend after `session.reset`.
- Atomic write helper for transcript attachments (`_atomic_write_bytes`): tmp + fsync + `os.replace`.
- Concurrency env overrides — `OPENSQUILLA_TASK_MAX_CONCURRENCY` and `OPENSQUILLA_CHANNEL_INFLIGHT_CAP` — with invalid-value fallback and warning logs.
### Changed
- Internal SquillaRouter package moved from `opensquilla.contrib.squilla_router`
to `opensquilla.squilla_router`; bundled model assets now live under
`src/opensquilla/squilla_router/models/`.
- `TurnRunner` and `TaskRuntime` share a single per-session `asyncio.Lock` (injected via `session_lock_provider`), removing the two-layer lock dictionary and the reverse-acquire risk it created.
### Fixed
- Channel adapter ghost-turn bug: a `TaskQueueFullError` no longer leaves a dangling user message in the transcript.
- `TaskRuntime` terminal-state dictionary leak across `_tasks`, `_session_locks`, and `_pending_by_session`.
+30
View File
@@ -0,0 +1,30 @@
# Code of Conduct
OpenSquilla welcomes focused, respectful participation from contributors and
users.
## Expected Behavior
- Keep discussions technical, specific, and constructive.
- Assume maintainers and contributors are working with incomplete context.
- Respect different experience levels and communication styles.
- Disclose conflicts of interest when they affect a technical recommendation.
- Keep security-sensitive details out of public threads.
## Unacceptable Behavior
- Harassment, threats, personal attacks, or discriminatory language.
- Publishing another person's private information.
- Repeated off-topic posting after a maintainer has redirected the discussion.
- Sharing credentials, exploit details, or account identifiers in public issues.
- Misrepresenting project affiliation or maintainer decisions.
## Enforcement
Maintainers may edit or hide comments, close issues, restrict participation, or
escalate a report when behavior disrupts the project or creates risk for users.
For security-sensitive reports, follow `SECURITY.md`. For conduct concerns that
are not security-sensitive, contact the maintainers through the repository's
normal support channels with a concise description and links to relevant public
activity.
+117
View File
@@ -0,0 +1,117 @@
# Contributing
Thanks for improving OpenSquilla. Keep pull requests small, focused, and covered by tests that outside contributors can run without private access.
## Target Branch
Open pull requests against `main` by default. OpenSquilla now uses `main` as the active integration branch for feature work, bug fixes, tests, documentation, and contributor changes.
Use `release/*`, `hotfix/*`, `staging/*`, `integration/*`, `sandbox-*`, or a
maintainer-approved staging/collaboration label only when maintainers request a
temporary collaboration branch. When in doubt, target `main`.
## Linked Issues
Declare issue relationships in pull request descriptions with GitHub keywords:
- Use `Fixes #123`, `Closes #123`, or `Resolves #123` when the pull request is intended to fix the issue.
- Use `Refs #123` when the pull request is related but should not move the issue toward closure.
- Use `None` when no public issue is linked.
OpenSquilla keeps issue closure tied to the default branch. Merging a fixing
pull request into `main` removes the linked-pull-request marker so the issue
can follow GitHub's normal closing flow. Maintainers may use `has-linked-pr`
while work is still under review. If a linked pull request is closed without
merging, the automation removes `has-linked-pr`.
## Reporting Bugs
Use the bug-report issue template. Attach a diagnostics bundle
(`opensquilla bundle`, or the Web UI / desktop download button — see
`docs/troubleshooting.md`); it is redacted by default and safe to share. Quote
any `(ref: …)` code from the error message you saw. For suspected
vulnerabilities use SECURITY.md instead.
## Attribution On Squash Or Replay
When maintainer cleanup, replay, or squash merging collapses contributor
commits, keep the final non-empty commit attributable with `Co-authored-by:`
trailers for every human contributor whose work is included. Preserve pull
request author attribution and commit author attribution separately when they
differ.
If an older squash, replay, or follow-up pull request dropped contributor
attribution, do not rewrite protected branch history only to repair it. Open a
focused attribution repair pull request instead: update `CONTRIBUTORS.md` or the
release notes with affected pull requests and evidence, and include
`Co-authored-by:` trailers on the repair commit for missing human contributors
when GitHub can associate those emails. Do not treat "already appears in GitHub
contributors" as complete repair; that confirms global account representation,
not attribution for the specific squashed or replayed work.
## Default Checks
Install development dependencies:
```powershell
uv sync --extra dev --extra recommended
```
Run the public quality gate before opening a pull request:
```powershell
uv run ruff check src tests
uv run pytest -q
uv build --wheel
```
Default tests must be offline, deterministic, credential-free, and safe for forks. Do not add network, provider, browser, or channel requirements to the default pull request path.
## Test Expectations
Add or update public regression tests for behavior changes and bug fixes. Prefer focused unit or integration tests unless the behavior crosses the gateway, browser UI, provider, or channel boundary.
Live checks are maintainer-only gates. The `Live Release E2E` workflow covers real provider, browser, and optional channel smoke tests with GitHub secrets and explicit opt-in inputs.
## Private Materials
Private test suites, release red-team prompts, real provider transcripts, real channel identifiers, local paths, credentials, and AI session artifacts must not be committed.
Local maintainer-only files may live under `tests/_private/` or `.omx/private-golden/`; both are excluded from the public tree and default pytest collection.
## Third-Party Origins
Declare any third-party origin in the pull request. If no third-party material is
involved, say `none`. If there is any uncertainty, use the more conservative
category and let maintainers narrow it during review.
- `inspired-by`: only the idea influenced the change; no code, rules, fixtures,
structure, or copied text is reused.
- `adapted/ported`: OpenSquilla re-expresses upstream behavior, rules, or
structure in OpenSquilla code.
- `vendored`: upstream source is copied into the repository with minimal or no
changes.
- `direct dependency`: OpenSquilla depends on an external package through
`pyproject.toml` or another package manager.
- `modified upstream`: vendored upstream source is patched or otherwise changed
in the OpenSquilla tree.
For `adapted/ported`, `vendored`, and `modified upstream` material, include the
upstream URL, license, copyright notice, and any required changes to
`THIRD_PARTY_NOTICES.md` or a local provenance file in the same pull request.
For direct dependencies, note the package name and license so maintainers can
audit redistribution and release-bundle obligations.
Permissive licenses such as Apache-2.0, MIT, MIT-0, BSD, ISC, and compatible
public-domain-equivalent grants are usually acceptable. GPL, AGPL, LGPL, SSPL,
source-available, custom commercial, or unclear licenses require explicit
maintainer approval before code, rules, fixtures, or adapted implementations are
merged.
## Security Reports
Do not include vulnerability details, exploit steps, credentials, or provider tokens in public issues. Use the process in `SECURITY.md` for suspected vulnerabilities.
## Community Standards
Keep discussion technical, specific, and respectful. The expected conduct for issues, pull requests, and maintainer decisions is documented in `CODE_OF_CONDUCT.md`.
+140
View File
@@ -0,0 +1,140 @@
# OpenSquilla Contributors
OpenSquilla uses GitHub pull requests, commits, release notes, and this
human-readable ledger together for contributor attribution. This file records
release-surface community work that can be harder to see when a release is
squash-merged or replayed onto `main`.
## Attribution Repairs
### PR #46 release-candidate sync
The release-candidate sync in [#46](https://github.com/opensquilla/opensquilla/pull/46)
collapsed community-authored work from `dev` into
[2158f56](https://github.com/opensquilla/opensquilla/commit/2158f56c1a0684168a013b4b4846233977d0067b)
without co-author trailers for the human commit authors below. This ledger entry
repairs the project-facing attribution record without rewriting protected branch
history. Later suspected cases were rechecked separately; their human authors
are represented on `main` by equivalent authored commits and/or co-author
trailers.
| Contributor | Repaired attribution | Evidence |
| --- | --- | --- |
| [@openvictory](https://github.com/openvictory) | README update carried into the 0.2.0rc1 release candidate. | [#46](https://github.com/opensquilla/opensquilla/pull/46), [`ff4bbec9`](https://github.com/opensquilla/opensquilla/pull/46/commits/ff4bbec93523582d893c7123421f7dc292bb6e38) |
| [@nice-code-la](https://github.com/nice-code-la) | DeepSeek reasoning replay fixes, Moonshot/Kimi routing defaults, and migration-importer replay work. | [#46](https://github.com/opensquilla/opensquilla/pull/46), [`4791ca5e`](https://github.com/opensquilla/opensquilla/pull/46/commits/4791ca5e04cf959a1dd57a0b076d2945677b89ed), [`80d2c17e`](https://github.com/opensquilla/opensquilla/pull/46/commits/80d2c17e9a62cf7d1d0a77b90fb7780e602eb425), [`15db2577`](https://github.com/opensquilla/opensquilla/pull/46/commits/15db25776f2233819f4ac229dd04ad807c584e23), [`04999013`](https://github.com/opensquilla/opensquilla/pull/46/commits/049990130d3607f076d9650db3d8bd92addf5a48), [`0aa075ac`](https://github.com/opensquilla/opensquilla/pull/46/commits/0aa075ac9aa758ac7d8c07793199e50ddaaae59a), [`3edc56a6`](https://github.com/opensquilla/opensquilla/pull/46/commits/3edc56a66a1392cf029ca926ff101ebbf784b3df) |
| cwan0785 (commit author name; GitHub account: [@Anonymous-4427](https://github.com/Anonymous-4427)) | Host-execution grant handling, quoted chat attachment parsing, and provider/tool edge hardening. | [#46](https://github.com/opensquilla/opensquilla/pull/46), [`af600aa5`](https://github.com/opensquilla/opensquilla/pull/46/commits/af600aa5eacbd8e3264b7f4258402acbdaaa8c36), [`eacbb2fb`](https://github.com/opensquilla/opensquilla/pull/46/commits/eacbb2fbe08e67231b5c793090726693a327b769), [`e301ec76`](https://github.com/opensquilla/opensquilla/pull/46/commits/e301ec764c560f57bfd0e39f3387e42369d73a01) |
| [@ab2ence](https://github.com/ab2ence) | macOS Seatbelt backend execution, denial escalation, and release-candidate type-check cleanup. | [#46](https://github.com/opensquilla/opensquilla/pull/46), [`fb1e6225`](https://github.com/opensquilla/opensquilla/pull/46/commits/fb1e6225e4db9cb0801ea347a89c2066e3e0601b), [`f73ac3eb`](https://github.com/opensquilla/opensquilla/pull/46/commits/f73ac3eb0044c64c79cfd18f9ec03d1bba9128ff), [`cf3b046f`](https://github.com/opensquilla/opensquilla/pull/46/commits/cf3b046f42a42efc951320b0af80e9d066dcf7d2) |
| [@kimjune01](https://github.com/kimjune01) | Provider stream timeout cleanup fix that prevents double-closing provider streams. | [#46](https://github.com/opensquilla/opensquilla/pull/46), [`06e3126d`](https://github.com/opensquilla/opensquilla/pull/46/commits/06e3126d8ebda4ad4cf349ca7be0d0804e0c008d) |
## OpenSquilla 0.5.0rc3
The 0.5.0 Preview 3 release records new human contributor work after the
0.5.0 Preview 2 release. It intentionally does not repeat contributors whose
new work is not present in this release range.
| Contributor | 0.5.0 Preview 3 contribution | Evidence |
| --- | --- | --- |
| [@ab2ence](https://github.com/ab2ence) | Fixed desktop gateway boot recovery so the packaged client can recover cleanly from failed startup. | [#491](https://github.com/opensquilla/opensquilla/pull/491) |
| [@JarvisPei](https://github.com/JarvisPei) | Corrected desktop OS-language resolution and added the opt-in Control UI background-music player. | [#550](https://github.com/opensquilla/opensquilla/pull/550), [#556](https://github.com/opensquilla/opensquilla/pull/556) |
| [@labulalala](https://github.com/labulalala) | Improved Windows source-installer PATH setup and added actionable shell guidance. | [#502](https://github.com/opensquilla/opensquilla/pull/502) |
| [@Liu-RK](https://github.com/Liu-RK) | Fixed Control UI token deep links and zero-output chat turns, and aligned sandbox file-access approvals. | [#486](https://github.com/opensquilla/opensquilla/pull/486), [#506](https://github.com/opensquilla/opensquilla/pull/506), [#526](https://github.com/opensquilla/opensquilla/pull/526) |
| [@lyteen](https://github.com/lyteen) | Contributed the original router self-learning work adopted and hardened for the opt-in feedback and retraining loop. | [#212](https://github.com/opensquilla/opensquilla/pull/212), [#511](https://github.com/opensquilla/opensquilla/pull/511) |
| [@nice-code-la](https://github.com/nice-code-la) | Added verified Squilla Router presets for coding providers. | [#560](https://github.com/opensquilla/opensquilla/pull/560) |
| [@TUOXI293](https://github.com/TUOXI293) | Improved chat scroll retention, code and skill-detail copy/inspection, compact tool traces, the Electron dark title bar, and Windows native-theme behavior. | [#487](https://github.com/opensquilla/opensquilla/pull/487), [#488](https://github.com/opensquilla/opensquilla/pull/488), [#509](https://github.com/opensquilla/opensquilla/pull/509), [#516](https://github.com/opensquilla/opensquilla/pull/516), [#524](https://github.com/opensquilla/opensquilla/pull/524), [#545](https://github.com/opensquilla/opensquilla/pull/545) |
## OpenSquilla 0.5.0rc2
The 0.5.0 Preview 2 release records new human contributor work after the
0.5.0 Preview 1 release. It intentionally does not repeat the earlier 0.5.0rc1
or 0.4.x contributor lists.
| Contributor | 0.5.0 Preview 2 contribution | Evidence |
| --- | --- | --- |
| [@HuaXiawithMoon](https://github.com/HuaXiawithMoon) | Kept `code-task` build scaffolding non-interactive by switching the runner-owned Electron/Vite scaffold to the package-supported skip flag. | [#473](https://github.com/opensquilla/opensquilla/pull/473) |
## OpenSquilla 0.5.0rc1
The 0.5.0 Preview 1 release records new human contributor work after the
0.4.1 release. It intentionally does not repeat the earlier 0.4.x contributor
lists.
| Contributor | 0.5.0 Preview 1 contribution | Evidence |
| --- | --- | --- |
| [@ab2ence](https://github.com/ab2ence) | Added drag-and-drop attachments, dynamic Model Ensemble routing, and ensemble timeout tuning. | [#388](https://github.com/opensquilla/opensquilla/pull/388), [`bc9ab2fe`](https://github.com/opensquilla/opensquilla/commit/bc9ab2fe), [#454](https://github.com/opensquilla/opensquilla/pull/454) |
| [@Liu-RK](https://github.com/Liu-RK) | Aligned sandbox run-mode authorization and approval behavior, then fixed managed execution host routing. | [#412](https://github.com/opensquilla/opensquilla/pull/412), [#450](https://github.com/opensquilla/opensquilla/pull/450) |
| [@TUOXI293](https://github.com/TUOXI293) | Added image preview navigation. | [#447](https://github.com/opensquilla/opensquilla/pull/447) |
| [@tqangxl](https://github.com/tqangxl) | Improved gateway lifecycle conflict diagnostics and promoted SQLAlchemy to a core dependency. | [`1fede3ea`](https://github.com/opensquilla/opensquilla/commit/1fede3ea), [`eb6776f2`](https://github.com/opensquilla/opensquilla/commit/eb6776f2) |
| [@HuaXiawithMoon](https://github.com/HuaXiawithMoon) | Fixed WeCom AI Bot websocket mode. | [`94e4b1c1`](https://github.com/opensquilla/opensquilla/commit/94e4b1c1) |
## OpenSquilla 0.4.1
The 0.4.1 release records new human contributor work after the 0.4.0
attribution ledger was merged. It intentionally does not repeat the larger
0.4.0 contributor list.
| Contributor | 0.4.1 contribution | Evidence |
| --- | --- | --- |
| [@ab2ence](https://github.com/ab2ence) | Hardened install telemetry so CI and test environments are not counted as installs, and allowed desktop HTML artifacts to open natively. | [#348](https://github.com/opensquilla/opensquilla/pull/348), [#355](https://github.com/opensquilla/opensquilla/pull/355) |
## OpenSquilla 0.4.0
The 0.4.0 release is prepared from current `dev` after `v0.3.1`. This section
records non-Open-Squilla contributor work with pull-request evidence in that
range. Some work was replayed or carried through Open-Squilla integration pull
requests; those rows name the original contributor and cite both the original
pull request and the integration pull request when useful.
| Contributor | 0.4.0 contribution | Evidence |
| --- | --- | --- |
| [@ab2ence](https://github.com/ab2ence) | Control UI migration and stabilization work, share-image export, Web Chat slash-input handling, bundled AwesomeWebpage MetaSkill work, the Coding mode toggle, and desktop gateway startup plus install telemetry hardening carried into `dev`. | [#264](https://github.com/opensquilla/opensquilla/pull/264), [#274](https://github.com/opensquilla/opensquilla/pull/274), [#177](https://github.com/opensquilla/opensquilla/pull/177), [#173](https://github.com/opensquilla/opensquilla/pull/173), [#313](https://github.com/opensquilla/opensquilla/pull/313), [#320](https://github.com/opensquilla/opensquilla/pull/320) |
| [@myz-ah](https://github.com/myz-ah) | Added the `code-task` workflow for isolated, runner-verified code changes behind Coding mode and improved Web UI LaTeX formula rendering. | [#311](https://github.com/opensquilla/opensquilla/pull/311), [#318](https://github.com/opensquilla/opensquilla/pull/318) |
| [@nice-code-la](https://github.com/nice-code-la) | Skills readiness in the Web UI, MetaSkill progress and clarification UX, manual-only `/meta` behavior, scoped MetaSkill run-history reads, router fallback/default refresh work, image follow-up routing gates, from-scratch `code-task` build support, and MetaSkill clarify resume feedback. | [#184](https://github.com/opensquilla/opensquilla/pull/184), [#222](https://github.com/opensquilla/opensquilla/pull/222), [#243](https://github.com/opensquilla/opensquilla/pull/243), [#253](https://github.com/opensquilla/opensquilla/pull/253), [#261](https://github.com/opensquilla/opensquilla/pull/261) carried through [#297](https://github.com/opensquilla/opensquilla/pull/297), [#272](https://github.com/opensquilla/opensquilla/pull/272), [#279](https://github.com/opensquilla/opensquilla/pull/279) carried through [#297](https://github.com/opensquilla/opensquilla/pull/297), [#321](https://github.com/opensquilla/opensquilla/pull/321), [#323](https://github.com/opensquilla/opensquilla/pull/323) |
| [@openvictory](https://github.com/openvictory) | Skill API-key resolution fallback plus MetaSkill run-history and rescue-action Control UI work carried through the session-contract Control UI integration. | [#183](https://github.com/opensquilla/opensquilla/pull/183), [#264](https://github.com/opensquilla/opensquilla/pull/264) |
| [@Liu-RK](https://github.com/Liu-RK) | Overhauled sandbox run modes and managed access controls, then refactored sandbox run modes across Windows and Linux. | [#189](https://github.com/opensquilla/opensquilla/pull/189), [#230](https://github.com/opensquilla/opensquilla/pull/230) |
| [@weiconghe](https://github.com/weiconghe) | Preserved and replayed Gemini `thought_signature` metadata across provider tool-call turns. | [#312](https://github.com/opensquilla/opensquilla/pull/312) |
| [@changquanyou](https://github.com/changquanyou) | Accepted no-space SSE `data:` lines and improved managed-layer MetaSkill inspection. | [#214](https://github.com/opensquilla/opensquilla/pull/214) |
| [@nkgotcode](https://github.com/nkgotcode) | Fixed DOCX `skill_exec` export behavior. | [#262](https://github.com/opensquilla/opensquilla/pull/262) |
| [@C1-BA-B1-F3](https://github.com/C1-BA-B1-F3) | Made SSRF fake-IP DNS blocks actionable for operators. | [#298](https://github.com/opensquilla/opensquilla/pull/298) carried through [#309](https://github.com/opensquilla/opensquilla/pull/309) and [#310](https://github.com/opensquilla/opensquilla/pull/310) |
| [@BlueOcean223](https://github.com/BlueOcean223) | Reset TUI EOF state on cached reentry. | [#203](https://github.com/opensquilla/opensquilla/pull/203) |
| [@szdtzpj](https://github.com/szdtzpj) | Fixed environment test precedence and the TUI abort hook. | [#176](https://github.com/opensquilla/opensquilla/pull/176) |
| [@lose4578](https://github.com/lose4578) | Submitted the OpenTUI scrollback-native frontend work carried into the 0.4.0 preview backend. | [#182](https://github.com/opensquilla/opensquilla/pull/182) carried through [#277](https://github.com/opensquilla/opensquilla/pull/277) |
| cwan0785 (commit author name; GitHub account: [@Anonymous-4427](https://github.com/Anonymous-4427)) | Authored OpenTUI preview backend implementation commits carried into the 0.4.0 preview backend. | [#182](https://github.com/opensquilla/opensquilla/pull/182) carried through [#277](https://github.com/opensquilla/opensquilla/pull/277) |
## OpenSquilla 0.3.1
The 0.3.1 release is prepared as a release-surface replay from `dev` onto the
stable `main` release ledger. Some community work in the release window was
already represented by earlier `main` attribution work; this section records
the 0.3.1-specific community contributions acknowledged in the release notes.
| Contributor | 0.3.1 contribution | Evidence |
| --- | --- | --- |
| [@openvictory](https://github.com/openvictory) | Visible running-state feedback plus short-drama and media helper workflows. | [#123](https://github.com/opensquilla/opensquilla/pull/123), [#133](https://github.com/opensquilla/opensquilla/pull/133), [#137](https://github.com/opensquilla/opensquilla/pull/137) |
| [@freeaccount-create](https://github.com/freeaccount-create) | Slack Socket Mode and self-targeting replies for channel workflows. | [#142](https://github.com/opensquilla/opensquilla/pull/142) |
| [@ruhook](https://github.com/ruhook) | Submitted the WebChat user-message newline preservation pull request. | [#124](https://github.com/opensquilla/opensquilla/pull/124) |
| [@qq712696307](https://github.com/qq712696307) | Authored the commit in #124 that preserved user-message newlines in WebChat. | [#124](https://github.com/opensquilla/opensquilla/pull/124) |
| [@Cola-Alex](https://github.com/Cola-Alex) | Increased tokenjuice summarize and failure-context windows for fallback tool-result projection. | [#143](https://github.com/opensquilla/opensquilla/pull/143) |
| [@nice-code-la](https://github.com/nice-code-la) | Voice workflow usability and clarification-pause resume behavior. | [#165](https://github.com/opensquilla/opensquilla/pull/165), [#166](https://github.com/opensquilla/opensquilla/pull/166) |
## OpenSquilla 0.3.0
The 0.3.0 release reached `main` through release synchronization after work had
landed through `dev` and integration branches. That compressed the default
branch commit history, so the following community contributions are recorded
explicitly here.
| Contributor | 0.3.0 contribution | Evidence |
| --- | --- | --- |
| [@ab2ence](https://github.com/ab2ence) | Tokenjuice tool-result compression and canonical projection, memory dream consolidation, chat streaming restore work, and cross-platform CI hardening. | [#56](https://github.com/opensquilla/opensquilla/pull/56), [#61](https://github.com/opensquilla/opensquilla/pull/61), [#81](https://github.com/opensquilla/opensquilla/pull/81), [#88](https://github.com/opensquilla/opensquilla/pull/88), [#109](https://github.com/opensquilla/opensquilla/pull/109) |
| [@lose4578](https://github.com/lose4578) | Submitted the TUI backend/runtime foundation pull request. | [#80](https://github.com/opensquilla/opensquilla/pull/80) |
| cwan0785 (commit author name; GitHub account: [@Anonymous-4427](https://github.com/Anonymous-4427)) | Authored the TUI backend/runtime extraction commits behind the foundation pull request. | [#80](https://github.com/opensquilla/opensquilla/pull/80) |
| [@nice-code-la](https://github.com/nice-code-la) | MetaSkill orchestration, router-control replay and hold behavior, retained high-value MetaSkill routing, lifestyle MetaSkill cleanup, and live MetaSkill execution hardening. | [#82](https://github.com/opensquilla/opensquilla/pull/82), [#93](https://github.com/opensquilla/opensquilla/pull/93), [#96](https://github.com/opensquilla/opensquilla/pull/96), [#110](https://github.com/opensquilla/opensquilla/pull/110), [#114](https://github.com/opensquilla/opensquilla/pull/114) replayed through [#115](https://github.com/opensquilla/opensquilla/pull/115), [#119](https://github.com/opensquilla/opensquilla/pull/119) |
| [@openvictory](https://github.com/openvictory) | UTF-8 migration loading fix for yoyo migrations on Windows locales, plus follow-up release-gate alignment. | [#116](https://github.com/opensquilla/opensquilla/pull/116) |
## Attribution Practice
When maintainer cleanup, replay, or squash merging collapses contributor
commits, the final non-empty commit should preserve each human contributor with
`Co-authored-by:` trailers that use GitHub-associated email addresses. Preserve
pull request author attribution and commit author attribution separately when
they differ.
+95
View File
@@ -0,0 +1,95 @@
# syntax=docker/dockerfile:1.6
#
# S20 — OpenSquilla container image.
#
# Safety contract:
# * Inside the container the gateway binds to 0.0.0.0 because the Docker
# network namespace needs a wildcard bind for `-p host:container`
# publishing to work. The defense-in-depth lives at the HOST-SIDE `-p`
# binding: the documented default `docker run -p 127.0.0.1:18791:18791`
# keeps the gateway reachable only from the host's loopback.
# * Network exposure is opt-in via `-p 0.0.0.0:18791:18791` — see the
# "Network exposure" section in README.md for the warning.
# * The S19 boot WARNING (`gateway.bind.public`) fires on every container
# start because the in-container bind is a wildcard by design — that is
# the intended signal to operators running the image.
FROM python:3.13-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_CACHE_DIR=1
# --- safety default ---------------------------------------------------------
# OPENSQUILLA_LISTEN=0.0.0.0 is required inside the container so the gateway can
# be reached via Docker port publishing. Do NOT flip this to 127.0.0.1 —
# that would make the container reachable only from itself. The safe
# default for HOST-side exposure lives at `docker run -p`, not here.
ENV OPENSQUILLA_LISTEN=0.0.0.0 \
OPENSQUILLA_GATEWAY_PORT=18791
WORKDIR /app
# Build tooling for optional C-extension deps (jieba FTS5 tokenizer, etc.).
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy minimal build context — everything else is in .dockerignore.
COPY pyproject.toml README.md README.release.md ./
COPY src/ ./src/
COPY migrations/ ./migrations/
RUN python - <<'PY'
from pathlib import Path
root = Path("src/opensquilla/squilla_router/models")
required = [
root / "v4.2_phase3_inference" / "lgbm_main.bin",
root / "v4.2_phase3_inference" / "router.runtime.yaml",
root / "v4.2_phase3_inference" / "mlp" / "model.onnx",
root / "v4.2_phase3_inference" / "features" / "tfidf.pkl",
root / "v4.2_phase3_inference" / "bge_onnx" / "model.onnx",
]
pointer = "version https://git-lfs.github.com/spec/v1"
missing = [str(path) for path in required if not path.is_file()]
pointers = []
for path in required:
if not path.is_file():
continue
first_line = path.read_text(encoding="utf-8", errors="ignore").splitlines()[0]
if first_line.strip() == pointer:
pointers.append(str(path))
if missing or pointers:
raise SystemExit(
"Squilla router v4 model assets are unavailable in this build context. "
'Run `git lfs pull --include="src/opensquilla/squilla_router/models/**"` '
f"before docker build. Missing={missing} Pointers={pointers}"
)
PY
RUN pip install ".[recommended]"
# Persisted state root. The gateway writes config, state, logs, and the
# workspace under OPENSQUILLA_STATE_DIR — mounting a volume here (see
# compose.yaml) is what makes a container's setup survive a recreate.
ENV OPENSQUILLA_STATE_DIR=/var/lib/opensquilla
# Run as a non-root user — avoids shipping root creds into production.
# The state root is created and owned by that user before the USER drop,
# so a freshly initialized volume inherits writable, non-root ownership.
RUN useradd --create-home --uid 10001 --shell /usr/sbin/nologin opensquilla \
&& mkdir -p /var/lib/opensquilla \
&& chown -R opensquilla:opensquilla /app /var/lib/opensquilla
USER opensquilla
EXPOSE 18791
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl --fail --silent --show-error http://127.0.0.1:18791/healthz || exit 1
ENTRYPOINT ["opensquilla"]
CMD ["gateway", "run"]
+61
View File
@@ -0,0 +1,61 @@
# frozen_string_literal: true
# OpenSquilla — microkernel Python agent runtime. See caveats for the loopback-safe
# gateway default and the documented `--listen 0.0.0.0` opt-in.
class Opensquilla < Formula
include Language::Python::Virtualenv
desc "Microkernel Python agent runtime with MCP tools and multi-channel messaging"
homepage "https://github.com/OpenSquilla/opensquilla"
url "https://github.com/OpenSquilla/opensquilla/archive/refs/tags/v0.1.0.tar.gz"
sha256 "0000000000000000000000000000000000000000000000000000000000000000"
license "Apache-2.0"
head "https://github.com/OpenSquilla/opensquilla.git", branch: "main"
depends_on "python@3.13"
# First-draft formula: pip_install_and_link resolves runtime deps from
# PyPI at brew-install time. Once OpenSquilla ships a 0.1.0 tag, each runtime
# dep will be pinned here as a `resource` block for audit-grade install.
def install
venv = virtualenv_create(libexec, "python3.13")
venv.pip_install_and_link buildpath
end
def caveats
<<~EOS
OpenSquilla installed.
Default gateway bind: 127.0.0.1:18790 (loopback only).
Network exposure is opt-in only. To expose the gateway on the network:
- CLI flag: opensquilla gateway run --listen 0.0.0.0
- Env var: OPENSQUILLA_LISTEN=0.0.0.0 opensquilla gateway run
Reminder: only expose 0.0.0.0 behind a trusted reverse proxy or VPN.
The gateway's first-class auth assumes loopback-scope by default.
The Homebrew formula installs the core runtime. The bundled local ML
router also requires hydrated Git LFS model assets, which GitHub source
tarballs do not carry. If you want squilla-router ML routing, use a
source checkout with Git LFS plus the `recommended` profile:
git lfs pull --include="src/opensquilla/squilla_router/models/**"
uv sync --extra recommended
Service units (launchd / systemd / Task Scheduler) ship in
service-units/. For macOS, install the LaunchAgent:
envsubst < service-units/launchd/ai.opensquilla.gateway.plist \\
> ~/Library/LaunchAgents/ai.opensquilla.gateway.plist
launchctl load ~/Library/LaunchAgents/ai.opensquilla.gateway.plist
See service-units/README.md for the per-platform install + opt-in
walkthrough.
EOS
end
test do
assert_match "opensquilla", shell_output("#{bin}/opensquilla --help 2>&1")
end
end
+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 [yyyy] [name of copyright owner]
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.
+9
View File
@@ -0,0 +1,9 @@
# MetaSkill Documentation Moved
The canonical MetaSkill documentation now lives under `docs/`:
- User guide: [`docs/features/meta-skill-user-guide.md`](docs/features/meta-skill-user-guide.md)
- Product overview: [`docs/features/meta-skills.md`](docs/features/meta-skills.md)
- Authoring guide: [`docs/authoring/meta-skills.md`](docs/authoring/meta-skills.md)
This file remains as a compatibility entrypoint for older links.
+475
View File
@@ -0,0 +1,475 @@
# Migration Guide
OpenSquilla can import state from OpenClaw and Hermes Agent into OpenSquilla
native files. The migration commands are designed to be previewed first, then
applied explicitly.
Supported migration paths:
- Auto-detect everything found under your home: `opensquilla migrate`
- OpenClaw -> OpenSquilla: `opensquilla migrate openclaw`
- Hermes Agent -> OpenSquilla: `opensquilla migrate hermes`
`opensquilla migrate` (with no subcommand) scans `~/.openclaw` and
`~/.hermes` and decides what to do based on what it finds:
- **Nothing detected**: prints the default paths it checked and exits 0.
- **Exactly one detected**: runs that migrator. No prompt, no flag needed.
- **Both detected, interactive (TTY) shell**: opens a multi-select prompt
so you can pick one, both, or neither.
- **Both detected, non-interactive context (CI, piped, `--json`)**: prints
the detected sources and exits 0 without migrating. Re-run with
`--source openclaw,hermes` (or a subset) to opt in explicitly.
When both sources are selected, OpenSquilla runs OpenClaw first and Hermes
second. The second migrator sees whatever the first one wrote, so its
existing per-file dedupe / persona-conflict rules kick in normally. Use
`--source openclaw` or `--source hermes` (comma-separated) to narrow the
selection.
If you are running from a source checkout instead of an installed
`opensquilla` command, prefix the examples with `uv run`:
```sh
uv run opensquilla migrate openclaw --json
uv run opensquilla migrate hermes --json
```
## Before You Start
1. Stop any running OpenSquilla gateway if it is using the target home.
2. Make a manual backup of your OpenSquilla home if you need whole-home
rollback. The migrators can back up overwritten items, but they do not yet
create a complete pre-migration snapshot of `~/.opensquilla`.
3. Run a dry run first and inspect the report.
4. Do not pass `--migrate-secrets` until you have reviewed what will be copied.
Default locations:
- OpenSquilla home: `~/.opensquilla`
- OpenClaw source home: `~/.openclaw`
- Hermes Agent source home: `~/.hermes`
On Windows, these are under your user profile, for example
`C:\Users\<you>\.opensquilla`.
## Common Options
Both migration commands support the same main controls:
| Option | Meaning |
| --- | --- |
| `--source PATH` | Source OpenClaw or Hermes Agent home. |
| `--config PATH` | OpenSquilla config path to preview or write. |
| `--apply` | Apply the migration. Without this, the command is a dry run. |
| `--migrate-secrets` | Copy recognized secrets such as API keys and channel tokens. Defaults to false. |
| `--overwrite` | Allow replacing existing targets. Existing overwritten items are backed up where supported. |
| `--preset user-data` | Migrate only user-facing data such as persona, memory, and skills. |
| `--preset full` | Migrate user data plus supported config/runtime artifacts. This is the default. |
| `--include IDS` | Include only selected migration option ids. Comma-separated. |
| `--exclude IDS` | Exclude selected migration option ids. Comma-separated. |
| `--skill-conflict MODE` | Handle imported skill name conflicts: `skip`, `overwrite`, or `rename`. |
| `--json` | Print a machine-readable report. Recommended for dry runs. |
## OpenClaw -> OpenSquilla
Use this path if your existing agent state is in an OpenClaw home.
Preview first:
```sh
opensquilla migrate openclaw --json
```
Preview a custom OpenClaw home:
```sh
opensquilla migrate openclaw --source /path/to/.openclaw --json
```
Apply without secrets:
```sh
opensquilla migrate openclaw --apply
```
Apply and copy recognized secrets:
```sh
opensquilla migrate openclaw --apply --migrate-secrets
```
Apply and rename imported skill conflicts instead of skipping them:
```sh
opensquilla migrate openclaw --apply --skill-conflict rename
```
### What Is Migrated From OpenClaw
OpenSquilla currently maps OpenClaw data into OpenSquilla-native locations:
- Workspace persona files such as `SOUL.md`, `AGENTS.md`, and `USER.md`.
- Long-term memory and daily memory where supported.
- User skills and shared skills, imported under `~/.opensquilla/skills/openclaw-imports/`.
- TTS assets, while unsupported TTS configuration is archived for review.
- Command allowlists.
- Model config, including string, object, and alias/catalog forms.
- Provider keys from `.env` or provider config when `--migrate-secrets` is set.
- MCP server definitions where OpenSquilla has native fields.
- Telegram, Discord, and Slack channel config where OpenSquilla has native channel support.
- Selected agent and tool settings with OpenSquilla-native equivalents.
- Unsupported or unsafe OpenClaw artifacts are archived for manual review.
The OpenClaw migrator also rewrites OpenClaw branding in migrated user-facing
workspace text to OpenSquilla branding and archives the original changed text
for review.
**Mixed-subject prose is kept verbatim.** When a workspace file (or a
single MEMORY.md block) already mentions `OpenSquilla` (any case), the
migrator skips the mechanical rebrand for that file/block and writes it
verbatim. Mechanical replacement of `OpenClaw` -> `OpenSquilla` in prose
that already names both runtimes as distinct entities produces factual
errors and self-referential nonsense. The report records
`details.rebrand_skipped: "mentions-opensquilla"` for the affected file
and, for MEMORY.md, `details.rebrand_skipped_block_count` so you can
reword the relevant lines by hand.
### SOUL.md / USER.md / AGENTS.md Conflict Handling
These persona files are identity definitions (not additive like memory), so
when the destination already holds real user-curated content the migrator
asks you which version to keep instead of either silently dropping the
imported content or clobbering the existing file.
Use ``--persona-conflict`` to control the behavior:
| Mode | Behavior |
| --- | --- |
| ``prompt`` (default) | When stdin is a TTY: prompt for each conflicting file with a side-by-side preview and the four choices below. When stdin is not a TTY (CI, pipe, ``--json``): fall back to ``use-opensquilla`` and record a note so the choice is visible in the report. |
| ``use-opensquilla`` | Keep the destination file untouched. The OpenClaw original is copied to ``<output_dir>/archive/files/openclaw-orphaned/<filename>`` for review so nothing is silently lost. ``status: skipped``, ``details.persona_conflict_resolution: "use-opensquilla"``. |
| ``use-openclaw`` | Back up the destination to ``<name>.backup.<timestamp>`` and replace it with the OpenClaw content. ``status: migrated``, ``details.persona_conflict_resolution: "use-openclaw"``. |
| ``merge`` | Back up the destination and append the OpenClaw content below it under a ``## Imported from OpenClaw`` separator. Useful when the two versions are complementary rather than conflicting. ``status: migrated``, ``details.persona_conflict_resolution: "merge"``. |
| ``skip`` | Leave both files alone. The OpenClaw original is *not* archived — use ``use-opensquilla`` instead if you want a recoverable copy. ``status: skipped``, ``details.persona_conflict_resolution: "skip"``. |
``--overwrite`` short-circuits all of this and replaces the destination
wholesale (still with an item-level backup).
The pristine bootstrap-template case described below is handled before
``--persona-conflict`` and never asks for input: a freshly initialised
OpenSquilla workspace where the template is still untouched is treated as
overwrite-safe.
### MEMORY.md Merge Semantics
OpenClaw memory is additive by nature: every imported daily-memory file is
its own ``## Imported daily memory: <name>`` section. The OpenClaw migrator
therefore handles ``MEMORY.md`` differently from other workspace files: it
will never silently overwrite existing user-curated memory and it will
never silently drop the imported memory either.
Behaviour matrix (without ``--overwrite``):
| Destination state | What happens |
| --- | --- |
| ``MEMORY.md`` does not exist | Imported memory is written fresh. |
| Pristine OpenSquilla bootstrap template | Template is backed up, imported memory replaces it. ``details.replaced_bootstrap_template: true``. |
| Real user-curated content | Imported blocks that are not already present (after a whitespace-normalised, header-stripped comparison) are appended below the existing content. The pre-existing file is backed up first. ``details.appended_to_existing: true``, ``new_blocks_appended: N``, ``deduplicated_blocks_vs_existing: M``. |
| All imported blocks already present | The file is left untouched. ``status: skipped, reason: "all openclaw memory blocks already present in destination"``, ``details.deduplicated_against_existing: true``. No backup created. |
``--overwrite`` is the explicit "replace, do not merge" escape hatch — the
destination is backed up and replaced wholesale regardless of its current
contents.
### OpenSquilla Bootstrap-Template Handling
`ensure_agent_workspace` seeds placeholder ``SOUL.md`` / ``USER.md`` /
``AGENTS.md`` / ``MEMORY.md`` files when an OpenSquilla home is first
initialised. Without special handling those placeholders would block every
workspace-file migration with a silent ``conflict: target exists`` —
including the imported daily memory the user is migrating for in the first
place.
The OpenClaw migrator detects a destination that still holds the pristine
bootstrap template (byte-identical to the shipped placeholder after a
trailing-whitespace normalisation) and treats it as overwrite-safe:
- The pristine template is backed up to
``<name>.backup.<timestamp>`` next to the destination so the placeholder
guidance can be recovered on demand.
- The imported content replaces the template.
- The migration report marks the item with
``details.replaced_bootstrap_template: true`` so the special case is
visible rather than silent.
A destination file that the user has truly edited (i.e. no longer matches
the canonical template byte-for-byte) still gets the normal
``status: conflict`` treatment — only the pristine placeholder is treated
as overwrite-safe. To accept user edits being overwritten as well, pass
``--overwrite``.
### OpenClaw Limits
Some OpenClaw runtime behavior is not fully mapped yet:
- WhatsApp and Signal settings are detected, but OpenSquilla does not yet create
native migrated channel entries for them.
- Some advanced MCP fields such as headers/auth/cwd/include/exclude are not
native mapped.
- Some gateway, session, browser, approval, logging, plugin, cron, hook, memory
backend, skills registry, and UI settings are archived rather than applied.
- OpenSquilla does not widen channel privileges: ordinary OpenClaw allowlists
are not treated as OpenSquilla admin senders.
Review `MIGRATION_NOTES.md` after an applied migration for partial mappings and
manual follow-up.
## Hermes Agent -> OpenSquilla
Use this path if your existing agent state is in a Hermes Agent home.
Preview first:
```sh
opensquilla migrate hermes --json
```
Preview a custom Hermes Agent home:
```sh
opensquilla migrate hermes --source /path/to/.hermes --json
```
Preview a Hermes profile:
```sh
opensquilla migrate hermes --profile work --json
```
Apply without secrets:
```sh
opensquilla migrate hermes --apply
```
Apply and copy recognized secrets:
```sh
opensquilla migrate hermes --apply --migrate-secrets
```
Apply and rename imported skill conflicts instead of skipping them:
```sh
opensquilla migrate hermes --apply --skill-conflict rename
```
### What Is Migrated From Hermes Agent
OpenSquilla currently maps the common Hermes Agent home surface:
- Persona and user data files such as `SOUL.md`, `MEMORY.md`, and `USER.md`.
- Hermes skills, imported under `~/.opensquilla/skills/hermes-imports/`.
- Hermes model/provider config where there is an OpenSquilla-native equivalent.
- Hermes custom providers with `base_url`, mapped to OpenAI-compatible provider config.
- Environment values and recognized provider keys when `--migrate-secrets` is set.
- Search config where supported.
- MCP server definitions where supported.
- Telegram, Discord, and Slack channel tokens when `--migrate-secrets` is set.
- Selected plugin, cron, and unsupported runtime artifacts are archived for review.
### Hermes Agent Limits
The Hermes Agent migrator is newer than the OpenClaw migrator and has a smaller
coverage surface. Review the dry-run report carefully before applying.
Current limits:
- Live runtime state, active sessions, process state, and gateway state are not imported.
- Some Hermes runtime config option ids are accepted but currently deferred:
`workspace-files`, `tools-config`, `browser-config`, `session-config`,
`gateway-config`, `approvals-config`, `logging-config`, and `memory-backend`.
Each appears in the migration report as `status: deferred` with reason
`handler not implemented yet`. Selecting them via `--include` is not an
error; the migrator just records the gap so it is visible.
- Browser, tool, session, gateway, approval, and logging settings may require manual review.
- A full pre-apply snapshot of `~/.opensquilla` is not created automatically.
### Hermes Agent Migration Behavior
The Hermes migrator now mirrors the OpenClaw migrator on a few correctness
behaviors that were previously documented but not implemented:
- **Item-level backups.** When `--overwrite` replaces an existing
workspace file (`SOUL.md`, `MEMORY.md`, `USER.md`) or skill directory, the
prior contents are written to `<name>.backup.<timestamp>` next to the
original before the new content is applied.
- **Semantic deduplication on merge.** Existing destination content is split
into paragraph blocks and compared after whitespace normalization. A new
source body is appended unless an equivalent block already exists. The
previous naive substring check could silently drop short source bodies.
- **Memory overflow archival.** If the merged `MEMORY.md` would exceed
OpenSquilla's per-file size limit, the overflow is split at a paragraph
boundary and archived to
`~/.opensquilla/migration/hermes/<timestamp>/archive/memory-overflow/MEMORY.overflow.md`.
A short pointer is left at the end of `MEMORY.md`.
- **Branding rewrite.** Hermes branding in imported workspace prose
(`SOUL.md`, `MEMORY.md`, `USER.md`) is rewritten to OpenSquilla. Bare
`Hermes` is only rewritten when it is followed by a workspace-context word
(e.g. `home`, `workspace`, `memory`, `config`). Source-reference tokens
such as `HERMES_HOME`, `NousResearch`, and `hermes-agent` are preserved so
the migration archive still points back at the original source. The
unrebranded original is copied to
`<output_dir>/archive/files/workspace-original/<name>.md` for review.
- **Path-token replacement is word-boundary aware.** Previously a plain
string replace turned `~/.hermesrc` into `~/.opensquillarc` and
`.hermes_backup` into `.opensquilla_backup` — meaningless paths. The
rebrand now only rewrites `.hermes` when it ends a path token (i.e.
followed by `/`, whitespace, quote, or end-of-string). Same rule
applied to `.openclaw` on the OpenClaw side, and bare-word
`OpenClaw` / `openclaw` are now matched with `\b` so substrings like
`OpenClawFlavored` or `openclaw_pid` are left alone.
- **Non-UTF-8 source files no longer crash.** Hand-edited source files
with stray bad bytes (CP1252 fragments, leftover binary paste, etc.)
used to abort the entire Hermes migration with
`UnicodeDecodeError`. The source read now uses `errors="replace"`
(matching OpenClaw); offending bytes become U+FFFD so users can spot
them.
- **`mcp.enabled = false` is no longer silently flipped.** When the
destination home already has MCP servers AND `mcp.enabled = false`
(an explicit "I don't want MCP right now" choice), importing more
MCP servers leaves the flag at `false` and surfaces
`details.mcp_enabled_left_disabled` plus a `manual_steps` hint. MCP
is still flipped on automatically when the destination had no
servers (framework default — flipping is what the user wants).
- **Mixed-subject prose is kept verbatim.** Workspace notes often
describe Hermes AND OpenSquilla as distinct entities ("Hermes Agent
v0.13.0 installed at ~/.local/bin/hermes; OpenSquilla also installed
at ~/.local/bin/opensquilla. Has `migrate hermes` subcommand."). A
mechanical rebrand collapses the two subjects into one and produces
factual errors (path mismatches), tautologies ("OpenSquilla skills
loadable by OpenSquilla"), and self-referential commands ("migrate
OpenSquilla skills to OpenSquilla"). When the source already
mentions `OpenSquilla` (any case), the migrator now skips rebrand
for that file and writes it verbatim, recording
`details.rebrand_skipped: "mentions-opensquilla"` so you can decide
which mentions to reword by hand. The same rule applies per-block
during MEMORY.md merging; the count of skipped blocks is reported
via `details.rebrand_skipped_block_count`.
- **Skill compatibility reporting.** Each imported skill's
report record now includes `details.compatibility` (`loadable` /
`needs_review` / `not_loadable`) and `details.compatibility_issues` listing
missing frontmatter, oversize bodies, or invalid YAML. Skills are still
copied; the field is informational so you can find ones that may need
attention before activating.
- **Unknown providers are no longer written to `llm.provider`.** Hermes
uses values like `auto` (runtime auto-detect) and may ship experimental
providers (`bedrock`, `ollama`, ...) that have no OpenSquilla equivalent.
Writing them verbatim used to crash `persist_config` because OpenSquilla
validates `llm.provider` against a known set AND requires
`squilla_router.tier_profile` to agree with it. The migrator now leaves
`llm.provider` untouched in that case; the model id and base URL are
still migrated, and the model-config item carries
`details.unrecognized_provider`, `details.llm_provider_left_unchanged`,
and a `manual_steps` hint explaining how to set the provider explicitly.
- **Known providers that clash with an existing
`squilla_router.tier_profile` are also not written.** Even when the
Hermes provider is recognized (e.g. `anthropic`), persisting it would
fail if the destination home already pins `squilla_router.tier_profile`
to a different provider (e.g. `openrouter`) — OpenSquilla requires the
two to match. The migrator now detects the clash, leaves `llm.provider`
unchanged, and records `details.tier_profile_conflict`,
`details.llm_provider_left_unchanged`, and a `manual_steps` hint so you
can switch providers explicitly via `opensquilla config set` or by
clearing `squilla_router.tier_profile` first.
- **MCP server entries upsert instead of replacing.** Both migrators
used to assign `cfg.mcp.servers = imported`, silently destroying any
pre-existing OpenSquilla MCP servers the user already had configured.
Now the imported servers are upserted by name: same-name entries are
replaced (the imported version wins), unrelated entries are preserved.
The `mcp-servers` report record carries `details.added`,
`details.replaced`, and `details.preserved_existing`.
- **Resilient SKILL.md compatibility check.** A SKILL.md with empty or
non-dict YAML frontmatter (e.g. `---\n\n---`) used to crash the whole
migration with `AttributeError`. The check now records the skill as
`compatibility: "not_loadable"` and continues. The reported
`compatibility` string is also kept consistent with the
`opensquilla_loadable` boolean.
## Reports
Use `--json` for dry-run automation:
```sh
opensquilla migrate openclaw --json
opensquilla migrate hermes --json
```
Applied migrations write report files under:
```text
~/.opensquilla/migration/openclaw/<timestamp>/
~/.opensquilla/migration/hermes/<timestamp>/
```
Typical files:
- `report.json`: structured item-level report.
- `summary.md`: human-readable count summary.
- `MIGRATION_NOTES.md`: OpenClaw migration notes when semantic conversions or
partial mappings are present.
- `archive/`: unsupported or review-only artifacts.
Hermes dry runs also write report files. OpenClaw dry runs are best inspected
with `--json`; apply mode writes the report files.
## Validate After Migration
After applying a migration, start the gateway and run a small chat check:
```sh
opensquilla gateway start --json
opensquilla chat
```
Or use a one-shot prompt:
```sh
opensquilla agent -m "Briefly summarize your active persona and available memory."
```
Also check:
- `~/.opensquilla/workspace/` for migrated persona and memory files.
- `~/.opensquilla/skills/openclaw-imports/` or `~/.opensquilla/skills/hermes-imports/`.
- `~/.opensquilla/migration/<source>/<timestamp>/summary.md`.
- `~/.opensquilla/migration/<source>/<timestamp>/MIGRATION_NOTES.md` when present.
If behavior does not look right, stop the gateway, review the migration report,
and re-run with a narrower `--preset`, `--include`, or `--exclude` selection.
## Examples
Migrate only user data from OpenClaw:
```sh
opensquilla migrate openclaw --preset user-data --apply
```
Migrate only Hermes skills and persona files:
```sh
opensquilla migrate hermes --include soul,skills --apply
```
Preview OpenClaw migration while excluding channel settings:
```sh
opensquilla migrate openclaw --exclude telegram-settings,discord-settings,slack-settings --json
```
Apply Hermes migration to a custom config file:
```sh
opensquilla migrate hermes --config /path/to/opensquilla.toml --apply
```
+147
View File
@@ -0,0 +1,147 @@
# OpenSquilla Privacy Policy
OpenSquilla is a local-first desktop and CLI application. This policy describes
what project-distributed OpenSquilla software stores locally, what it may send
over the network, and how users can opt out or delete local data.
This policy covers OpenSquilla release artifacts published by the OpenSquilla
project. Third-party AI providers, search providers, operating systems, app
stores, package registries, and GitHub are governed by their own policies.
## Local Data
OpenSquilla stores user configuration, sessions, logs, memory, scheduler state,
cache, and provider settings on the user's machine. The default CLI/gateway
state lives under `~/.opensquilla`. The Electron desktop app also uses the
platform Electron `userData` directory for desktop-specific configuration,
encrypted credentials when Electron `safeStorage` is available, and gateway
logs.
OpenSquilla does not require an OpenSquilla account. Provider API keys are
configured by the user and are kept locally as environment variables, local
configuration references, `.env` files, or desktop encrypted storage depending
on the installation path and setup choices.
## Provider Requests
OpenSquilla sends prompts, messages, tool results, selected files, or generated
context to third-party AI providers only when the user configures a provider and
starts a workflow that uses that provider. The exact data sent depends on the
active provider, model, command, channel, skill, and user-selected context.
Users should review their configured provider's terms and privacy policy before
using external models. OpenSquilla cannot control how an external provider
stores, logs, filters, trains on, or processes requests after the provider API
receives them.
## Search, Channels, And Integrations
Features such as web search, channel connectors, GitHub workflows, browser
automation, or other integrations may contact external services when the user
configures and invokes them. OpenSquilla does not send those requests unless the
corresponding feature is enabled by configuration or user action.
## Network Observability Controls
OpenSquilla groups non-user-initiated network observability under one switch.
Set this before startup to disable automatic install telemetry, passive update
checks, and desktop startup auto-update checks:
```sh
OPENSQUILLA_PRIVACY_DISABLE_NETWORK_OBSERVABILITY=true
```
The same control can be set in configuration:
```toml
[privacy]
disable_network_observability = true
```
Legacy environment variables remain honored for compatibility:
```sh
OPENSQUILLA_TELEMETRY_DISABLED=true
OPENSQUILLA_UPDATE_CHECK_DISABLED=true
```
Manual user-initiated actions may still contact network services after user
intent, including manual release, download, or update checks and configured
providers, search, channels, automation, or integrations.
## Installation Telemetry
OpenSquilla uses anonymous installation telemetry to estimate install counts,
version adoption, and runtime compatibility. Telemetry is sent on first gateway
startup and once per OpenSquilla version. Uploads use a short timeout and never
block startup.
Telemetry payloads include:
- schema version
- locally generated stable `install_id` digest
- OpenSquilla version
- event type, such as `install` or `version_seen`
- install method, such as `pip`, `source`, `docker`, `desktop`, or `unknown`
- operating system, OS version, CPU architecture, and Python major/minor version
- first-seen and sent timestamps
- CI/test-environment marker
The `install_id` is a local one-way SHA-256 digest derived from usable MAC
addresses, then local IP addresses when no MAC is available, with a random
persisted fallback. Raw MAC addresses and raw IP addresses are not uploaded.
Telemetry does not include usernames, hostnames, local paths, API keys,
provider configuration, chat content, session content, memory content, agent
content, file names, or file contents. Source IP addresses may be visible to
HTTP servers at the transport layer, but are not part of the telemetry payload.
Use the unified network observability switch above to opt out before startup.
The legacy telemetry opt-out `OPENSQUILLA_TELEMETRY_DISABLED=true` remains
honored for compatibility.
Advanced deployments can direct installation telemetry to their own endpoint:
```sh
OPENSQUILLA_TELEMETRY_ENDPOINT=https://example.com/v1/install
```
## Logs And Diagnostics
OpenSquilla writes local logs for gateway, desktop, workflow, and troubleshooting
purposes. Logs may include command names, runtime errors, provider identifiers,
timestamps, local status, and diagnostic context. Users should review logs
before sharing them publicly because logs may reflect local configuration or
workflow details.
## Updates And Downloads
OpenSquilla release downloads are hosted on GitHub Releases. Downloading release
assets may expose standard request metadata, such as IP address and user agent,
to GitHub and network intermediaries. Release checksums are published in
`SHA256SUMS` when release assets are generated.
The unified network observability switch disables passive update checks and
desktop startup auto-update checks. Manual release, download, or update checks
may still contact GitHub after the user asks OpenSquilla to perform them.
## Deletion
Use `opensquilla uninstall` to remove OpenSquilla. By default it removes the
program and keeps user data. To delete local state and configuration, opt in:
```sh
opensquilla uninstall --purge-state
opensquilla uninstall --purge-config
opensquilla uninstall --purge-all
```
The command previews and limits deletion to OpenSquilla-owned paths. Desktop
and Docker installs may require platform-specific removal steps shown by the
uninstall command; desktop data cleanup does not remove the OS app bundle.
## Security And Privacy Reports
Report security or privacy issues through the process documented in
[`SECURITY.md`](SECURITY.md). Please do not include secrets, API keys, private
conversation content, or unrelated personal data in public issues.
+810
View File
@@ -0,0 +1,810 @@
<!-- Übersetzt aus README.md @ 8794ffbe. Maßgeblich ist das englische README. -->
<!-- Aktualität prüfen: git log 8794ffbe..HEAD -- README.md -->
# OpenSquilla — Token-effizienter AI Agent
<p align="center">
<img src="assets/opensquilla-long-logo.png" alt="OpenSquilla logo" width="500">
</p>
<p align="center">
<b>Gleiches Budget lass deinen Agent mehr und Besseres leisten.</b><br>
Mikrokernel-AI-Agent intelligentes Routing, persistentes Gedächtnis, sichere Sandbox, integrierte Suche und lokale Embeddings.
</p>
<p align="center">
<a href="https://github.com/opensquilla/opensquilla/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/opensquilla/opensquilla/ci.yml?style=for-the-badge" alt="CI"></a>
<a href="https://opensquilla.ai/"><img src="https://img.shields.io/badge/website-opensquilla.ai-blue?style=for-the-badge" alt="Website"></a>
<a href="https://github.com/opensquilla/opensquilla/releases"><img src="https://img.shields.io/github/v/release/opensquilla/opensquilla?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
<a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?style=for-the-badge" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue?style=for-the-badge" alt="Apache 2.0 License"></a>
</p>
<p align="center">
<a href="README.md">English</a> · <a href="README.zh-Hans.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.fr.md">Français</a> · <b>Deutsch</b> · <a href="README.es.md">Español</a>
</p>
> Dieses Dokument wurde aus dem englischen [`README.md`](README.md) übersetzt; bei Abweichungen ist die englische Fassung maßgeblich.
---
## Neuigkeiten
- 📢 **2026-07-03** — Unser technischer Bericht **[Agentic Routing: The Harness-Native Data Flywheel](docs/releases/agentic_routing_v0.pdf)** (Vorschau) ist erschienen und wurde zusammen mit OpenSquilla **0.5.0 Preview 1** veröffentlicht. Er beschreibt, wie der harness-native Router alltäglichen Agent-Traffic in ein sich selbst verbesserndes Daten-Schwungrad verwandelt.
---
## Überblick
OpenSquilla ist ein Token-effizienter Mikrokernel-AI-Agent. Ein lokaler
Modell-Router schickt jeden Turn an das günstigste Modell, das ihn
bewältigen kann; dauerhaftes Gedächtnis, eine geschichtete Sandbox,
integrierte Websuche und Embeddings auf dem Gerät ergänzen eine einzige
gemeinsame Turn-Schleife.
Jeder Einstiegspunkt — Web UI, CLI und Chat-Kanäle — läuft durch
dieselbe Schleife, sodass sich Tool-Dispatch, Wiederholungsversuche und
Entscheidungs-Logging überall identisch verhalten. Eine modulare
Provider-Schicht spricht mit TokenRhythm, OpenRouter, OpenAI, Anthropic, Ollama,
DeepSeek, Gemini, Qwen/DashScope und über 20 weiteren LLM-Providern —
ohne Änderung an deinem Code oder deinem Konfigurationsschema.
OpenSquilla 0.5.0 Preview 3 ist die aktuelle Preview-Version.
Für aufgabenorientierte Produktdokumentation beginnst du am besten mit
dem [OpenSquilla-Produktleitfaden](README.product.md) oder dem
[Dokumentationsindex](docs/README.md).
---
## Installation
OpenSquilla läuft unter Windows, macOS und Linux. Wähle den Weg, der zu
deinem Einsatzzweck passt.
Desktop-Installationsprogramme und die schnelle Terminal-Installation liefern dir
ein vorgefertigtes **Release** — kein
Git erforderlich. Die beiden anderen — Aus Quellcode installieren und
Aus Quellcode entwickeln — bauen **aus einem Git-Checkout** (`git clone`
+ Git LFS).
Release-Installationsbefehle verwenden veröffentlichte GitHub-Release-Assets.
Python-Wheel-Installationen verwenden versionsbehaftete Wheel-Dateinamen,
weil die Installationsprogramme die im Wheel-Dateinamen eingebettete
Version prüfen.
Für den Desktop-Einsatz von 0.5.0 Preview 3 bevorzugst du die gepackten
Desktop-Installationsprogramme aus dem GitHub-Release:
`OpenSquilla-0.5.0-rc3-mac-arm64.dmg` unter macOS und
`OpenSquilla-0.5.0-rc3-win-x64.exe` unter Windows.
| Weg | Zielgruppe | Wann verwenden |
| --- | --- | --- |
| [Desktop-Installationsprogramme](#desktop-installers) **(empfohlen für Desktop)** | macOS- und Windows-Nutzer | Gepackte Desktop-App |
| [Schnelle Terminal-Installation](#quick-terminal-install) **(empfohlen)** | Endnutzer auf jedem Betriebssystem | Release-Wheel aus dem Terminal |
| [Aus Quellcode installieren](#install-from-source) | Nutzer, die `main` verfolgen | Aus einem Checkout ausführen, nicht bearbeiten |
| [Aus Quellcode entwickeln](#develop-from-source) | Mitwirkende | Quellcode bearbeiten, testen oder debuggen |
### Voraussetzungen
| Anforderung | Schnelle Terminal-Installation | Aus Quellcode installieren | Aus Quellcode entwickeln |
| --- | :---: | :---: | :---: |
| Python 3.12+ | über `uv` | über `uv` oder System | über `uv` |
| Git + Git LFS | — | erforderlich | erforderlich |
| `uv` | wird bei Bedarf installiert | empfohlen | erforderlich |
Das Standardprofil `recommended` installiert **SquillaRouter**
OpenSquillas Modell-Router auf dem Gerät — und seine Modell-Assets;
`OPENSQUILLA_INSTALL_PROFILE=core` lässt diese Abhängigkeiten weg. Das
separate Onboarding-Flag `--router disabled` behält die installierten
Abhängigkeiten bei, schaltet den Router aber zur Laufzeit ab.
Unter Windows benötigt die mit SquillaRouter gebündelte ONNX-Runtime
zusätzlich die Visual-C++-Runtime. Das PowerShell-Installationsprogramm für die
Quellcode-Installation installiert sie automatisch über `winget`; der Weg über die
**schnelle Terminal-Installation** (`uv tool install`) tut das nicht —
falls beim Start ein `DLL load failed`-Fehler protokolliert wird,
installiere sie manuell (siehe [Fehlerbehebung](#troubleshooting)).
OpenSquilla läuft mit direktem Single-Model-Routing weiter, bis sie
installiert ist.
Bei Terminal-Installationen unter macOS benötigt die LightGBM-Runtime
von SquillaRouter möglicherweise zusätzlich die OpenMP-Systembibliothek.
Die Desktop-App bringt die benötigte Runtime mit, aber die
**schnelle Terminal-Installation** installiert keine
Homebrew-/Systembibliotheken. Falls beim Start `Library not loaded:
@rpath/libomp.dylib` protokolliert wird, führe `brew install libomp` aus
und starte dann das Gateway neu. OpenSquilla läuft mit direktem
Single-Model-Routing weiter, bis sie installiert ist.
Installationslinks: [Git](https://git-scm.com/downloads) ·
[Git LFS](https://git-lfs.com/) ·
[uv](https://docs.astral.sh/uv/getting-started/installation/).
<a id="desktop-installers"></a>
### Desktop-Installationsprogramme
Die 0.5.0-Preview-3-Desktop-Installationsprogramme bündeln die Vue-Steuerkonsole
und die Gateway-Runtime in einer Electron-Hülle.
- macOS Apple Silicon: <https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/OpenSquilla-0.5.0-rc3-mac-arm64.dmg>
- Windows x64: <https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/OpenSquilla-0.5.0-rc3-win-x64.exe>
Beende vor dem Upgrade jede laufende OpenSquilla-Desktop-App.
Vorhandene `~/.opensquilla/config.toml` und Sitzungsdaten werden
weiterverwendet.
Führe beim Upgrade der Windows-Desktop-App von RC3 auf RC4 oder neuer das neue
Installationsprogramm direkt über der vorhandenen Installation aus. Deinstalliere
RC3 nicht zuerst: Das RC3-Deinstallationsprogramm kann Desktop-Benutzerdaten löschen.
Sichere vor dem Upgrade `%APPDATA%\OpenSquilla`. Installationsprogramme ab RC4
behalten Profildaten bei einer normalen Deinstallation bei.
<a id="quick-terminal-install"></a>
### Schnelle Terminal-Installation
Der empfohlene Weg unter Windows, macOS und Linux. `uv` installiert
OpenSquilla in eine eigene, isolierte Umgebung und verwaltet sein
eigenes Python — kein System-Python erforderlich. Dieser Weg
installiert nur veröffentlichte Releases; für `main`,
Entwicklungs-Branches oder lokale Checkouts nutze
[Aus Quellcode installieren](#install-from-source).
**1. `uv` installieren** — überspringen, falls `uv --version` bereits
funktioniert.
Linux / macOS:
```sh
curl -LsSf https://astral.sh/uv/install.sh | sh
. "$HOME/.local/bin/env"
```
Windows PowerShell:
```powershell
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
$env:Path = "$env:USERPROFILE\.local\bin;" + $env:Path
```
**2. OpenSquilla installieren** — derselbe Befehl auf jeder Plattform.
```sh
uv tool install --python 3.12 "opensquilla[recommended] @ https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/opensquilla-0.5.0rc3-py3-none-any.whl"
```
Damit wird das OpenSquilla-Wheel von der Release-URL installiert;
anschließend lässt `uv` die von den gewählten Extras deklarierten
Abhängigkeiten herunterladen. Das Standard-Extra `recommended` enthält
SquillaRouter-Runtime-Abhängigkeiten wie ONNX Runtime, LightGBM, NumPy
und tokenizers, sodass eine Erstinstallation Netzwerkzugriff benötigt,
sofern diese Wheels nicht bereits zwischengespeichert sind. `uv`
installiert keine nativen Systemruntimes wie macOS `libomp` oder das
Windows Visual C++ Redistributable; siehe
[Fehlerbehebung](#troubleshooting), falls die Router-Runtime einen
Ladefehler einer nativen Bibliothek meldet.
**3. Konfigurieren und ausführen.**
```sh
opensquilla onboard
opensquilla gateway run
```
> [!NOTE]
> Wird `opensquilla` direkt nach einer frischen `uv`-Installation nicht
> gefunden, öffne ein neues Terminal oder führe die PATH-Zeile aus
> Schritt 1 erneut aus.
Für eine vollständig festgelegte Installation verwende die
versionsbehaftete Wheel-URL:
`https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/opensquilla-0.5.0rc3-py3-none-any.whl`.
<a id="install-from-source"></a>
### Aus Quellcode installieren
Nutze diesen Weg, um OpenSquilla aus einem Checkout auszuführen, ohne
ihn zu bearbeiten. Der Klon dient dem Installationsprogramm nur als
Paketquelle; verwende nach der Installation den `opensquilla`-Befehl —
führe nicht `uv run` aus. Wähle stattdessen
[Aus Quellcode entwickeln](#develop-from-source), wenn du den Code
ändern möchtest.
1. **Mit LFS-Assets klonen**
```sh
git lfs install
git clone https://github.com/opensquilla/opensquilla.git
cd opensquilla
git lfs pull --include="src/opensquilla/squilla_router/models/**"
```
2. **Installationsprogramm ausführen**
**macOS / Linux**
```sh
bash scripts/install_source.sh
```
**Windows PowerShell**
```powershell
powershell -ExecutionPolicy Bypass -File ./scripts/install_source.ps1
```
Das Skript installiert `.[recommended]` (SquillaRouter + Gedächtnis +
lokale Modelle) über `uv tool install` in eine dedizierte
Benutzerumgebung und fällt auf `python -m pip install --user` zurück,
wenn `uv` nicht verfügbar ist. Öffne ein neues Terminal, falls
`opensquilla` nach der Installation nicht im `PATH` liegt.
3. **(optional) Fortgeschrittene Extras installieren.** Die meisten
Kanäle — Feishu, Telegram, DingTalk, QQ, WeCom, Slack und Discord —
funktionieren mit der Basisinstallation. Die optionalen Extras sind:
- `matrix` — Matrix-Kanal (zieht `matrix-nio` mit ein)
- `matrix-e2e` — Matrix-Kanal mit Ende-zu-Ende-Verschlüsselung
(erfordert libolm)
- `document-extras` — PDF-Erzeugung über WeasyPrint
```sh
OPENSQUILLA_INSTALL_EXTRAS=matrix bash scripts/install_source.sh # macOS / Linux
```
```powershell
powershell -ExecutionPolicy Bypass -File ./scripts/install_source.ps1 -Extras matrix # Windows
```
4. **Konfigurieren und ausführen** — siehe [Konfiguration](#configuration).
<details>
<summary>Aus Quellcode installieren — Terminal-Voraussetzungen und Installationsoptionen</summary>
**Voraussetzungen (Git, Git LFS, uv) aus einem Terminal installieren**
Windows PowerShell:
```powershell
winget install --id Git.Git -e
winget install --id GitHub.GitLFS -e
powershell -ExecutionPolicy Bypass -c "irm https://astral.sh/uv/install.ps1 | iex"
git lfs install
```
macOS (Homebrew):
```sh
brew install git git-lfs uv
git lfs install
```
Debian / Ubuntu:
```sh
sudo apt update && sudo apt install -y git git-lfs
curl -LsSf https://astral.sh/uv/install.sh | sh
git lfs install
```
Unter Fedora verwende `sudo dnf install -y git git-lfs`; unter Arch
`sudo pacman -S --needed git git-lfs`; installiere `uv` anschließend mit
dem obigen `curl`-Befehl. PATH-Änderungen dieser Installationsprogramme
gelten für neue Terminal-Sitzungen.
**Umgebungsvariablen des Installationsprogramms und PATH-Prüfungen**
```sh
OPENSQUILLA_INSTALL_PROFILE=core bash scripts/install_source.sh # minimale Runtime, kein SquillaRouter
OPENSQUILLA_INSTALL_DRY_RUN=1 bash scripts/install_source.sh # nur den Plan ausgeben
```
Prüfe mit `command -v opensquilla` (macOS/Linux) oder
`where.exe opensquilla` (Windows), welches `opensquilla` deine Shell
tatsächlich ausführt. Liegt es nicht im `PATH`, führe
`uv tool update-shell` aus. Starte das Gateway nach einer
Neuinstallation aus einem lokalen Checkout neu, damit es das
aktualisierte Paket lädt.
</details>
<a id="develop-from-source"></a>
### Aus Quellcode entwickeln
Nutze diesen Weg, wenn du am Quellcode von OpenSquilla arbeitest:
Änderungen vornehmen, Tests ausführen oder Verhalten gegen diesen
Checkout debuggen. Es ist nicht der normale Installationsweg. Anders als
[Aus Quellcode installieren](#install-from-source) erfordert dieser Weg
`uv`: `uv sync` legt ein repository-lokales `.venv` an, und `uv run`
führt Befehle gegen die Dateien in diesem Checkout aus.
```sh
uv sync --extra recommended --extra dev
uv run opensquilla --help
```
Das Extra `recommended` enthält SquillaRouter auch für die Entwicklung;
das Extra `dev` installiert die Test-, Lint- und Typecheck-Werkzeuge.
Installiere zusätzliche Extras in dieselbe Umgebung, die du ausführst:
```sh
uv sync --extra recommended --extra dev --extra matrix
uv run opensquilla channels status matrix --json
```
Setze in diesem Modus jedem `opensquilla`-Befehl in der
[Konfiguration](#configuration) ein `uv run` voran. Debugge einen
Entwicklungs-Checkout nicht über einen benutzerlokalen
`opensquilla`-Befehl — dieser Befehl läuft in einer anderen
Python-Umgebung.
### Deinstallieren
Entferne OpenSquilla mit `opensquilla uninstall`. Standardmäßig bleiben
deine Daten erhalten und nur das Programm wird entfernt:
```sh
opensquilla uninstall --dry-run # vorab anzeigen, was entfernt und behalten würde
opensquilla uninstall # Programm entfernen, Daten behalten
```
Um auch Daten zu löschen, entscheide dich ausdrücklich dafür:
```sh
opensquilla uninstall --purge-state # Sitzungen, Logs, Cache, Scheduler, Gedächtnis
opensquilla uninstall --purge-config # config.toml und Geheimnisse (.env)
opensquilla uninstall --purge-all # alles (verlangt eine Eingabe zur Bestätigung)
```
Das laufende Gateway wird zuerst geleert und gestoppt, das Löschen
bleibt innerhalb des OpenSquilla-Stammverzeichnisses, und für
Docker-/Desktop-Installationen werden stattdessen geführte
Entfernungsschritte angeboten. Die vollständige Referenz findest du in
[`docs/cli.md`](docs/cli.md#uninstall).
---
## Installationsdatenschutz
OpenSquilla verwendet anonyme Installationstelemetrie, um
Installationszahlen, Versionsverbreitung und Laufzeitkompatibilität
abzuschätzen. Die Daten werden beim ersten Gateway-Start und einmal pro
OpenSquilla-Version gesendet. Uploads verwenden ein kurzes Timeout und
blockieren den Start nie.
Was gesendet wird:
- Schemaversion
- lokal erzeugter, stabiler `install_id`-Digest
- OpenSquilla-Version
- Ereignistyp (`install` oder `version_seen`)
- Installationsmethode (`pip`, `source`, `docker`, `desktop` oder
`unknown`)
- Betriebssystem, Betriebssystemversion, CPU-Architektur sowie
Python-Haupt-/Nebenversion
- Zeitstempel des ersten Auftretens und des Versands
- CI-/Testumgebungs-Marker (`ci_environment`)
Die `install_id` ist ein lokaler, einseitiger SHA-256-Digest, abgeleitet
aus nutzbaren MAC-Adressen, dann aus lokalen IP-Adressen, wenn keine MAC
verfügbar ist, mit einem zufälligen, dauerhaft gespeicherten Fallback.
Rohe MAC-/IP-Werte werden nicht hochgeladen.
Was nicht gesendet wird: Benutzernamen, Hostnamen, Pfade, API-Keys,
Provider-Konfiguration, Chat-/Sitzungs-/Gedächtnis-/Agent-Inhalte,
Dateinamen oder Dateiinhalte. Die Quell-IP kann für HTTP-Server auf der
Transportschicht sichtbar sein, ist aber nicht Teil der Nutzlast.
Zum Deaktivieren:
```sh
OPENSQUILLA_TELEMETRY_DISABLED=true
```
Fortgeschrittene Deployments können einen eigenen Endpunkt verwenden:
```sh
OPENSQUILLA_TELEMETRY_ENDPOINT=https://example.com/v1/install
```
---
<a id="configuration"></a>
## Konfiguration
### Ersteinrichtung
`opensquilla onboard` ist der interaktive Assistent für die
Ersteinrichtung. Er schreibt die aktive Konfigurationsdatei und behält
Provider-Geheimnisse in Umgebungsvariablen, wenn du `--api-key-env`
angibst. Der Router ist standardmäßig auf `recommended` gesetzt
(SquillaRouter auf unterstützten Providern); gib `--router disabled` an
für direktes Single-Model-Routing.
```sh
opensquilla onboard # vollständiger interaktiver Assistent
opensquilla onboard --if-needed # idempotent: sicher für Skripte und Neuinstallationen
opensquilla onboard --minimal # nur Provider; Kanäle und Suche überspringen
opensquilla onboard status # jeden Einrichtungsabschnitt prüfen, ohne zu schreiben
```
Verwende in SSH, CI oder jeder Umgebung ohne TTY die nicht-interaktive
Form — behalte das Geheimnis in der Umgebung und übergib seinen
**Namen**, nicht seinen Wert:
**Linux / macOS**
```sh
export OPENROUTER_API_KEY="sk-..."
opensquilla onboard --provider openrouter --api-key-env OPENROUTER_API_KEY
```
**Windows PowerShell**
```powershell
$env:OPENROUTER_API_KEY="sk-..."
opensquilla onboard --provider openrouter --api-key-env OPENROUTER_API_KEY
```
OpenRouter ist nur ein Beispiel — ersetze es durch einen beliebigen
unterstützten Provider und dessen API-Key-Variable.
Konfiguriere später einen einzelnen Abschnitt neu, ohne den gesamten
Assistenten zu wiederholen (diese Beispiele setzen voraus, dass der
betreffende API-Key bereits in der Umgebung vorhanden ist):
```sh
opensquilla configure provider --provider openai --model gpt-4o --api-key-env OPENAI_API_KEY
opensquilla configure router --router recommended
opensquilla configure search --search-provider duckduckgo
opensquilla configure search --search-provider exa --api-key-env EXA_API_KEY
opensquilla configure channels
```
Abschnitte: `provider`, `router`, `channels`, `search`,
`image-generation`, `memory-embedding`. Die Web UI stellt denselben
Katalog und dasselbe Statusmodell unter `/control/setup` bereit:
Provider und Router sind der schnelle Weg, während Channels, Search,
Image generation und Memory embedding im Capability Center liegen und
später konfiguriert werden können. Leere Kanäle gelten als bewusstes
Auslassen, nicht als fehlgeschlagene Einrichtung.
**Ladereihenfolge der Konfiguration:** `OPENSQUILLA_GATEWAY_CONFIG_PATH`
→ `./opensquilla.toml` → `~/.opensquilla/config.toml` → eingebaute
Standardwerte. Umgebungswerte einzelner Geheimnisse haben stets Vorrang
vor Dateiwerten.
### Von OpenClaw oder Hermes Agent migrieren
Falls du bereits Zustandsdaten unter `~/.openclaw` oder `~/.hermes`
hast, führe zuerst einen Probelauf aus, um den Migrationsbericht zu
prüfen, und wende ihn dann ausdrücklich an:
```sh
opensquilla migrate openclaw --json
opensquilla migrate openclaw --apply
opensquilla migrate hermes --json
opensquilla migrate hermes --apply
```
Verwende `opensquilla migrate --source openclaw,hermes --apply`, um
beide Standard-Stammverzeichnisse zu importieren. Füge
`--migrate-secrets` erst hinzu, nachdem du den Probelaufbericht geprüft
hast. Für benutzerdefinierte Pfade und Konfliktbehandlung siehe
[`MIGRATION.md`](MIGRATION.md).
### Ausführen
```sh
opensquilla gateway run # Vordergrund, 127.0.0.1:18791
opensquilla gateway start --json # Hintergrund + Warten auf Health
opensquilla chat # interaktive REPL
opensquilla agent -m "dein Prompt" # einmalig, automatisierungsfreundlich
```
Öffne die Web UI unter <http://127.0.0.1:18791/control/>. Die Ansicht
**Health** zeigt, ob OpenSquilla bereit ist, was nicht bereit ist und
die nächsten Schritte zur Wiederherstellung. Führe in der CLI aus:
```sh
opensquilla doctor
opensquilla doctor --json
opensquilla doctor --config ./opensquilla.toml --json
```
`/health` und `/healthz` sind leichtgewichtige Liveness-Endpunkte für
Prozessprüfungen. `opensquilla doctor` und die Health-Ansicht der Web UI
sind die Readiness-Oberflächen für Provider-Konfiguration, Gedächtnis,
Logs, Suche, Kanäle, Sandbox-Haltung, Router, Bildgenerierung und
Wiederherstellungshinweise. Drücke `Ctrl+C`, um ein Vordergrund-Gateway
zu stoppen.
Weitere Befehlsgruppen sind unter anderem `sessions`, `skills`,
`memory`, `migrate`, `cron`, `channels`, `providers`, `models` und
`cost`. Führe `opensquilla --help` oder `opensquilla <gruppe> --help`
für Details aus.
<details>
<summary>Fortgeschrittene Konfiguration — einen Kanal verifizieren, Bindung ans öffentliche Netz, Docker</summary>
**Einen Messaging-Kanal verbinden und verifizieren**
Das Speichern eines Kanals ist eine Konfigurationsänderung, kein Beleg
für die Konnektivität zur Laufzeit. Starte das Gateway nach
Kanal-Änderungen neu und verifiziere dann den aktiven Kanal:
```sh
opensquilla gateway restart
opensquilla channels status <name> --json
```
Betrachte einen Kanal nur dann als verbunden, wenn die Status-Nutzlast
`enabled=true`, `configured=true` und `connected=true` meldet. Feishu
verwendet standardmäßig den Websocket-Modus, Telegram Polling, und Slack
kann den Socket Mode nutzen — keiner dieser Modi benötigt eine
öffentliche URL. Der Feishu-Webhook-Modus, der Telegram-Webhook-Modus,
der Slack-Webhook-Modus und WeCom erfordern eine öffentliche, vom
Provider erreichbare URL.
**Bindung ans öffentliche Netz**
Um die Web UI von einer anderen Maschine zu erreichen, binde das Gateway
an alle Schnittstellen und verwende die öffentliche IP des Hosts:
```sh
opensquilla gateway run --listen 0.0.0.0 --port 18791
```
Öffentlicher Zugriff erfordert außerdem, dass die Host-Firewall oder die
Cloud-Sicherheitsgruppe eingehendes TCP auf diesem Port erlaubt. Mache
das Gateway nicht mit `[auth] mode = "none"` öffentlich zugänglich —
konfiguriere Token-Authentifizierung, bevor du an `0.0.0.0` bindest.
**Docker**
Vorgebaute Multi-Arch-Images (`amd64`/`arm64`) werden mit jedem
Release-Tag auf `ghcr.io/opensquilla/opensquilla` veröffentlicht —
[`docs/docker.md`](docs/docker.md) ist der vollständige
Container-Leitfaden (Heimserver und NAS, LAN-Zugriff mit
Token-Authentifizierung, Upgrades):
```sh
OPENSQUILLA_GATEWAY_IMAGE=ghcr.io/opensquilla/opensquilla:latest docker compose up -d
```
Ohne `OPENSQUILLA_GATEWAY_IMAGE` führt der Compose-Weg ein
`opensquilla:local`-Image aus, das du selbst
baust. Baue es aus einem Quellcode-Checkout mit den per Git LFS
geholten Router-Assets (Klon und `git lfs pull` siehe
[Aus Quellcode installieren](#install-from-source)):
```sh
docker build -t opensquilla:local .
```
`./start.sh` (oder `start.ps1` unter Windows) führt dann
`docker compose up -d` aus und folgt den Gateway-Logs. Docker erspart
eine Python-Toolchain auf dem Host — nicht den lokalen Image-Build.
</details>
Provider-Tiers, Sandbox-Feinabstimmung, Bildgenerierung und
Nebenläufigkeitseinstellungen liegen in `opensquilla.toml.example`.
---
## Neuerungen in 0.4.1
OpenSquilla 0.4.1 ist ein Wartungsrelease für die Desktop- und
Control-UI-Linie:
- **Desktop-Zuverlässigkeit** die Prüfungen des gepackten Gateways
decken nun den Coding-Modus, `code-task` und den SquillaRouter-Start
ab, und das Handling von Desktop-Fenstern/Artefakten ist stabiler.
- **Sechssprachige Client-Unterstützung** die Control UI und der
Desktop-Client unterstützen Englisch, vereinfachtes Chinesisch,
Japanisch, Französisch, Deutsch und Spanisch über First-Paint- und
Einstellungsoberflächen hinweg.
- **Coding-Modus und Router-Paketierung** Desktop-Builds schlagen
schnell fehl, wenn Router-Assets fehlen oder noch Git-LFS-Pointer sind,
und verhindern so beeinträchtigte Release-Pakete.
- **Telemetrie und Windows-Feinschliff** die Installationstelemetrie
überspringt CI- und Testumgebungen, und Windows-Desktop-Assets
verwenden das OpenSquilla-Logo.
- **Mainline-Governance** gewöhnliche Pull Requests und die
Release-Integration sind um `main` herum ausgerichtet, während
Maintainer-Branches für Release-, Hotfix-, Staging-, Integrations- und
Sandbox-Arbeit reserviert sind.
Vollständige Hinweise: [`CHANGELOG.md`](CHANGELOG.md) ·
[`docs/releases/0.4.1.md`](docs/releases/0.4.1.md).
## Neuerungen in 0.2.1
OpenSquilla 0.2.1 ist ein Wartungsrelease mit Fokus auf den Start von
Release-Paketen und die Zuverlässigkeit langlaufender Agents:
- **Windows-Portable-Start** der Portable-Launcher erkennt und bootet
die vom gebündelten ONNX-Router benötigte Visual-C++-Runtime besser.
- **Langlaufende Agent-Turns** tool-intensive WebUI-Sitzungen erholen
sich sauberer von überdimensionierten Tool-Ergebnissen, fehlerhaften
Tool-Aufrufen, Übergaben bei der Artefaktauslieferung und
beeinträchtigten finalen Antworten.
- **Sauberere WebUI-Ausgabe** generierte Artefaktmarker werden aus dem
normalen Chat-Replay herausgehalten, während ausgelieferte Dateien
sichtbar bleiben.
- **Bewertung des Gedächtnisabrufs** lokale und OpenAI-kompatible
Embedding-Vektoren werden vor der semantischen Suche normalisiert, und
starke Stichwort-Treffer bleiben nutzbar, wenn Vektorwerte niedrig
sind.
Vollständige Hinweise: [`CHANGELOG.md`](CHANGELOG.md) ·
[Release-Notizen](https://opensquilla.ai/news/).
## Neuerungen in 0.2.0
Dieses Release erweitert OpenSquilla über Migration, CLI-Chat, Kanäle,
Scheduling und langlaufende Tool-Arbeit hinweg:
- **Migrationsweg aus bestehenden Agent-Stammverzeichnissen**
`opensquilla migrate` zeigt Importe aus bestehenden
OpenClaw-/Hermes-Stammverzeichnissen in der Vorschau und führt sie
aus, einschließlich Gedächtnis, Persona-Dateien, Skills,
MCP-/Kanal-Konfiguration, Konfliktbehandlung und Migrationsberichten.
- **Nutzbare Chat-CLI** `opensquilla chat` hat eine stabile
Terminal-UI, Streaming-Ausgabe, Eingabe-Queue, Slash-Modus-Discovery,
Tool-/Status-Leisten und ein deterministischeres Verhalten der
Live-Eingabeaufforderung.
- **Oberflächenübergreifende Cron-Automatisierung** Cron-Jobs decken
nun strukturierte Zeitpläne, zeitzonenbewusste Exact-/Every-/Cron-Läufe,
Kanal- oder Webhook-Auslieferung, Fehlerziele, manuelle Läufe und
WebUI-/CLI-/RPC-Parität ab.
- **Bessere Feishu- und Discord-Kanäle** Kanal-Adapter legen
klarere Capability-Metadaten, sichereres DM-/Gruppen-Handling, native
Datei- und Artefaktpfade sowie verbessertes Anhang-/Thread-Verhalten
offen, während privilegierte Aktionen abgegrenzt bleiben.
- **Robustere langlaufende Turns** fehlgeschlagene Turns werden aus dem
Provider-Replay herausgehalten, fehlerhafte Tool-Aufrufe werden
sicherer behandelt, und freigabepflichtige Wiederholungen warten auf
die Entscheidung der Operatoren.
- **Intelligenteres Kontext- und Tool-Budgeting**
Provider-Budget-Kompaktierung, Bewahrung des Prompt-Caches,
begrenzte Tool-Ergebnisse und nebenwirkungsbewusste Nebenläufigkeit
machen große, tool-intensive Sitzungen vorhersehbarer.
- **Feinschliff bei Web UI und Release** Aktualitätssortierung,
Tabellenlayout, mobile Steuerelemente, doppelte Benachrichtigungen,
Einrichtungsformulare, Release-URLs und Installationswege wurden für
0.2.0 nachgeschärft.
Vollständige Hinweise: [`CHANGELOG.md`](CHANGELOG.md) ·
[Release-Notizen](https://opensquilla.ai/news/).
---
## Hauptfunktionen
| Fähigkeit | Was sie leistet |
| --- | --- |
| **Token-effizientes Routing** | `SquillaRouter` — ein lokaler LightGBM-+-ONNX-Klassifizierer im Extra `recommended` — bewertet jeden Turn nach Länge, Sprache, Code, Stichwörtern und semantischen Embeddings und routet ihn dann über vier Tiers (C0C3; die alten Namen T0T3 sind Aliase) zum günstigsten leistungsfähigen Modell. Die Klassifizierung läuft auf dem Gerät; dein Prompt verlässt die Maschine für diese Entscheidung nie. |
| **Adaptives Reasoning und Prompts** | OpenSquilla fordert erweitertes Reasoning nur für Turns an, die der Router als komplex bewertet, und der System-Prompt skaliert mit der Aufgabenkomplexität — schlank für triviale Turns, vollständige Anweisungen für komplexe. |
| **Über 20 LLM-Provider** | Die Provider-Registry zielt auf über 20 LLM-Backends — TokenRhythm, OpenRouter, OpenAI, Anthropic, Ollama, DeepSeek, Gemini, DashScope/Qwen, Moonshot, Mistral, Groq, Zhipu, SiliconFlow, vLLM, LM Studio und mehr — mit Primär-plus-Fallback-Auswahl; das Erst-Onboarding legt die verifizierte Teilmenge offen. |
| **Bedarfsgesteuerte Skills und MCP** | 15 gebündelte Skills (Coding, GitHub, Cron, pptx/docx/xlsx/pdf, Zusammenfassung, tmux, Wetter und mehr) werden nur geladen, wenn die Aufgabe sie braucht. OpenSquilla ist ein MCP-Client und kann auch als MCP-Server laufen — `opensquilla mcp-server run` benötigt das Extra `mcp` (installiere `opensquilla[recommended,mcp]`). Skills lassen sich über die CLI erstellen, installieren und veröffentlichen. |
| **Dauerhaftes lokales Gedächtnis** | Eine kuratierte `MEMORY.md` plus datierte Markdown-Notizen, durchsucht mit SQLite-Volltext-Stichwortsuche und `sqlite-vec`-Semantikabruf. Embeddings laufen über gebündeltes ONNX auf dem Gerät oder wechseln zu OpenAI/Ollama. Optionaler exponentieller Decay und eine aktivierbare „Dream“-Konsolidierung sind verfügbar. |
| **Geschichtete Sicherheits-Sandbox** | Drei Richtlinien-Tiers (Standard / Strict / Locked) auf einer Berechtigungsmatrix. Bubblewrap isoliert die Codeausführung unter Linux; das macOS-Seatbelt-Backend rendert derzeit nur Profile (Ausführung ausstehend), und unter Windows gibt es noch kein Sandbox-Backend. Ein Denial-Ledger pausiert autonome Läufe nach wiederholten Ablehnungen automatisch, abgelehnte Ausgaben werden verworfen, und Skill-Metadaten sowie Tool-Ergebnisse werden gegen Prompt-Injection XML-escaped. |
| **Integrierte Tools** | Datei lesen/schreiben/bearbeiten, Shell- und Hintergrundprozesse, Git, Websuche (DuckDuckGo, Bocha, Brave, Tavily oder Exa) und Fetch hinter einem SSRF-Schutz, Tabellen-/PPTX-/PDF-Erstellung, Bildgenerierung und Text-to-Speech. |
| **Einheitliches Gateway** | Ein Starlette-ASGI-Server unter `127.0.0.1:18791` mit WebSocket-RPC und einer eingebetteten Steuerkonsole (`/control/`). Web UI, CLI und Kanäle für Terminal, WebSocket, Slack, Telegram, Discord, Feishu, DingTalk, WeCom, Matrix und QQ teilen sich alle einen `TurnRunner`. |
| **Dauerhafte Sitzungen, Subagents und Scheduling** | SQLite-gestützte Speicherung von Sitzungen, Transkripten und Replays mit Arbeitsbereichen pro Agent. Agents starten tiefenbegrenzte Subagents, und eine `SchedulerEngine` mit einem in den Code integrierten Cron-Parser führt wiederkehrende Jobs über `opensquilla cron` aus. |
| **Operator-Steuerung** | Human-in-the-Loop-Freigaben können sensible Tool-Aufrufe für eine Entscheidung pausieren; Token- und Kostenaufstellungen pro Turn und pro Sitzung (`opensquilla cost`) sowie Diagnosen sind über CLI und Web UI verfügbar. |
MetaSkill-Doku: [`docs/features/meta-skills.md`](docs/features/meta-skills.md),
[`docs/features/meta-skill-user-guide.md`](docs/features/meta-skill-user-guide.md)
und [`docs/authoring/meta-skills.md`](docs/authoring/meta-skills.md).
---
## Benchmark-Ergebnisse
PinchBench-1.2.1-Durchschnittsergebnisse über 25 Aufgaben:
| Agent | Basismodell | Ø-Score | Eingabe-Tokens gesamt | Ausgabe-Tokens gesamt | Gesamtkosten |
| --- | ---: | ---: | ---: | ---: | ---: |
| OpenSquilla | Modell-Router (Opus4.7, GLM5.1, DS4 Flash) | 0.9251 | 1,721,328 | 61,475 | $0.688 |
| OpenClaw | Claude Opus 4.7 | 0.9255 | 3,066,243 | 50,890 | $6.233 |
Der Score ist der Mittelwert über die 25 Aufgaben; Token-Zahlen und
Kosten sind Summen für den gesamten Lauf.
---
<a id="troubleshooting"></a>
## Fehlerbehebung
<details>
<summary>macOS: <code>Library not loaded: @rpath/libomp.dylib</code></summary>
Wenn beim Start `Library not loaded: @rpath/libomp.dylib` aus
`lightgbm/lib/lib_lightgbm.dylib` protokolliert wird, läuft OpenSquilla
mit direktem Single-Model-Routing weiter, aber die gebündelte
`SquillaRouter`-Runtime bleibt inaktiv, bis die macOS-OpenMP-Runtime
installiert ist.
Die Desktop-App bringt die benötigte native Runtime mit. Wenn
du die schnelle Terminal-Installation oder eine Quellcode-Installation
aus einer Shell verwendet hast, installiere `libomp` mit Homebrew und
starte das Gateway neu:
```sh
brew install libomp
opensquilla gateway restart
```
</details>
<details>
<summary>Windows: <code>DLL load failed</code> / Visual-C++-Runtime</summary>
Wenn beim Start `DLL load failed while importing
onnxruntime_pybind11_state` protokolliert wird, läuft OpenSquilla mit
direktem Single-Model-Routing weiter, aber die gebündelte
`SquillaRouter`-Runtime bleibt inaktiv, bis das Visual C++
Redistributable für Visual Studio 20152022 (x64) installiert ist.
Das PowerShell-Installationsprogramm für die Quellcode-Installation versucht,
das Redistributable über `winget` zu installieren. Wenn du die schnelle Terminal-Installation
verwendet hast oder `winget` nicht verfügbar ist, installiere es manuell
und starte PowerShell neu:
<https://aka.ms/vs/17/release/vc_redist.x64.exe>. Stelle anschließend den
empfohlenen Router wieder her:
```powershell
opensquilla onboard --provider openrouter --api-key-env OPENROUTER_API_KEY --router recommended
opensquilla gateway restart
```
</details>
---
## Danksagungen
OpenSquilla ist inspiriert von
[OpenClaw](https://github.com/openclaw/openclaw). Gebündelte
Drittanbieter-Inhalte sind in
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) ausgewiesen.
Community-Mitwirkende werden in
[`CONTRIBUTORS.md`](CONTRIBUTORS.md) gewürdigt, einschließlich
release-spezifischer Attributionshinweise für squash-gemergte oder
wiedergespielte Arbeit.
---
## Mitwirkende
Dank an alle, die zu OpenSquilla beitragen.
<p align="center">
<a href="https://github.com/opensquilla/opensquilla/graphs/contributors">
<img src="https://contrib.rocks/image?repo=opensquilla/opensquilla&max=100&columns=10" alt="OpenSquilla contributors" />
</a>
</p>
---
## Mitwirken
Beiträge jeder Art sind willkommen — Fehlerberichte, Funktionsideen,
Dokumentation, neue Provider- oder Kanal-Adapter, Skills und Arbeit an
der Kern-Runtime. Siehe [`CONTRIBUTING.md`](CONTRIBUTING.md) und
eröffne dann ein Issue oder einen Pull Request auf
[GitHub](https://github.com/opensquilla/opensquilla).
[Verhaltenskodex](CODE_OF_CONDUCT.md) · [Sicherheit](SECURITY.md) ·
[Support](SUPPORT.md) · [Lizenz](LICENSE) (Apache-2.0)
+572
View File
@@ -0,0 +1,572 @@
<!-- Traducido de README.md @ 8794ffbe. El README en inglés es la fuente autorizada. -->
<!-- Comprobar si está desactualizado: git log 8794ffbe..HEAD -- README.md -->
# OpenSquilla — Agente de IA eficiente en tokens
<p align="center">
<img src="assets/opensquilla-long-logo.png" alt="OpenSquilla logo" width="500">
</p>
<p align="center">
<b>Con el mismo presupuesto, haz que tu agente haga más y lo haga mejor.</b><br>
Agente de IA con microkernel: enrutamiento inteligente, memoria persistente, sandbox seguro, búsqueda integrada y embeddings locales.
</p>
<p align="center">
<a href="https://github.com/opensquilla/opensquilla/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/opensquilla/opensquilla/ci.yml?style=for-the-badge" alt="CI"></a>
<a href="https://opensquilla.ai/"><img src="https://img.shields.io/badge/website-opensquilla.ai-blue?style=for-the-badge" alt="Website"></a>
<a href="https://github.com/opensquilla/opensquilla/releases"><img src="https://img.shields.io/github/v/release/opensquilla/opensquilla?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
<a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?style=for-the-badge" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue?style=for-the-badge" alt="Apache 2.0 License"></a>
</p>
<p align="center">
<a href="README.md">English</a> · <a href="README.zh-Hans.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.fr.md">Français</a> · <a href="README.de.md">Deutsch</a> · <b>Español</b>
</p>
> Este documento es una traducción del [`README.md`](README.md) en inglés; si hay alguna discrepancia, la versión en inglés es la autorizada.
---
## Novedades
- 📢 **2026-07-03** — Nuestro informe técnico **[Agentic Routing: The Harness-Native Data Flywheel](docs/releases/agentic_routing_v0.pdf)** (versión preliminar) ya está disponible, publicado junto con OpenSquilla **0.5.0 Preview 1**. Detalla cómo el enrutador nativo del harness convierte el tráfico cotidiano de los agentes en un volante de datos que se mejora a sí mismo.
---
## Resumen
OpenSquilla es un agente de IA con microkernel y eficiente en el uso de tokens. Un enrutador de modelos local envía cada turno al modelo más económico que pueda resolverlo, mientras que la memoria persistente, un sandbox por capas, la búsqueda web integrada y los embeddings en el propio dispositivo completan un único bucle de turnos compartido.
Cada punto de entrada —Web UI, CLI y canales de chat— se ejecuta a través de ese mismo bucle, de modo que el envío de herramientas, los reintentos y el registro de decisiones se comportan de forma idéntica en todas partes. Una capa de proveedores conectable se comunica con TokenRhythm, OpenRouter, OpenAI, Anthropic, Ollama, DeepSeek, Gemini, Qwen/DashScope y más de 20 proveedores de LLM adicionales, sin ningún cambio en tu código ni en el esquema de configuración.
OpenSquilla 0.5.0 Preview 3 es la versión preliminar actual.
Para documentación de producto orientada a tareas, comienza por la [Guía de producto de OpenSquilla](README.product.md) o el [índice de documentación](docs/README.md).
---
## Instalación
OpenSquilla funciona en Windows, macOS y Linux. Elige la ruta que se ajuste a tu caso de uso.
Los instaladores de escritorio y la instalación rápida desde terminal te ofrecen una **versión** precompilada, sin necesidad de Git. Las otras dos —instalar desde el código fuente y desarrollar desde el código fuente— se compilan **a partir de un checkout de Git** (`git clone` + Git LFS).
Los comandos de instalación de versiones usan los recursos de release publicados en GitHub. Las instalaciones del wheel de Python usan nombres de archivo de wheel con versión, porque los instaladores validan la versión incrustada en el nombre del archivo del wheel.
Para el uso de escritorio de 0.5.0 Preview 3, opta por los instaladores de escritorio empaquetados de la Release de GitHub: `OpenSquilla-0.5.0-rc3-mac-arm64.dmg` en macOS y `OpenSquilla-0.5.0-rc3-win-x64.exe` en Windows.
| Ruta | Público | Cuándo usarla |
| --- | --- | --- |
| [Instaladores de escritorio](#desktop-installers) **(recomendado para escritorio)** | Usuarios de macOS y Windows | Aplicación de escritorio empaquetada |
| [Instalación rápida desde terminal](#quick-terminal-install) **(recomendado)** | Usuarios finales en cualquier SO | Wheel de release desde una terminal |
| [Instalar desde el código fuente](#install-from-source) | Usuarios que siguen `main` | Ejecutar desde un checkout, no editarlo |
| [Desarrollar desde el código fuente](#develop-from-source) | Colaboradores | Editar, probar o depurar el código fuente |
### Requisitos previos
| Requisito | Instalación rápida desde terminal | Instalar desde el código fuente | Desarrollar desde el código fuente |
| --- | :---: | :---: | :---: |
| Python 3.12+ | mediante `uv` | mediante `uv` o el sistema | mediante `uv` |
| Git + Git LFS | — | requerido | requerido |
| `uv` | se instala si falta | recomendado | requerido |
El perfil predeterminado `recommended` instala **SquillaRouter** —el enrutador de modelos en el dispositivo de OpenSquilla— y sus recursos de modelo; `OPENSQUILLA_INSTALL_PROFILE=core` omite esas dependencias. El indicador de onboarding independiente `--router disabled` mantiene las dependencias instaladas, pero apaga el enrutador en tiempo de ejecución.
En Windows, el runtime ONNX que incluye SquillaRouter también necesita el runtime de Visual C++. El instalador de PowerShell desde el código fuente lo instala automáticamente mediante `winget`; la ruta de **instalación rápida desde terminal** (`uv tool install`) no lo hace: si el arranque registra un error `DLL load failed`, instálalo manualmente (consulta [Solución de problemas](#troubleshooting)). OpenSquilla sigue funcionando con enrutamiento directo a un único modelo hasta que se instale.
En las instalaciones desde terminal de macOS, el runtime LightGBM de SquillaRouter también puede necesitar la biblioteca OpenMP del sistema. La aplicación de escritorio incluye el runtime que necesita, pero la **instalación rápida desde terminal** no instala bibliotecas de Homebrew ni del sistema. Si el arranque registra `Library not loaded: @rpath/libomp.dylib`, ejecuta `brew install libomp` y luego reinicia el gateway. OpenSquilla sigue funcionando con enrutamiento directo a un único modelo hasta que se instale.
Enlaces de instalación: [Git](https://git-scm.com/downloads) ·
[Git LFS](https://git-lfs.com/) ·
[uv](https://docs.astral.sh/uv/getting-started/installation/).
<a id="desktop-installers"></a>
### Instaladores de escritorio
Los instaladores de escritorio de 0.5.0 Preview 3 empaquetan la consola de control de Vue y el runtime del gateway en una carcasa de Electron.
- macOS Apple Silicon: <https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/OpenSquilla-0.5.0-rc3-mac-arm64.dmg>
- Windows x64: <https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/OpenSquilla-0.5.0-rc3-win-x64.exe>
Cierra cualquier aplicación de escritorio de OpenSquilla en ejecución antes de actualizar. Se reutilizan el `~/.opensquilla/config.toml` y los datos de sesión existentes.
Al actualizar la aplicación de escritorio de Windows de RC3 a RC4 o una versión
posterior, ejecuta el instalador nuevo directamente sobre la instalación existente.
No desinstales RC3 primero: su desinstalador puede eliminar los datos de usuario de
la aplicación. Haz una copia de seguridad de `%APPDATA%\OpenSquilla` antes de
actualizar. Los instaladores RC4 y posteriores conservan los datos del perfil durante
una desinstalación normal.
<a id="quick-terminal-install"></a>
### Instalación rápida desde terminal
La ruta recomendada en Windows, macOS y Linux. `uv` instala OpenSquilla en su propio entorno aislado y gestiona su propio Python, sin necesidad de un Python del sistema. Esta ruta instala únicamente versiones publicadas; para `main`, ramas de desarrollo o checkouts locales, usa [Instalar desde el código fuente](#install-from-source).
**1. Instala `uv`**: omite este paso si `uv --version` ya funciona.
Linux / macOS:
```sh
curl -LsSf https://astral.sh/uv/install.sh | sh
. "$HOME/.local/bin/env"
```
Windows PowerShell:
```powershell
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
$env:Path = "$env:USERPROFILE\.local\bin;" + $env:Path
```
**2. Instala OpenSquilla**: el mismo comando en todas las plataformas.
```sh
uv tool install --python 3.12 "opensquilla[recommended] @ https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/opensquilla-0.5.0rc3-py3-none-any.whl"
```
Esto instala el wheel de OpenSquilla desde la URL de la release y luego deja que `uv` descargue las dependencias declaradas por los extras seleccionados. El extra predeterminado `recommended` incluye dependencias del runtime de SquillaRouter como ONNX Runtime, LightGBM, NumPy y tokenizers, así que una primera instalación necesita acceso a la red salvo que esos wheels ya estén en caché. `uv` no instala runtimes nativos del sistema como `libomp` de macOS o el Visual C++ Redistributable de Windows; consulta [Solución de problemas](#troubleshooting) si el runtime del enrutador informa de un error de carga de biblioteca nativa.
**3. Configura y ejecuta.**
```sh
opensquilla onboard
opensquilla gateway run
```
> [!NOTE]
> Si no se encuentra `opensquilla` justo después de una instalación nueva con `uv`, abre una terminal nueva o vuelve a ejecutar la línea de PATH del paso 1.
Para una instalación totalmente fijada, usa la URL del wheel con versión:
`https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/opensquilla-0.5.0rc3-py3-none-any.whl`.
<a id="install-from-source"></a>
### Instalar desde el código fuente
Usa esta ruta para ejecutar OpenSquilla desde un checkout sin editarlo. El clon es solo el código fuente del paquete para el instalador; tras instalar, usa el comando `opensquilla`, no ejecutes `uv run`. Elige en su lugar [Desarrollar desde el código fuente](#develop-from-source) si tu intención es modificar el código.
1. **Clona con los recursos LFS**
```sh
git lfs install
git clone https://github.com/opensquilla/opensquilla.git
cd opensquilla
git lfs pull --include="src/opensquilla/squilla_router/models/**"
```
2. **Ejecuta el instalador**
**macOS / Linux**
```sh
bash scripts/install_source.sh
```
**Windows PowerShell**
```powershell
powershell -ExecutionPolicy Bypass -File ./scripts/install_source.ps1
```
El script instala `.[recommended]` (SquillaRouter + memoria + modelos locales) en un entorno de usuario dedicado mediante `uv tool install`, recurriendo a `python -m pip install --user` cuando `uv` no está disponible. Abre una terminal nueva si `opensquilla` no está en el `PATH` tras la instalación.
3. **(opcional) Instala extras avanzados.** La mayoría de los canales —Feishu, Telegram, DingTalk, QQ, WeCom, Slack y Discord— funcionan desde la instalación base. Los extras opcionales son:
- `matrix` — Canal de Matrix (incorpora `matrix-nio`)
- `matrix-e2e` — Canal de Matrix con cifrado de extremo a extremo (requiere libolm)
- `document-extras` — Generación de PDF mediante WeasyPrint
```sh
OPENSQUILLA_INSTALL_EXTRAS=matrix bash scripts/install_source.sh # macOS / Linux
```
```powershell
powershell -ExecutionPolicy Bypass -File ./scripts/install_source.ps1 -Extras matrix # Windows
```
4. **Configura y ejecuta**: consulta [Configuración](#configuration).
<details>
<summary>Instalar desde el código fuente: requisitos previos de terminal y opciones del instalador</summary>
**Instalar los requisitos previos (Git, Git LFS, uv) desde una terminal**
Windows PowerShell:
```powershell
winget install --id Git.Git -e
winget install --id GitHub.GitLFS -e
powershell -ExecutionPolicy Bypass -c "irm https://astral.sh/uv/install.ps1 | iex"
git lfs install
```
macOS (Homebrew):
```sh
brew install git git-lfs uv
git lfs install
```
Debian / Ubuntu:
```sh
sudo apt update && sudo apt install -y git git-lfs
curl -LsSf https://astral.sh/uv/install.sh | sh
git lfs install
```
En Fedora usa `sudo dnf install -y git git-lfs`; en Arch usa `sudo pacman -S --needed git git-lfs`; luego instala `uv` con el comando `curl` anterior. Los cambios de PATH de estos instaladores se aplican a las nuevas sesiones de terminal.
**Variables de entorno del instalador y comprobaciones de PATH**
```sh
OPENSQUILLA_INSTALL_PROFILE=core bash scripts/install_source.sh # runtime mínimo, sin SquillaRouter
OPENSQUILLA_INSTALL_DRY_RUN=1 bash scripts/install_source.sh # solo imprime el plan
```
Verifica qué `opensquilla` ejecuta tu shell con `command -v opensquilla` (macOS/Linux) o `where.exe opensquilla` (Windows). Si no está en el `PATH`, ejecuta `uv tool update-shell`. Tras reinstalar desde un checkout local, reinicia el gateway para que cargue el paquete actualizado.
</details>
<a id="develop-from-source"></a>
### Desarrollar desde el código fuente
Usa esta ruta cuando estés trabajando en el código fuente de OpenSquilla: haciendo cambios, ejecutando pruebas o depurando el comportamiento contra este checkout. No es la ruta de instalación habitual. A diferencia de [Instalar desde el código fuente](#install-from-source), esta ruta requiere `uv`: `uv sync` crea un `.venv` local del repositorio y `uv run` ejecuta los comandos contra los archivos de este checkout.
```sh
uv sync --extra recommended --extra dev
uv run opensquilla --help
```
El extra `recommended` también incluye SquillaRouter para el desarrollo; el extra `dev` instala las herramientas de prueba, lint y comprobación de tipos. Instala extras adicionales en el mismo entorno que ejecutas:
```sh
uv sync --extra recommended --extra dev --extra matrix
uv run opensquilla channels status matrix --json
```
En este modo, antepón `uv run` a cada comando `opensquilla` de [Configuración](#configuration). No depures un checkout de desarrollo a través de un comando `opensquilla` local del usuario: ese comando se ejecuta en un entorno de Python distinto.
### Desinstalación
Elimina OpenSquilla con `opensquilla uninstall`. Conserva tus datos de forma predeterminada y elimina solo el programa:
```sh
opensquilla uninstall --dry-run # previsualiza qué se eliminaría y qué se conservaría
opensquilla uninstall # elimina el programa, conserva tus datos
```
Para eliminar también los datos, opta explícitamente por ello:
```sh
opensquilla uninstall --purge-state # sesiones, registros, caché, programador, memoria
opensquilla uninstall --purge-config # config.toml y secretos (.env)
opensquilla uninstall --purge-all # todo (te pide que escribas una confirmación)
```
Primero se vacía y detiene el gateway en ejecución, la eliminación se mantiene dentro del directorio raíz de OpenSquilla, y las instalaciones de Docker o de escritorio obtienen pasos de eliminación guiados en su lugar. Consulta [`docs/cli.md`](docs/cli.md#uninstall) para la referencia completa.
---
## Privacidad de la instalación
OpenSquilla utiliza telemetría de instalación anónima para estimar el número de instalaciones, la adopción de versiones y la compatibilidad en tiempo de ejecución. Los datos se envían en el primer arranque del gateway y una vez por cada versión de OpenSquilla. Las cargas usan un tiempo de espera corto y nunca bloquean el arranque.
Lo que se envía:
- versión del esquema
- resumen (digest) `install_id` estable generado localmente
- versión de OpenSquilla
- tipo de evento (`install` o `version_seen`)
- método de instalación (`pip`, `source`, `docker`, `desktop` o `unknown`)
- sistema operativo, versión del SO, arquitectura de CPU y versión mayor/menor de Python
- marcas de tiempo de primera detección y de envío
- marcador de entorno de CI/pruebas (`ci_environment`)
El `install_id` es un resumen SHA-256 local y unidireccional derivado de direcciones MAC utilizables, luego de direcciones IP locales cuando no hay ninguna MAC disponible, con un valor aleatorio persistente de reserva. Los valores MAC/IP en bruto no se cargan.
Lo que no se envía: nombres de usuario, nombres de host, rutas, claves de API, configuración de proveedores, contenido de chat/sesión/memoria/agente, nombres de archivo ni contenido de archivos. La IP de origen puede ser visible para los servidores HTTP en la capa de transporte, pero no forma parte de la carga útil.
Para optar por no participar:
```sh
OPENSQUILLA_TELEMETRY_DISABLED=true
```
Las implementaciones avanzadas pueden usar su propio endpoint:
```sh
OPENSQUILLA_TELEMETRY_ENDPOINT=https://example.com/v1/install
```
---
<a id="configuration"></a>
## Configuración
### Configuración inicial
`opensquilla onboard` es el asistente interactivo de configuración inicial. Escribe el archivo de configuración activo y mantiene los secretos del proveedor en variables de entorno cuando pasas `--api-key-env`. El enrutador usa `recommended` de forma predeterminada (SquillaRouter en proveedores compatibles); pasa `--router disabled` para enrutamiento directo a un único modelo.
```sh
opensquilla onboard # asistente interactivo completo
opensquilla onboard --if-needed # idempotente: seguro para scripts y reinstalaciones
opensquilla onboard --minimal # solo el proveedor; omite canales y búsqueda
opensquilla onboard status # inspecciona cada sección de configuración sin escribir
```
En SSH, CI o cualquier entorno sin TTY, usa la forma no interactiva: mantén el secreto en el entorno y pasa su **nombre**, no su valor:
**Linux / macOS**
```sh
export OPENROUTER_API_KEY="sk-..."
opensquilla onboard --provider openrouter --api-key-env OPENROUTER_API_KEY
```
**Windows PowerShell**
```powershell
$env:OPENROUTER_API_KEY="sk-..."
opensquilla onboard --provider openrouter --api-key-env OPENROUTER_API_KEY
```
OpenRouter es solo un ejemplo: sustitúyelo por cualquier proveedor compatible y su variable de clave de API.
Vuelve a configurar una sección más tarde sin rehacer todo el asistente (estos ejemplos asumen que la clave de API correspondiente ya está en el entorno):
```sh
opensquilla configure provider --provider openai --model gpt-4o --api-key-env OPENAI_API_KEY
opensquilla configure router --router recommended
opensquilla configure search --search-provider duckduckgo
opensquilla configure search --search-provider exa --api-key-env EXA_API_KEY
opensquilla configure channels
```
Secciones: `provider`, `router`, `channels`, `search`, `image-generation`, `memory-embedding`. La Web UI expone el mismo catálogo y modelo de estado en `/control/setup`: Provider y Router son la ruta rápida, mientras que Channels, Search, Image generation y Memory embedding se encuentran en el Centro de capacidades (Capability Center) y pueden configurarse más tarde. Dejar los canales vacíos se trata como una exclusión voluntaria, no como una configuración fallida.
**Orden de carga de la configuración:** `OPENSQUILLA_GATEWAY_CONFIG_PATH` →
`./opensquilla.toml` → `~/.opensquilla/config.toml` → valores predeterminados integrados. Para los secretos individuales, los valores del entorno siempre prevalecen sobre los del archivo.
### Migrar desde OpenClaw o Hermes Agent
Si ya tienes estado en `~/.openclaw` o `~/.hermes`, ejecuta primero un dry run para revisar el informe de migración y luego aplícalo explícitamente:
```sh
opensquilla migrate openclaw --json
opensquilla migrate openclaw --apply
opensquilla migrate hermes --json
opensquilla migrate hermes --apply
```
Usa `opensquilla migrate --source openclaw,hermes --apply` para importar ambos directorios raíz predeterminados. Añade `--migrate-secrets` solo después de revisar el informe del dry run. Consulta [`MIGRATION.md`](MIGRATION.md) para rutas personalizadas y la gestión de conflictos.
### Ejecución
```sh
opensquilla gateway run # en primer plano, 127.0.0.1:18791
opensquilla gateway start --json # en segundo plano + espera de comprobación de salud
opensquilla chat # REPL interactivo
opensquilla agent -m "tu prompt" # una sola vez, apto para automatización
```
Abre la Web UI en <http://127.0.0.1:18791/control/>. La vista **Health (Salud)** muestra si OpenSquilla está listo, qué no está listo y los siguientes pasos de recuperación. Desde la CLI, ejecuta:
```sh
opensquilla doctor
opensquilla doctor --json
opensquilla doctor --config ./opensquilla.toml --json
```
`/health` y `/healthz` son endpoints de liveness ligeros para las comprobaciones de proceso. `opensquilla doctor` y la vista Health de la Web UI son las superficies de comprobación de disponibilidad para la configuración del proveedor, la memoria, los registros, la búsqueda, los canales, la postura del sandbox, el enrutador, la generación de imágenes y la orientación de recuperación. Pulsa `Ctrl+C` para detener un gateway en primer plano.
Otros grupos de comandos incluyen `sessions`, `skills`, `memory`, `migrate`, `cron`, `channels`, `providers`, `models` y `cost`. Ejecuta `opensquilla --help` o `opensquilla <grupo> --help` para más detalles.
<details>
<summary>Configuración avanzada: verificar un canal, vinculación a la red pública, Docker</summary>
**Conectar y verificar un canal de mensajería**
Guardar un canal es un cambio de configuración, no una prueba de conectividad en tiempo de ejecución. Reinicia el gateway tras editar canales y luego verifica el canal en vivo:
```sh
opensquilla gateway restart
opensquilla channels status <name> --json
```
Considera un canal como conectado solo cuando la carga útil de estado informa `enabled=true`, `configured=true` y `connected=true`. Feishu usa el modo websocket de forma predeterminada, Telegram usa polling y Slack puede usar Socket Mode: ninguno de esos modos necesita una URL pública. El modo webhook de Feishu, el modo webhook de Telegram, el modo webhook de Slack y WeCom requieren una URL pública y accesible por el proveedor.
**Vinculación a la red pública**
Para llegar a la Web UI desde otra máquina, vincula el gateway a todas las interfaces y usa la IP pública del host:
```sh
opensquilla gateway run --listen 0.0.0.0 --port 18791
```
El acceso público también requiere que el firewall del host o el grupo de seguridad de la nube permita el TCP entrante en ese puerto. No expongas el gateway con `[auth] mode = "none"`: configura la autenticación por token antes de vincularlo a `0.0.0.0`.
**Docker**
Se publican imágenes multiarquitectura preconstruidas (`amd64`/`arm64`) en `ghcr.io/opensquilla/opensquilla` con cada etiqueta de release; [`docs/docker.md`](docs/docker.md) es la guía completa de contenedores (servidores domésticos y NAS, exposición en la LAN con autenticación por token, actualizaciones):
```sh
OPENSQUILLA_GATEWAY_IMAGE=ghcr.io/opensquilla/opensquilla:latest docker compose up -d
```
Sin `OPENSQUILLA_GATEWAY_IMAGE`, la ruta de compose ejecuta una imagen `opensquilla:local` que construyes tú mismo. Constrúyela a partir de un checkout del código fuente con los recursos del enrutador de Git LFS descargados (consulta [Instalar desde el código fuente](#install-from-source) para el clon y `git lfs pull`):
```sh
docker build -t opensquilla:local .
```
Luego, `./start.sh` (o `start.ps1` en Windows) ejecuta `docker compose up -d` y sigue los registros del gateway. Docker evita una cadena de herramientas de Python en el host, no la construcción de la imagen local.
</details>
Los niveles de proveedor, el ajuste del sandbox, la generación de imágenes y los ajustes de concurrencia están en `opensquilla.toml.example`.
---
## Novedades en 0.4.1
OpenSquilla 0.4.1 es una versión de mantenimiento para la línea de escritorio y de la Control UI:
- **Fiabilidad del escritorio**: las comprobaciones del gateway empaquetado ahora cubren el modo Coding, `code-task` y el arranque de SquillaRouter, y la gestión de ventanas/artefactos del escritorio es más estable.
- **Compatibilidad de cliente en seis idiomas**: la Control UI y el cliente de escritorio admiten inglés, chino simplificado, japonés, francés, alemán y español en las superficies de primer pintado y de configuración.
- **Modo Coding y empaquetado del enrutador**: las compilaciones de escritorio fallan rápidamente si faltan los recursos del enrutador o siguen siendo punteros de Git LFS, lo que evita paquetes de release degradados.
- **Telemetría y pulido de Windows**: la telemetría de instalación omite los entornos de CI y de pruebas, y los recursos de escritorio de Windows usan el logotipo de OpenSquilla.
- **Gobernanza de la línea principal**: las pull request ordinarias y la integración de releases se alinean en torno a `main`, con ramas de mantenedor reservadas para el trabajo de release, hotfix, staging, integración y sandbox.
Notas completas: [`CHANGELOG.md`](CHANGELOG.md) ·
[`docs/releases/0.4.1.md`](docs/releases/0.4.1.md).
## Novedades en 0.2.1
OpenSquilla 0.2.1 es una versión de mantenimiento centrada en el arranque del paquete de release y la fiabilidad del agente de larga duración:
- **Arranque de la versión portable de Windows**: el lanzador portable detecta y arranca mejor el runtime de Visual C++ que necesita el enrutador ONNX incluido.
- **Turnos de agente de larga duración**: las sesiones de WebUI con uso intensivo de herramientas se recuperan de forma más limpia ante resultados de herramienta sobredimensionados, llamadas de herramienta mal formadas, transferencias de entrega de artefactos y respuestas finales degradadas.
- **Salida de WebUI más limpia**: los marcadores de artefactos generados se mantienen fuera de la repetición normal del chat, mientras que los archivos entregados siguen siendo visibles.
- **Puntuación de recuerdo de memoria**: los vectores de embedding locales y compatibles con OpenAI se normalizan antes de la búsqueda semántica, y las coincidencias fuertes de palabras clave siguen siendo útiles cuando las puntuaciones vectoriales son bajas.
Notas completas: [`CHANGELOG.md`](CHANGELOG.md) ·
[notas de la versión](https://opensquilla.ai/news/).
## Novedades en 0.2.0
Esta versión amplía OpenSquilla en migración, chat por CLI, canales, programación y trabajo de herramientas de larga duración:
- **Ruta de migración desde directorios raíz de agente existentes**: `opensquilla migrate` previsualiza y aplica importaciones desde directorios raíz existentes de OpenClaw/Hermes, incluyendo memoria, archivos de persona, skills, configuración de MCP/canales, gestión de conflictos e informes de migración.
- **CLI de chat utilizable**: `opensquilla chat` tiene una interfaz de terminal estable, salida en streaming, entrada en cola, descubrimiento del modo slash, barras de herramientas/estado y un comportamiento de prompt en vivo más determinista.
- **Automatización de cron entre superficies**: los trabajos cron ahora cubren programaciones estructuradas, ejecuciones exactas/periódicas/cron con reconocimiento de zona horaria, entrega por canal o webhook, destinos de fallo, ejecuciones manuales y paridad entre WebUI/CLI/RPC.
- **Mejores canales de Feishu y Discord**: los adaptadores de canal exponen metadatos de capacidad más claros, una gestión más segura de DM/grupos, rutas nativas de archivos y artefactos, y un comportamiento mejorado de adjuntos/hilos, mientras que las acciones privilegiadas mantienen un alcance acotado.
- **Turnos de larga duración más robustos**: los turnos fallidos se mantienen fuera de la repetición del proveedor, las llamadas de herramienta mal formadas se gestionan de forma más segura y los reintentos sujetos a aprobación esperan las decisiones del operador.
- **Presupuestación más inteligente de contexto y herramientas**: la compactación según el presupuesto del proveedor, la preservación de la caché de prompts, los resultados de herramienta acotados y la concurrencia consciente de efectos secundarios hacen que las grandes sesiones con uso intensivo de herramientas sean más predecibles.
- **Pulido de la Web UI y de la release**: se afinaron para 0.2.0 el orden por recencia, el diseño de tablas, los controles móviles, las notificaciones duplicadas, los formularios de configuración, las URL de release y las rutas de instalación.
Notas completas: [`CHANGELOG.md`](CHANGELOG.md) ·
[notas de la versión](https://opensquilla.ai/news/).
---
## Características clave
| Capacidad | Qué hace |
| --- | --- |
| **Enrutamiento eficiente en tokens** | `SquillaRouter` —un clasificador local de LightGBM + ONNX incluido en el extra `recommended`— puntúa cada turno según su longitud, idioma, código, palabras clave y embeddings semánticos, y luego lo enruta a través de cuatro niveles (C0C3; los antiguos nombres T0T3 son alias) hacia el modelo capaz más económico. La clasificación se ejecuta en el dispositivo; tu prompt nunca sale de la máquina para tomar esa decisión. |
| **Razonamiento y prompts adaptativos** | OpenSquilla solicita razonamiento extendido únicamente para los turnos que el enrutador puntúa como complejos, y el prompt del sistema se ajusta a la complejidad de la tarea: ligero para los turnos triviales, con instrucciones completas para los complejos. |
| **Más de 20 proveedores de LLM** | El registro de proveedores apunta a más de 20 backends de LLM —TokenRhythm, OpenRouter, OpenAI, Anthropic, Ollama, DeepSeek, Gemini, DashScope/Qwen, Moonshot, Mistral, Groq, Zhipu, SiliconFlow, vLLM, LM Studio y más— con selección de proveedor principal más reserva; el onboarding de la primera ejecución expone el subconjunto verificado. |
| **Skills bajo demanda y MCP** | 15 skills incluidas (coding, GitHub, cron, pptx/docx/xlsx/pdf, resumen, tmux, clima y más) se cargan solo cuando la tarea lo necesita. OpenSquilla es un cliente MCP y también puede ejecutarse como servidor MCP: `opensquilla mcp-server run` necesita el extra `mcp` (instala `opensquilla[recommended,mcp]`). Las skills se pueden crear, instalar y publicar desde la CLI. |
| **Memoria local persistente** | Un `MEMORY.md` curado más notas Markdown fechadas, consultadas con búsqueda de palabras clave de texto completo de SQLite y recuerdo semántico con `sqlite-vec`. Los embeddings se ejecutan en el dispositivo mediante el ONNX incluido, o puedes cambiar a OpenAI/Ollama. Están disponibles un decaimiento exponencial opcional y una consolidación «dream» opcional. |
| **Sandbox de seguridad por capas** | Tres niveles de política (Standard / Strict / Locked) sobre una matriz de permisos. Bubblewrap aísla la ejecución de código en Linux; el backend Seatbelt de macOS actualmente solo renderiza perfiles (la ejecución está pendiente), y todavía no hay backend de sandbox en Windows. Un registro de denegaciones (denial ledger) pausa automáticamente las ejecuciones autónomas tras denegaciones repetidas, las salidas rechazadas se purgan, y los metadatos de las skills y los resultados de las herramientas se escapan en XML como protección contra la inyección de prompts. |
| **Herramientas integradas** | Lectura/escritura/edición de archivos, shell y procesos en segundo plano, git, búsqueda web (DuckDuckGo, Bocha, Brave, Tavily o Exa) y fetch tras una protección SSRF, creación de hojas de cálculo/PPTX/PDF, generación de imágenes y conversión de texto a voz. |
| **Gateway unificado** | Un servidor ASGI de Starlette en `127.0.0.1:18791` con RPC por WebSocket y una consola de control integrada (`/control/`). La Web UI, la CLI y los canales de Terminal, WebSocket, Slack, Telegram, Discord, Feishu, DingTalk, WeCom, Matrix y QQ comparten todos un mismo `TurnRunner`. |
| **Sesiones duraderas, subagentes y programación** | Almacenamiento de sesiones, transcripciones y repetición respaldado por SQLite, con espacios de trabajo por agente. Los agentes generan subagentes con profundidad acotada, y un `SchedulerEngine` con un parser de cron incorporado ejecuta trabajos recurrentes mediante `opensquilla cron`. |
| **Controles del operador** | Las aprobaciones con humano en el bucle (human-in-the-loop) pueden pausar llamadas de herramienta sensibles a la espera de una decisión; los resúmenes de tokens y coste por turno y por sesión (`opensquilla cost`) y los diagnósticos están disponibles desde la CLI y la Web UI. |
Documentación de MetaSkill: [`docs/features/meta-skills.md`](docs/features/meta-skills.md),
[`docs/features/meta-skill-user-guide.md`](docs/features/meta-skill-user-guide.md)
y [`docs/authoring/meta-skills.md`](docs/authoring/meta-skills.md).
---
## Resultados de benchmark
Resultados promedio de PinchBench 1.2.1 en 25 tareas:
| Agente | Modelo base | Puntuación media | Tokens de entrada totales | Tokens de salida totales | Coste total |
| --- | ---: | ---: | ---: | ---: | ---: |
| OpenSquilla | Enrutador de modelos (Opus4.7, GLM5.1, DS4 Flash) | 0.9251 | 1,721,328 | 61,475 | $0.688 |
| OpenClaw | Claude Opus 4.7 | 0.9255 | 3,066,243 | 50,890 | $6.233 |
La puntuación es la media de las 25 tareas; los recuentos de tokens y el coste son totales de la ejecución completa.
---
<a id="troubleshooting"></a>
## Solución de problemas
<details>
<summary>macOS: <code>Library not loaded: @rpath/libomp.dylib</code></summary>
Si el arranque registra `Library not loaded: @rpath/libomp.dylib` desde `lightgbm/lib/lib_lightgbm.dylib`, OpenSquilla sigue funcionando con enrutamiento directo a un único modelo, pero el runtime `SquillaRouter` incluido permanece inactivo hasta que se instale el runtime OpenMP de macOS.
La aplicación de escritorio incluye el runtime nativo que necesita. Si usaste la instalación rápida desde terminal o la instalación desde el código fuente en un shell, instala `libomp` con Homebrew y reinicia el gateway:
```sh
brew install libomp
opensquilla gateway restart
```
</details>
<details>
<summary>Windows: <code>DLL load failed</code> / runtime de Visual C++</summary>
Si el arranque registra `DLL load failed while importing onnxruntime_pybind11_state`, OpenSquilla sigue funcionando con enrutamiento directo a un único modelo, pero el runtime `SquillaRouter` incluido permanece inactivo hasta que se instale el Visual C++ Redistributable para Visual Studio 20152022 (x64).
El instalador de PowerShell desde el código fuente intenta instalar el redistributable mediante `winget`. Si usaste la instalación rápida desde terminal, o `winget` no está disponible, instálalo manualmente y reinicia PowerShell: <https://aka.ms/vs/17/release/vc_redist.x64.exe>. Luego restaura el enrutador recomendado:
```powershell
opensquilla onboard --provider openrouter --api-key-env OPENROUTER_API_KEY --router recommended
opensquilla gateway restart
```
</details>
---
## Créditos
OpenSquilla está inspirado en [OpenClaw](https://github.com/openclaw/openclaw). El contenido de terceros incluido se atribuye en [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
Los colaboradores de la comunidad se reconocen en [`CONTRIBUTORS.md`](CONTRIBUTORS.md), incluidas las notas de atribución específicas de cada release para el trabajo combinado con squash o reproducido.
---
## Colaboradores
Gracias a todas las personas que contribuyen a OpenSquilla.
<p align="center">
<a href="https://github.com/opensquilla/opensquilla/graphs/contributors">
<img src="https://contrib.rocks/image?repo=opensquilla/opensquilla&max=100&columns=10" alt="OpenSquilla contributors" />
</a>
</p>
---
## Cómo contribuir
Las contribuciones de todo tipo son bienvenidas: informes de errores, ideas de funcionalidades, documentación, nuevos adaptadores de proveedores o canales, skills y trabajo en el runtime central. Consulta [`CONTRIBUTING.md`](CONTRIBUTING.md) y luego abre una issue o una pull request en [GitHub](https://github.com/opensquilla/opensquilla).
[Código de conducta](CODE_OF_CONDUCT.md) · [Seguridad](SECURITY.md) ·
[Soporte](SUPPORT.md) · [Licencia](LICENSE) (Apache-2.0)
+776
View File
@@ -0,0 +1,776 @@
<!-- Traduit depuis README.md @ 8794ffbe. Le README anglais fait foi. -->
<!-- Vérifier l'obsolescence : git log 8794ffbe..HEAD -- README.md -->
# OpenSquilla — Agent IA économe en Token
<p align="center">
<img src="assets/opensquilla-long-logo.png" alt="OpenSquilla logo" width="500">
</p>
<p align="center">
<b>À budget égal, faites en sorte que votre Agent fasse plus, et le fasse mieux.</b><br>
Un Agent IA à micro-noyau — routage intelligent, mémoire persistante, bac à sable sécurisé, recherche intégrée et embeddings locaux.
</p>
<p align="center">
<a href="https://github.com/opensquilla/opensquilla/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/opensquilla/opensquilla/ci.yml?style=for-the-badge" alt="CI"></a>
<a href="https://opensquilla.ai/"><img src="https://img.shields.io/badge/website-opensquilla.ai-blue?style=for-the-badge" alt="Website"></a>
<a href="https://github.com/opensquilla/opensquilla/releases"><img src="https://img.shields.io/github/v/release/opensquilla/opensquilla?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
<a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?style=for-the-badge" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue?style=for-the-badge" alt="Apache 2.0 License"></a>
</p>
<p align="center">
<a href="README.md">English</a> · <a href="README.zh-Hans.md">中文</a> · <a href="README.ja.md">日本語</a> · <b>Français</b> · <a href="README.de.md">Deutsch</a> · <a href="README.es.md">Español</a>
</p>
> Ce document est traduit du [`README.md`](README.md) anglais ; en cas de divergence, la version anglaise fait foi.
---
## Actualités
- 📢 **2026-07-03** — Notre rapport technique **[Agentic Routing: The Harness-Native Data Flywheel](docs/releases/agentic_routing_v0.pdf)** (préversion) est disponible, publié en même temps qu'OpenSquilla **0.5.0 Preview 1**. Il détaille comment le routeur natif du harness transforme le trafic quotidien des agents en un volant d'inertie de données qui s'améliore de lui-même.
---
## Présentation
OpenSquilla est un Agent IA à micro-noyau, économe en Token. Un routeur de modèles
local envoie chaque tour au modèle le moins coûteux capable de le traiter, tandis
que la mémoire persistante, un bac à sable en couches, la recherche web intégrée et
les embeddings exécutés sur l'appareil viennent compléter une boucle de tour unique
et partagée.
Chaque point d'entrée — Web UI, CLI et canaux de chat — passe par cette même boucle,
si bien que la répartition des outils, les nouvelles tentatives et la journalisation
des décisions se comportent de façon identique partout. Une couche de fournisseurs
enfichable dialogue avec TokenRhythm, OpenRouter, OpenAI, Anthropic, Ollama, DeepSeek, Gemini,
Qwen/DashScope et plus de 20 autres fournisseurs de LLM, sans aucun changement dans
votre code ni dans votre schéma de configuration.
OpenSquilla 0.5.0 Preview 3 est la préversion actuelle.
Pour une documentation produit orientée tâches, commencez par le
[Guide produit OpenSquilla](README.product.md) ou par l'[index de la
documentation](docs/README.md).
---
## Installation
OpenSquilla fonctionne sous Windows, macOS et Linux. Choisissez la voie qui
correspond à votre cas d'usage.
Les installateurs de bureau et l'installation rapide en terminal vous fournissent
une **version** préconstruite — aucun Git requis. Les deux
autres — Installation depuis les sources et Développement depuis les sources —
construisent **à partir d'un dépôt Git** (`git clone` + Git LFS).
Les commandes d'installation de la version publiée utilisent les ressources de release
GitHub publiées. Les installations de wheel Python utilisent des noms de fichier de
wheel versionnés, car les installateurs valident la version intégrée au nom de
fichier du wheel.
Pour un usage bureau en 0.5.0 Preview 3, préférez les installateurs de bureau empaquetés issus de la
Release GitHub : `OpenSquilla-0.5.0-rc3-mac-arm64.dmg` sous macOS et
`OpenSquilla-0.5.0-rc3-win-x64.exe` sous Windows.
| Voie | Public | Quand l'utiliser |
| --- | --- | --- |
| [Installateurs de bureau](#desktop-installers) **(recommandé pour le bureau)** | Utilisateurs macOS et Windows | Application de bureau empaquetée |
| [Installation rapide en terminal](#quick-terminal-install) **(recommandé)** | Utilisateurs finaux sur tout OS | Wheel de la version publiée depuis un terminal |
| [Installation depuis les sources](#install-from-source) | Utilisateurs suivant `main` | Exécuter depuis un dépôt, sans le modifier |
| [Développement depuis les sources](#develop-from-source) | Contributeurs | Modifier, tester ou déboguer les sources |
### Prérequis
| Exigence | Installation rapide en terminal | Installation depuis les sources | Développement depuis les sources |
| --- | :---: | :---: | :---: |
| Python 3.12+ | via `uv` | via `uv` ou le système | via `uv` |
| Git + Git LFS | — | requis | requis |
| `uv` | installé s'il manque | recommandé | requis |
Le profil `recommended` par défaut installe **SquillaRouter** — le routeur de modèles
exécuté sur l'appareil d'OpenSquilla — ainsi que ses ressources de modèle ;
`OPENSQUILLA_INSTALL_PROFILE=core` omet ces dépendances. L'indicateur d'onboarding
distinct `--router disabled` conserve les dépendances installées mais désactive le
routeur à l'exécution.
Sous Windows, l'environnement d'exécution ONNX intégré à SquillaRouter a aussi besoin
de l'environnement d'exécution Visual C++. L'installateur PowerShell depuis les
sources l'installe automatiquement via `winget` ; la voie **Installation rapide en terminal** (`uv tool install`) ne le fait
pas — si le démarrage journalise une erreur `DLL load failed`, installez-le
manuellement (voir [Dépannage](#troubleshooting)). OpenSquilla continue de fonctionner
avec un routage direct vers un modèle unique jusqu'à ce qu'il soit installé.
Lors des installations en terminal sous macOS, l'environnement d'exécution LightGBM de
SquillaRouter peut aussi avoir besoin de la bibliothèque OpenMP du système.
L'application de bureau embarque l'environnement d'exécution dont elle a besoin,
mais l'**Installation rapide en terminal** n'installe pas les bibliothèques
Homebrew/système. Si le démarrage journalise `Library not loaded:
@rpath/libomp.dylib`, exécutez `brew install libomp`, puis redémarrez la passerelle.
OpenSquilla continue de fonctionner avec un routage direct vers un modèle unique
jusqu'à ce qu'il soit installé.
Liens d'installation : [Git](https://git-scm.com/downloads) ·
[Git LFS](https://git-lfs.com/) ·
[uv](https://docs.astral.sh/uv/getting-started/installation/).
<a id="desktop-installers"></a>
### Installateurs de bureau
Les installateurs de bureau 0.5.0 Preview 3 empaquettent la console de contrôle Vue et
l'environnement d'exécution de la passerelle dans une enveloppe Electron.
- macOS Apple Silicon : <https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/OpenSquilla-0.5.0-rc3-mac-arm64.dmg>
- Windows x64 : <https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/OpenSquilla-0.5.0-rc3-win-x64.exe>
Quittez toute application de bureau OpenSquilla en cours d'exécution avant la mise à
niveau. Les fichiers `~/.opensquilla/config.toml` et les données de session existants
sont réutilisés.
Pour mettre à niveau l'application de bureau Windows de RC3 vers RC4 ou une version
ultérieure, exécutez le nouvel installateur directement sur l'installation existante.
Ne désinstallez pas RC3 auparavant : son programme de désinstallation peut supprimer
les données utilisateur de l'application. Sauvegardez `%APPDATA%\OpenSquilla` avant
la mise à niveau. Les installateurs RC4 et ultérieurs conservent les données du profil
lors d'une désinstallation normale.
<a id="quick-terminal-install"></a>
### Installation rapide en terminal
La voie recommandée sous Windows, macOS et Linux. `uv` installe OpenSquilla dans son
propre environnement isolé et gère son propre Python — aucun Python système requis.
Cette voie n'installe que des versions publiées ; pour `main`, des branches de
développement ou des dépôts locaux, utilisez l'[Installation depuis les
sources](#install-from-source).
**1. Installer `uv`** — à ignorer si `uv --version` fonctionne déjà.
Linux / macOS :
```sh
curl -LsSf https://astral.sh/uv/install.sh | sh
. "$HOME/.local/bin/env"
```
Windows PowerShell :
```powershell
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
$env:Path = "$env:USERPROFILE\.local\bin;" + $env:Path
```
**2. Installer OpenSquilla** — la même commande sur toutes les plateformes.
```sh
uv tool install --python 3.12 "opensquilla[recommended] @ https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/opensquilla-0.5.0rc3-py3-none-any.whl"
```
Cela installe le wheel OpenSquilla depuis l'URL de release, puis laisse `uv`
télécharger les dépendances déclarées par les extras sélectionnés. L'extra
`recommended` par défaut inclut les dépendances d'exécution de SquillaRouter telles
que ONNX Runtime, LightGBM, NumPy et tokenizers ; une première installation nécessite
donc un accès réseau, à moins que ces wheels ne soient déjà en cache. `uv` n'installe
pas les environnements d'exécution natifs du système, comme `libomp` sous macOS ou le
Visual C++ Redistributable sous Windows ; consultez le [Dépannage](#troubleshooting)
si l'environnement d'exécution du routeur signale une erreur de chargement de
bibliothèque native.
**3. Configurer et exécuter.**
```sh
opensquilla onboard
opensquilla gateway run
```
> [!NOTE]
> Si `opensquilla` est introuvable juste après une installation `uv` neuve, ouvrez un
> nouveau terminal, ou réexécutez la ligne PATH de l'étape 1.
Pour une installation entièrement épinglée, utilisez l'URL de wheel versionnée :
`https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/opensquilla-0.5.0rc3-py3-none-any.whl`.
<a id="install-from-source"></a>
### Installation depuis les sources
Utilisez cette voie pour exécuter OpenSquilla depuis un dépôt sans le modifier. Le
clone ne sert que de source du paquet pour l'installateur ; après l'installation,
utilisez la commande `opensquilla` — n'exécutez pas `uv run`. Choisissez plutôt
[Développement depuis les sources](#develop-from-source) si vous comptez modifier le
code.
1. **Cloner avec les ressources LFS**
```sh
git lfs install
git clone https://github.com/opensquilla/opensquilla.git
cd opensquilla
git lfs pull --include="src/opensquilla/squilla_router/models/**"
```
2. **Exécuter l'installateur**
**macOS / Linux**
```sh
bash scripts/install_source.sh
```
**Windows PowerShell**
```powershell
powershell -ExecutionPolicy Bypass -File ./scripts/install_source.ps1
```
Le script installe `.[recommended]` (SquillaRouter + mémoire + modèles locaux)
dans un environnement utilisateur dédié via `uv tool install`, en se rabattant sur
`python -m pip install --user` lorsque `uv` n'est pas disponible. Ouvrez un nouveau
terminal si `opensquilla` n'est pas dans le `PATH` après l'installation.
3. **(facultatif) Installer des extras avancés.** La plupart des canaux — Feishu,
Telegram, DingTalk, QQ, WeCom, Slack et Discord — fonctionnent depuis
l'installation de base. Les extras optionnels sont :
- `matrix` — canal Matrix (installe aussi `matrix-nio`)
- `matrix-e2e` — canal Matrix avec chiffrement de bout en bout (nécessite libolm)
- `document-extras` — génération de PDF via WeasyPrint
```sh
OPENSQUILLA_INSTALL_EXTRAS=matrix bash scripts/install_source.sh # macOS / Linux
```
```powershell
powershell -ExecutionPolicy Bypass -File ./scripts/install_source.ps1 -Extras matrix # Windows
```
4. **Configurer et exécuter** — voir [Configuration](#configuration).
<details>
<summary>Installation depuis les sources — prérequis terminal et options de l'installateur</summary>
**Installer les prérequis (Git, Git LFS, uv) depuis un terminal**
Windows PowerShell :
```powershell
winget install --id Git.Git -e
winget install --id GitHub.GitLFS -e
powershell -ExecutionPolicy Bypass -c "irm https://astral.sh/uv/install.ps1 | iex"
git lfs install
```
macOS (Homebrew) :
```sh
brew install git git-lfs uv
git lfs install
```
Debian / Ubuntu :
```sh
sudo apt update && sudo apt install -y git git-lfs
curl -LsSf https://astral.sh/uv/install.sh | sh
git lfs install
```
Sous Fedora, utilisez `sudo dnf install -y git git-lfs` ; sous Arch, utilisez
`sudo pacman -S --needed git git-lfs` ; puis installez `uv` avec la commande `curl`
ci-dessus. Les modifications du PATH effectuées par ces installateurs s'appliquent aux
nouvelles sessions de terminal.
**Variables d'environnement de l'installateur et vérifications du PATH**
```sh
OPENSQUILLA_INSTALL_PROFILE=core bash scripts/install_source.sh # runtime minimal, sans SquillaRouter
OPENSQUILLA_INSTALL_DRY_RUN=1 bash scripts/install_source.sh # afficher uniquement le plan
```
Vérifiez quel `opensquilla` votre shell exécute avec `command -v opensquilla`
(macOS/Linux) ou `where.exe opensquilla` (Windows). S'il n'est pas dans le `PATH`,
exécutez `uv tool update-shell`. Après une réinstallation depuis un dépôt local,
redémarrez la passerelle afin qu'elle charge le paquet mis à jour.
</details>
<a id="develop-from-source"></a>
### Développement depuis les sources
Utilisez cette voie lorsque vous travaillez sur le code source d'OpenSquilla :
apporter des changements, exécuter des tests ou déboguer le comportement par rapport à
ce dépôt. Ce n'est pas la voie d'installation normale. Contrairement à
[Installation depuis les sources](#install-from-source), cette voie nécessite `uv` :
`uv sync` crée un `.venv` local au dépôt, et `uv run` exécute les commandes par rapport
aux fichiers de ce dépôt.
```sh
uv sync --extra recommended --extra dev
uv run opensquilla --help
```
L'extra `recommended` inclut aussi SquillaRouter pour le développement ; l'extra `dev`
installe les outils de test, de lint et de vérification de types. Installez des extras
supplémentaires dans le même environnement que celui que vous exécutez :
```sh
uv sync --extra recommended --extra dev --extra matrix
uv run opensquilla channels status matrix --json
```
Dans ce mode, préfixez chaque commande `opensquilla` de la
[Configuration](#configuration) par `uv run`. Ne déboguez pas un dépôt de développement
via une commande `opensquilla` locale à l'utilisateur — cette commande s'exécute dans
un environnement Python différent.
### Désinstallation
Supprimez OpenSquilla avec `opensquilla uninstall`. Il conserve vos données par défaut
et ne supprime que le programme :
```sh
opensquilla uninstall --dry-run # prévisualiser ce qui serait supprimé et conservé
opensquilla uninstall # supprimer le programme, conserver vos données
```
Pour supprimer aussi les données, activez-le explicitement :
```sh
opensquilla uninstall --purge-state # sessions, journaux, cache, planificateur, mémoire
opensquilla uninstall --purge-config # config.toml et secrets (.env)
opensquilla uninstall --purge-all # tout (vous demande de saisir une confirmation)
```
La passerelle en cours d'exécution est d'abord drainée et arrêtée, la suppression
reste à l'intérieur du répertoire personnel d'OpenSquilla, et les installations
Docker/bureau reçoivent à la place des étapes de suppression guidées. Consultez
[`docs/cli.md`](docs/cli.md#uninstall) pour la référence complète.
---
## Confidentialité de l'installation
OpenSquilla utilise une télémétrie d'installation anonyme pour estimer le nombre
d'installations, l'adoption des versions et la compatibilité d'exécution. Les données
sont envoyées au premier démarrage de la passerelle et une fois par version
d'OpenSquilla. Les envois utilisent un délai d'expiration court et ne bloquent jamais
le démarrage.
Ce qui est envoyé :
- la version du schéma
- un condensé `install_id` stable généré localement
- la version d'OpenSquilla
- le type d'événement (`install` ou `version_seen`)
- la méthode d'installation (`pip`, `source`, `docker`, `desktop` ou `unknown`)
- le système d'exploitation, la version de l'OS, l'architecture du processeur et la
version majeure/mineure de Python
- les horodatages de première observation et d'envoi
- un marqueur d'environnement CI/test (`ci_environment`)
L'`install_id` est un condensé local SHA-256 à sens unique dérivé des adresses MAC
utilisables, puis des adresses IP locales lorsqu'aucune MAC n'est disponible, avec une
valeur de repli aléatoire persistante. Les valeurs MAC/IP brutes ne sont pas envoyées.
Ce qui n'est pas envoyé : noms d'utilisateur, noms d'hôte, chemins, clés d'API,
configuration des fournisseurs, contenu de chat/session/mémoire/Agent, noms de fichiers
ou contenu de fichiers. L'IP source peut être visible des serveurs HTTP au niveau de la
couche de transport, mais ne fait pas partie de la charge utile.
Pour la désactiver :
```sh
OPENSQUILLA_TELEMETRY_DISABLED=true
```
Les déploiements avancés peuvent utiliser leur propre point de terminaison :
```sh
OPENSQUILLA_TELEMETRY_ENDPOINT=https://example.com/v1/install
```
---
<a id="configuration"></a>
## Configuration
### Configuration de premier démarrage
`opensquilla onboard` est l'assistant interactif de premier démarrage. Il écrit le
fichier de configuration actif et conserve les secrets des fournisseurs dans des
variables d'environnement lorsque vous passez `--api-key-env`. Le routeur a pour valeur
par défaut `recommended` (SquillaRouter sur les fournisseurs pris en charge) ; passez
`--router disabled` pour un routage direct vers un modèle unique.
```sh
opensquilla onboard # assistant interactif complet
opensquilla onboard --if-needed # idempotent : sûr pour les scripts et réinstallations
opensquilla onboard --minimal # fournisseur uniquement ; ignore les canaux et la recherche
opensquilla onboard status # inspecter chaque section de configuration sans écrire
```
En SSH, en CI ou dans tout environnement sans TTY, utilisez la forme non interactive —
conservez le secret dans l'environnement et passez son **nom**, pas sa valeur :
**Linux / macOS**
```sh
export OPENROUTER_API_KEY="sk-..."
opensquilla onboard --provider openrouter --api-key-env OPENROUTER_API_KEY
```
**Windows PowerShell**
```powershell
$env:OPENROUTER_API_KEY="sk-..."
opensquilla onboard --provider openrouter --api-key-env OPENROUTER_API_KEY
```
OpenRouter n'est qu'un exemple — substituez n'importe quel fournisseur pris en charge
et sa variable de clé d'API.
Reconfigurez une section plus tard sans refaire l'assistant complet (ces exemples
supposent que la clé d'API concernée est déjà dans l'environnement) :
```sh
opensquilla configure provider --provider openai --model gpt-4o --api-key-env OPENAI_API_KEY
opensquilla configure router --router recommended
opensquilla configure search --search-provider duckduckgo
opensquilla configure search --search-provider exa --api-key-env EXA_API_KEY
opensquilla configure channels
```
Sections : `provider`, `router`, `channels`, `search`, `image-generation`,
`memory-embedding`. La Web UI expose le même catalogue et le même modèle de statut sur
`/control/setup` : Provider et Router constituent la voie rapide, tandis que Channels,
Search, Image generation et Memory embedding se trouvent dans le Capability Center et
peuvent être configurés plus tard. Des canaux vides sont traités comme un
désengagement, pas comme une configuration échouée.
**Ordre de chargement de la configuration :** `OPENSQUILLA_GATEWAY_CONFIG_PATH` →
`./opensquilla.toml` → `~/.opensquilla/config.toml` → valeurs par défaut intégrées.
Pour les secrets individuels, les valeurs de l'environnement l'emportent toujours sur
les valeurs des fichiers.
### Migrer depuis OpenClaw ou Hermes Agent
Si vous avez déjà un état sous `~/.openclaw` ou `~/.hermes`, exécutez d'abord un dry run
pour inspecter le rapport de migration, puis appliquez-le explicitement :
```sh
opensquilla migrate openclaw --json
opensquilla migrate openclaw --apply
opensquilla migrate hermes --json
opensquilla migrate hermes --apply
```
Utilisez `opensquilla migrate --source openclaw,hermes --apply` pour importer les deux
répertoires personnels par défaut. N'ajoutez `--migrate-secrets` qu'après avoir examiné
le rapport du dry run. Consultez [`MIGRATION.md`](MIGRATION.md) pour les chemins
personnalisés et la gestion des conflits.
### Exécution
```sh
opensquilla gateway run # premier plan, 127.0.0.1:18791
opensquilla gateway start --json # arrière-plan + attente de l'état de santé
opensquilla chat # REPL interactif
opensquilla agent -m "your prompt" # exécution unique, adaptée à l'automatisation
```
Ouvrez la Web UI sur <http://127.0.0.1:18791/control/>. La vue **Health** (santé)
indique si OpenSquilla est prêt, ce qui ne l'est pas, et les prochaines étapes de
rétablissement. Depuis la CLI, exécutez :
```sh
opensquilla doctor
opensquilla doctor --json
opensquilla doctor --config ./opensquilla.toml --json
```
`/health` et `/healthz` sont des points de terminaison de liveness légers pour les
vérifications de processus. `opensquilla doctor` et la vue Health de la Web UI sont les
surfaces de readiness pour la configuration des fournisseurs, la mémoire, les journaux,
la recherche, les canaux, la posture du bac à sable, le routeur, la génération d'images
et les conseils de rétablissement. Appuyez sur `Ctrl+C` pour arrêter une passerelle au
premier plan.
Les autres groupes de commandes incluent `sessions`, `skills`, `memory`, `migrate`,
`cron`, `channels`, `providers`, `models` et `cost`. Exécutez `opensquilla --help` ou
`opensquilla <groupe> --help` pour les détails.
<details>
<summary>Configuration avancée — vérifier un canal, liaison réseau publique, Docker</summary>
**Connecter et vérifier un canal de messagerie**
Enregistrer un canal est un changement de configuration, pas une preuve de
connectivité à l'exécution. Redémarrez la passerelle après des modifications de canal,
puis vérifiez le canal en direct :
```sh
opensquilla gateway restart
opensquilla channels status <name> --json
```
Considérez un canal comme connecté uniquement lorsque la charge utile de statut indique
`enabled=true`, `configured=true` et `connected=true`. Feishu utilise par défaut le
mode websocket, Telegram le polling, et Slack peut utiliser le Socket Mode — aucun de
ces modes ne nécessite d'URL publique. Le mode webhook de Feishu, le mode webhook de
Telegram, le mode webhook de Slack et WeCom nécessitent une URL publique, accessible
par le fournisseur.
**Liaison réseau publique**
Pour atteindre la Web UI depuis une autre machine, liez la passerelle à toutes les
interfaces et utilisez l'IP publique de l'hôte :
```sh
opensquilla gateway run --listen 0.0.0.0 --port 18791
```
L'accès public requiert également que le pare-feu de l'hôte ou le groupe de sécurité
cloud autorise le trafic TCP entrant sur ce port. N'exposez pas la passerelle avec
`[auth] mode = "none"` — configurez l'authentification par token avant de lier à
`0.0.0.0`.
**Docker**
Des images multi-architecture préconstruites (`amd64`/`arm64`) sont publiées sur
`ghcr.io/opensquilla/opensquilla` à chaque tag de release —
[`docs/docker.md`](docs/docker.md) est le guide conteneur complet
(serveurs domestiques et NAS, exposition LAN avec authentification par jeton,
mises à niveau) :
```sh
OPENSQUILLA_GATEWAY_IMAGE=ghcr.io/opensquilla/opensquilla:latest docker compose up -d
```
Sans `OPENSQUILLA_GATEWAY_IMAGE`, la voie compose exécute une image
`opensquilla:local` que vous construisez vous-même.
Construisez-la à partir d'un dépôt source dont les ressources de routeur Git LFS ont été
récupérées (voir [Installation depuis les sources](#install-from-source) pour le clone
et `git lfs pull`) :
```sh
docker build -t opensquilla:local .
```
`./start.sh` (ou `start.ps1` sous Windows) exécute ensuite `docker compose up -d` et
suit les journaux de la passerelle. Docker évite une chaîne d'outils Python sur l'hôte —
pas la construction de l'image locale.
</details>
Les niveaux de fournisseurs, le réglage du bac à sable, la génération d'images et les
paramètres de concurrence se trouvent dans `opensquilla.toml.example`.
---
## Nouveautés de la 0.4.1
OpenSquilla 0.4.1 est une version de maintenance pour la ligne bureau et Control UI :
- **Fiabilité du bureau** - les vérifications de la passerelle empaquetée couvrent
désormais le mode Coding, `code-task` et le démarrage de SquillaRouter, et la gestion
des fenêtres/artefacts de bureau est plus stable.
- **Prise en charge client en six langues** - la Control UI et le client de bureau
prennent en charge l'anglais, le chinois simplifié, le japonais, le français,
l'allemand et l'espagnol sur les surfaces de premier affichage et de réglages.
- **Mode Coding et empaquetage du routeur** - les builds de bureau échouent rapidement
si les ressources du routeur sont manquantes ou encore des pointeurs Git LFS, ce qui
évite des paquets de release dégradés.
- **Télémétrie et finitions Windows** - la télémétrie d'installation ignore les
environnements CI et de test, et les ressources de bureau Windows utilisent le logo
OpenSquilla.
- **Gouvernance de la ligne principale** - les pull requests ordinaires et
l'intégration des releases sont alignées autour de `main`, les branches de
mainteneur étant réservées aux travaux de release, hotfix, staging, intégration et
bac à sable.
Notes complètes : [`CHANGELOG.md`](CHANGELOG.md) ·
[`docs/releases/0.4.1.md`](docs/releases/0.4.1.md).
## Nouveautés de la 0.2.1
OpenSquilla 0.2.1 est une version de maintenance axée sur le démarrage des paquets de
release et la fiabilité des Agents à longue durée d'exécution :
- **Démarrage de la version portable Windows** — le lanceur portable détecte et amorce
mieux l'environnement d'exécution Visual C++ requis par le routeur ONNX intégré.
- **Tours d'Agent à longue durée** — les sessions WebUI à forte intensité d'outils se
rétablissent plus proprement après des résultats d'outils surdimensionnés, des appels
d'outils mal formés, des transferts de livraison d'artefacts et des réponses finales
dégradées.
- **Sortie WebUI plus propre** — les marqueurs d'artefacts générés sont tenus à l'écart
de la relecture de chat normale tandis que les fichiers livrés restent visibles.
- **Score de rappel de la mémoire** — les vecteurs d'embedding locaux et compatibles
OpenAI sont normalisés avant la recherche sémantique, et les fortes correspondances
de mots-clés restent exploitables lorsque les scores vectoriels sont faibles.
Notes complètes : [`CHANGELOG.md`](CHANGELOG.md) ·
[notes de version](https://opensquilla.ai/news/).
## Nouveautés de la 0.2.0
Cette version étend OpenSquilla à la migration, au chat en CLI, aux canaux, à la
planification et aux travaux d'outils de longue durée :
- **Voie de migration depuis des répertoires personnels d'Agent existants** —
`opensquilla migrate` prévisualise et applique les imports depuis des répertoires
personnels OpenClaw/Hermes existants, y compris la mémoire, les fichiers de persona,
les compétences, la configuration MCP/canal, la gestion des conflits et les rapports
de migration.
- **CLI de chat utilisable** — `opensquilla chat` dispose d'une interface terminal
stable, d'une sortie en streaming, d'une saisie mise en file d'attente, d'une
découverte du mode slash, de bandeaux d'outils/de statut, et d'un comportement
d'invite en direct plus déterministe.
- **Automatisation cron multi-surface** — les tâches cron couvrent désormais les
planifications structurées, les exécutions exactes/à intervalle/cron tenant compte du
fuseau horaire, la livraison par canal ou webhook, les destinations en cas d'échec,
les exécutions manuelles, ainsi que la parité WebUI/CLI/RPC.
- **Meilleurs canaux Feishu et Discord** — les adaptateurs de canal exposent des
métadonnées de capacité plus claires, une gestion des messages privés/de groupe plus
sûre, des chemins de fichiers et d'artefacts natifs, et un comportement amélioré des
pièces jointes/fils, tandis que les actions privilégiées restent à portée limitée.
- **Tours de longue durée plus robustes** — les tours échoués sont tenus à l'écart de la
relecture du fournisseur, les appels d'outils mal formés sont gérés plus sûrement, et
les nouvelles tentatives soumises à approbation attendent les décisions de
l'opérateur.
- **Budget de contexte et d'outils plus intelligent** — la compaction selon le budget du
fournisseur, la préservation du cache de prompt, des résultats d'outils bornés et une
concurrence consciente des effets de bord rendent les grandes sessions à forte
intensité d'outils plus prévisibles.
- **Finitions de la Web UI et des releases** — l'ordonnancement par récence, la mise en
page des tableaux, les contrôles mobiles, les notifications en double, les formulaires
de configuration, les URL de release et les voies d'installation sont resserrés pour
la 0.2.0.
Notes complètes : [`CHANGELOG.md`](CHANGELOG.md) ·
[notes de version](https://opensquilla.ai/news/).
---
## Fonctionnalités clés
| Capacité | Ce qu'elle fait |
| --- | --- |
| **Routage économe en Token** | `SquillaRouter` — un classifieur local LightGBM + ONNX présent dans l'extra `recommended` — évalue chaque tour selon la longueur, la langue, le code, les mots-clés et les embeddings sémantiques, puis l'achemine à travers quatre niveaux (C0C3 ; les anciens noms T0T3 sont des alias) vers le modèle le moins coûteux capable de le traiter. La classification s'exécute sur l'appareil ; votre prompt ne quitte jamais la machine pour prendre cette décision. |
| **Raisonnement et prompts adaptatifs** | OpenSquilla ne demande un raisonnement étendu que pour les tours que le routeur évalue comme complexes, et le prompt système s'adapte à la complexité de la tâche — léger pour les tours triviaux, instructions complètes pour les tours complexes. |
| **Plus de 20 fournisseurs de LLM** | Le registre des fournisseurs vise plus de 20 backends de LLM — TokenRhythm, OpenRouter, OpenAI, Anthropic, Ollama, DeepSeek, Gemini, DashScope/Qwen, Moonshot, Mistral, Groq, Zhipu, SiliconFlow, vLLM, LM Studio, et bien d'autres, avec une sélection principal-plus-repli ; l'onboarding de premier démarrage expose le sous-ensemble vérifié. |
| **Compétences à la demande et MCP** | 15 compétences intégrées (coding, GitHub, cron, pptx/docx/xlsx/pdf, résumé, tmux, météo, et plus encore) ne se chargent que lorsque la tâche en a besoin. OpenSquilla est un client MCP, et peut aussi s'exécuter comme serveur MCP — `opensquilla mcp-server run` nécessite l'extra `mcp` (installez `opensquilla[recommended,mcp]`). Les compétences peuvent être créées, installées et publiées depuis la CLI. |
| **Mémoire locale persistante** | Un `MEMORY.md` soigneusement constitué, complété par des notes Markdown datées, interrogé via la recherche par mots-clés en texte intégral de SQLite et le rappel sémantique de `sqlite-vec`. Les embeddings s'exécutent sur l'appareil via un ONNX intégré, ou basculent vers OpenAI/Ollama. Une décroissance exponentielle facultative et une consolidation « dream » activable sur option sont disponibles. |
| **Bac à sable de sécurité en couches** | Trois niveaux de stratégie (Standard / Strict / Locked) sur une matrice de permissions. Bubblewrap isole l'exécution de code sous Linux ; le backend Seatbelt de macOS ne fait pour l'instant que générer des profils (l'exécution est à venir), et il n'existe pas encore de backend de bac à sable sous Windows. Un registre de refus (denial ledger) met automatiquement en pause les exécutions autonomes après des refus répétés, les sorties rejetées sont purgées, et les métadonnées de compétences ainsi que les résultats d'outils sont échappés en XML contre l'injection de prompt. |
| **Outils intégrés** | Lecture/écriture/édition de fichiers, shell et processus en arrière-plan, git, recherche web (DuckDuckGo, Bocha, Brave, Tavily ou Exa) et récupération derrière une protection SSRF, création de feuilles de calcul/PPTX/PDF, génération d'images et synthèse vocale. |
| **Passerelle unifiée** | Un serveur ASGI Starlette sur `127.0.0.1:18791` avec RPC WebSocket et une console de contrôle intégrée (`/control/`). La Web UI, la CLI et les canaux Terminal, WebSocket, Slack, Telegram, Discord, Feishu, DingTalk, WeCom, Matrix et QQ partagent tous un même `TurnRunner`. |
| **Sessions durables, sous-Agents et planification** | Stockage des sessions, des transcriptions et des relectures adossé à SQLite, avec des espaces de travail par Agent. Les Agents engendrent des sous-Agents à profondeur bornée, et un `SchedulerEngine` doté d'un analyseur cron intégré exécute des tâches récurrentes via `opensquilla cron`. |
| **Contrôles de l'opérateur** | Les approbations avec humain dans la boucle peuvent mettre en pause les appels d'outils sensibles en attendant une décision ; les récapitulatifs de Token et de coût par tour et par session (`opensquilla cost`) ainsi que les diagnostics sont accessibles depuis la CLI et la Web UI. |
Documentation MetaSkill : [`docs/features/meta-skills.md`](docs/features/meta-skills.md),
[`docs/features/meta-skill-user-guide.md`](docs/features/meta-skill-user-guide.md),
et [`docs/authoring/meta-skills.md`](docs/authoring/meta-skills.md).
---
## Résultats des tests de performance
Résultats moyens de PinchBench 1.2.1 sur 25 tâches :
| Agent | Modèle de base | Score moyen | Total des tokens d'entrée | Total des tokens de sortie | Coût total |
| --- | ---: | ---: | ---: | ---: | ---: |
| OpenSquilla | Routeur de modèles (Opus4.7, GLM5.1, DS4 Flash) | 0.9251 | 1,721,328 | 61,475 | $0.688 |
| OpenClaw | Claude Opus 4.7 | 0.9255 | 3,066,243 | 50,890 | $6.233 |
Le score est la moyenne sur les 25 tâches ; les comptes de tokens et le coût sont les
totaux de l'exécution complète.
---
<a id="troubleshooting"></a>
## Dépannage
<details>
<summary>macOS : <code>Library not loaded: @rpath/libomp.dylib</code></summary>
Si le démarrage journalise `Library not loaded: @rpath/libomp.dylib` depuis
`lightgbm/lib/lib_lightgbm.dylib`, OpenSquilla continue de fonctionner avec un routage
direct vers un modèle unique, mais l'environnement d'exécution `SquillaRouter` intégré
reste inactif jusqu'à ce que l'environnement d'exécution OpenMP de macOS soit installé.
L'application de bureau embarque l'environnement d'exécution natif dont elle a
besoin. Si vous avez utilisé l'installation rapide en terminal ou l'installation depuis
les sources via un shell, installez `libomp` avec Homebrew et redémarrez la passerelle :
```sh
brew install libomp
opensquilla gateway restart
```
</details>
<details>
<summary>Windows : <code>DLL load failed</code> / environnement d'exécution Visual C++</summary>
Si le démarrage journalise `DLL load failed while importing
onnxruntime_pybind11_state`, OpenSquilla continue de fonctionner avec un routage direct
vers un modèle unique, mais l'environnement d'exécution `SquillaRouter` intégré reste
inactif jusqu'à ce que le Visual C++ Redistributable pour Visual Studio 20152022 (x64)
soit installé.
L'installateur PowerShell depuis les sources tente d'installer le redistributable via
`winget`. Si vous avez utilisé l'installation rapide en terminal, ou si `winget`
n'est pas disponible, installez-le manuellement et
redémarrez PowerShell : <https://aka.ms/vs/17/release/vc_redist.x64.exe>. Puis rétablissez
le routeur recommandé :
```powershell
opensquilla onboard --provider openrouter --api-key-env OPENROUTER_API_KEY --router recommended
opensquilla gateway restart
```
</details>
---
## Remerciements
OpenSquilla s'inspire d'[OpenClaw](https://github.com/openclaw/openclaw). Le contenu
tiers intégré est attribué dans
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
Les contributeurs de la communauté sont remerciés dans
[`CONTRIBUTORS.md`](CONTRIBUTORS.md), avec notamment des notes d'attribution propres à
chaque release pour les travaux fusionnés par squash ou rejoués.
---
## Contributeurs
Merci à toutes les personnes qui contribuent à OpenSquilla.
<p align="center">
<a href="https://github.com/opensquilla/opensquilla/graphs/contributors">
<img src="https://contrib.rocks/image?repo=opensquilla/opensquilla&max=100&columns=10" alt="OpenSquilla contributors" />
</a>
</p>
---
## Contribuer
Les contributions de toute nature sont les bienvenues — rapports de bugs, idées de
fonctionnalités, documentation, nouveaux adaptateurs de fournisseurs ou de canaux,
compétences et travail sur le runtime central. Consultez
[`CONTRIBUTING.md`](CONTRIBUTING.md), puis ouvrez une issue ou une pull request sur
[GitHub](https://github.com/opensquilla/opensquilla).
[Code de conduite](CODE_OF_CONDUCT.md) · [Sécurité](SECURITY.md) ·
[Support](SUPPORT.md) · [Licence](LICENSE) (Apache-2.0)
+578
View File
@@ -0,0 +1,578 @@
<!-- このファイルは README.md @ 8794ffbe から翻訳されています。正典は英語版 README です。 -->
<!-- 古くなっていないか確認するには: git log 8794ffbe..HEAD -- README.md -->
# OpenSquilla — Token を効率的に使う AI Agent
<p align="center">
<img src="assets/opensquilla-long-logo.png" alt="OpenSquilla logo" width="500">
</p>
<p align="center">
<b>同じ予算で、Agent にもっと多くを、もっと上手にこなさせる。</b><br>
マイクロカーネル AI Agent — スマートルーティング、永続メモリ、安全なサンドボックス、組み込み検索とローカル埋め込み。
</p>
<p align="center">
<a href="https://github.com/opensquilla/opensquilla/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/opensquilla/opensquilla/ci.yml?style=for-the-badge" alt="CI"></a>
<a href="https://opensquilla.ai/"><img src="https://img.shields.io/badge/website-opensquilla.ai-blue?style=for-the-badge" alt="Website"></a>
<a href="https://github.com/opensquilla/opensquilla/releases"><img src="https://img.shields.io/github/v/release/opensquilla/opensquilla?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
<a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?style=for-the-badge" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue?style=for-the-badge" alt="Apache 2.0 License"></a>
</p>
<p align="center">
<a href="README.md">English</a> · <a href="README.zh-Hans.md">中文</a> · <b>日本語</b> · <a href="README.fr.md">Français</a> · <a href="README.de.md">Deutsch</a> · <a href="README.es.md">Español</a>
</p>
> このドキュメントは英語版 [`README.md`](README.md) から翻訳されています。内容に食い違いがある場合は英語版が正典です。
---
## お知らせ
- 📢 **2026-07-03** — 技術レポート **[Agentic Routing: The Harness-Native Data Flywheel](docs/releases/agentic_routing_v0.pdf)**(プレビュー版)を、OpenSquilla **0.5.0 Preview 1** と同時に公開しました。harness ネイティブなルーターが日々の Agent トラフィックを自己改善型のデータフライホイールへと変える仕組みを詳しく解説しています。
---
## 概要
OpenSquilla は、Token を効率的に使うマイクロカーネル AI Agent です。ローカルのモデルルーターが各ターンを、それを処理できる最も安価なモデルに振り分けます。さらに永続メモリ、階層化されたサンドボックス、組み込みのウェブ検索、デバイス上で動く埋め込みが、ひとつの共有されたターンループを支えています。
すべての入口——Web UI、CLI、チャットチャネル——が同じループ上で動くため、ツールのディスパッチ、リトライ、判断ログの挙動はどこでも同一です。プラグイン可能なプロバイダ層は TokenRhythm、OpenRouter、OpenAI、Anthropic、Ollama、DeepSeek、Gemini、Qwen/DashScope をはじめとする 20 以上の LLM プロバイダと、あなたのコードや設定スキーマを変えることなくやり取りします。
OpenSquilla 0.5.0 Preview 3 が現在のプレビューリリースです。
タスク指向の製品ドキュメントについては、[OpenSquilla 製品ガイド](README.product.md)または[ドキュメント索引](docs/README.md)から始めてください。
---
## インストール
OpenSquilla は Windows、macOS、Linux で動作します。ご自身のユースケースに合った方法を選んでください。
デスクトップインストーラーとターミナルからのクイックインストールは、ビルド済みの**リリース**版をそのまま入手できます——Git は不要です。残りの 2 つ——ソースからのインストールとソースからの開発——は、**Git のチェックアウトから**ビルドします(`git clone` + Git LFS)。
リリース版のインストールコマンドは、公開された GitHub リリースのアセットを使います。Python wheel のインストールでは、バージョン付きの wheel ファイル名を使います。インストーラーが wheel ファイル名に埋め込まれたバージョンを検証するためです。
0.5.0 Preview 3 をデスクトップで使う場合は、GitHub リリースからパッケージ版デスクトップインストーラーを使うことをおすすめします。macOS では `OpenSquilla-0.5.0-rc3-mac-arm64.dmg`、Windows では `OpenSquilla-0.5.0-rc3-win-x64.exe` です。
| 方法 | 対象 | 使うべき場面 |
| --- | --- | --- |
| [デスクトップインストーラー](#desktop-installers)**(デスクトップ推奨)** | macOS および Windows ユーザー | パッケージ版デスクトップアプリ |
| [ターミナルからのクイックインストール](#quick-terminal-install)**(推奨)** | あらゆる OS のエンドユーザー | ターミナルからリリース版 wheel をインストール |
| [ソースからのインストール](#install-from-source) | `main` を追跡するユーザー | チェックアウトを編集せずに実行する |
| [ソースからの開発](#develop-from-source) | コントリビューター | ソースを編集、テスト、デバッグする |
### 前提条件
| 要件 | クイックインストール | ソースからのインストール | ソースからの開発 |
| --- | :---: | :---: | :---: |
| Python 3.12+ | `uv` 経由 | `uv` またはシステム経由 | `uv` 経由 |
| Git + Git LFS | — | 必須 | 必須 |
| `uv` | なければ自動インストール | 推奨 | 必須 |
デフォルトの `recommended` プロファイルは **SquillaRouter**——OpenSquilla のデバイス上モデルルーター——とそのモデルアセットをインストールします。`OPENSQUILLA_INSTALL_PROFILE=core` ではこれらの依存関係を省きます。これとは別の `--router disabled` というオンボーディングフラグは、依存関係はインストールしたまま、実行時にルーターをオフにします。
Windows では、SquillaRouter に同梱された ONNX ランタイムが Visual C++ ランタイムも必要とします。ソースからの PowerShell インストーラーは、`winget` 経由でこれを自動的にインストールします。一方、**ターミナルからのクイックインストール**(`uv tool install`)の経路ではインストールしません——起動時に `DLL load failed` エラーが記録された場合は、手動でインストールしてください([トラブルシューティング](#troubleshooting)を参照)。インストールされるまで、OpenSquilla は単一モデルへの直接ルーティングで動作を続けます。
macOS のターミナルインストールでは、SquillaRouter の LightGBM ランタイムがシステムの OpenMP ライブラリも必要とすることがあります。デスクトップアプリは必要なランタイムを同梱していますが、**ターミナルからのクイックインストール**は Homebrew やシステムライブラリをインストールしません。起動時に `Library not loaded: @rpath/libomp.dylib` が記録された場合は、`brew install libomp` を実行してからゲートウェイを再起動してください。インストールされるまで、OpenSquilla は単一モデルへの直接ルーティングで動作を続けます。
インストールリンク: [Git](https://git-scm.com/downloads) ·
[Git LFS](https://git-lfs.com/) ·
[uv](https://docs.astral.sh/uv/getting-started/installation/)。
<a id="desktop-installers"></a>
### デスクトップインストーラー
0.5.0 Preview 3 のデスクトップインストーラーは、Vue 製コントロールコンソールとゲートウェイランタイムを Electron シェルにまとめています。
- macOS Apple Silicon: <https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/OpenSquilla-0.5.0-rc3-mac-arm64.dmg>
- Windows x64: <https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/OpenSquilla-0.5.0-rc3-win-x64.exe>
アップグレードの前に、実行中の OpenSquilla デスクトップアプリをすべて終了してください。既存の `~/.opensquilla/config.toml` とセッションデータはそのまま再利用されます。
Windows Desktop を RC3 から RC4 以降へ更新するときは、新しいインストーラーを既存のインストールへ直接上書き実行してください。RC3 を先にアンインストールしないでください。RC3 のアンインストーラーはデスクトップのユーザーデータを削除する可能性があります。更新前に `%APPDATA%\OpenSquilla` をバックアップしてください。RC4 以降のインストーラーは通常のアンインストールでプロファイルデータを保持します。
<a id="quick-terminal-install"></a>
### ターミナルからのクイックインストール
Windows、macOS、Linux での推奨経路です。`uv` は OpenSquilla を独立した隔離環境にインストールし、専用の Python を自前で管理します——システムの Python は不要です。この経路は公開済みのリリースのみをインストールします。`main`、開発ブランチ、ローカルのチェックアウトが必要な場合は[ソースからのインストール](#install-from-source)を使ってください。
**1. `uv` をインストールする**——`uv --version` がすでに動くならスキップしてください。
Linux / macOS:
```sh
curl -LsSf https://astral.sh/uv/install.sh | sh
. "$HOME/.local/bin/env"
```
Windows PowerShell:
```powershell
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
$env:Path = "$env:USERPROFILE\.local\bin;" + $env:Path
```
**2. OpenSquilla をインストールする**——どのプラットフォームでも同じコマンドです。
```sh
uv tool install --python 3.12 "opensquilla[recommended] @ https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/opensquilla-0.5.0rc3-py3-none-any.whl"
```
これはリリース URL から OpenSquilla wheel をインストールし、続いて `uv` が、選択した extra が宣言する依存関係をダウンロードします。デフォルトの `recommended` extra には、ONNX Runtime、LightGBM、NumPy、tokenizers といった SquillaRouter のランタイム依存関係が含まれるため、これらの wheel がすでにキャッシュされていない限り、初回インストールにはネットワークアクセスが必要です。`uv` は macOS の `libomp` や Windows の Visual C++ Redistributable のようなシステムネイティブのランタイムはインストールしません。ルーターランタイムがネイティブライブラリの読み込みエラーを報告した場合は、[トラブルシューティング](#troubleshooting)を参照してください。
**3. 設定して実行する。**
```sh
opensquilla onboard
opensquilla gateway run
```
> [!NOTE]
> 新規の `uv` インストール直後に `opensquilla` が見つからない場合は、新しいターミナルを開くか、ステップ 1 の PATH 設定の行を再実行してください。
完全にバージョンを固定したインストールには、バージョン付きの wheel URL を使ってください:
`https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/opensquilla-0.5.0rc3-py3-none-any.whl`
<a id="install-from-source"></a>
### ソースからのインストール
この経路は、OpenSquilla をチェックアウトから編集せずに実行する場合に使います。このクローンはインストーラーにとってのパッケージソースにすぎません。インストール後は `opensquilla` コマンドを使ってください——`uv run` は実行しないでください。コードを変更するつもりなら、代わりに[ソースからの開発](#develop-from-source)を選んでください。
1. **LFS アセット込みでクローンする**
```sh
git lfs install
git clone https://github.com/opensquilla/opensquilla.git
cd opensquilla
git lfs pull --include="src/opensquilla/squilla_router/models/**"
```
2. **インストーラーを実行する**
**macOS / Linux**
```sh
bash scripts/install_source.sh
```
**Windows PowerShell**
```powershell
powershell -ExecutionPolicy Bypass -File ./scripts/install_source.ps1
```
このスクリプトは `uv tool install` で `.[recommended]`SquillaRouter + メモリ +
ローカルモデル)を専用のユーザー環境にインストールし、`uv` が使えない場合は
`python -m pip install --user` にフォールバックします。インストール後に
`opensquilla` が `PATH` に乗っていない場合は、新しいターミナルを開いてください。
3. **(任意)高度な extra をインストールする。** ほとんどのチャネル——Feishu、
Telegram、DingTalk、QQ、WeCom、Slack、Discord——は基本インストールで動作します。オプトインの extra は次のとおりです:
- `matrix` — Matrix チャネル(`matrix-nio` を導入)
- `matrix-e2e` — エンドツーエンド暗号化付きの Matrix チャネル(libolm が必要)
- `document-extras` — WeasyPrint による PDF 生成
```sh
OPENSQUILLA_INSTALL_EXTRAS=matrix bash scripts/install_source.sh # macOS / Linux
```
```powershell
powershell -ExecutionPolicy Bypass -File ./scripts/install_source.ps1 -Extras matrix # Windows
```
4. **設定して実行する**——[設定](#configuration)を参照してください。
<details>
<summary>ソースからのインストール——ターミナルでの前提条件とインストーラーのオプション</summary>
**ターミナルから前提条件(Git、Git LFS、uv)をインストールする**
Windows PowerShell:
```powershell
winget install --id Git.Git -e
winget install --id GitHub.GitLFS -e
powershell -ExecutionPolicy Bypass -c "irm https://astral.sh/uv/install.ps1 | iex"
git lfs install
```
macOSHomebrew:
```sh
brew install git git-lfs uv
git lfs install
```
Debian / Ubuntu:
```sh
sudo apt update && sudo apt install -y git git-lfs
curl -LsSf https://astral.sh/uv/install.sh | sh
git lfs install
```
Fedora では `sudo dnf install -y git git-lfs`、Arch では
`sudo pacman -S --needed git git-lfs` を使い、その後に上記の `curl` コマンドで `uv` を
インストールしてください。これらのインストーラーによる PATH の変更は、新しいターミナルセッションで反映されます。
**インストーラーの環境変数と PATH の確認**
```sh
OPENSQUILLA_INSTALL_PROFILE=core bash scripts/install_source.sh # 最小ランタイム、SquillaRouter なし
OPENSQUILLA_INSTALL_DRY_RUN=1 bash scripts/install_source.sh # 計画を表示するだけ
```
シェルが実際にどの `opensquilla` を実行するかは、`command -v opensquilla`macOS/Linux)または `where.exe opensquilla`(Windows)で確認してください。`PATH` に乗っていない場合は `uv tool update-shell` を実行します。ローカルのチェックアウトから再インストールした後は、更新されたパッケージを読み込むためにゲートウェイを再起動してください。
</details>
<a id="develop-from-source"></a>
### ソースからの開発
この経路は、OpenSquilla のソースコードに手を入れているとき——変更を加える、テストを走らせる、このチェックアウトに対して挙動をデバッグする——に使います。通常のインストール経路ではありません。[ソースからのインストール](#install-from-source)とは異なり、この経路には `uv` が必要です。`uv sync` はリポジトリローカルの `.venv` を作成し、`uv run` はこのチェックアウト内のファイルに対してコマンドを実行します。
```sh
uv sync --extra recommended --extra dev
uv run opensquilla --help
```
`recommended` extra は開発時にも SquillaRouter を含みます。`dev` extra はテスト、lint、型チェックのツールをインストールします。追加の extra は、実行する環境と同じ環境にインストールしてください:
```sh
uv sync --extra recommended --extra dev --extra matrix
uv run opensquilla channels status matrix --json
```
このモードでは、[設定](#configuration)に出てくるすべての `opensquilla` コマンドに `uv run` を前置してください。ユーザーローカルの `opensquilla` コマンドで開発用チェックアウトをデバッグしてはいけません——そのコマンドは別の Python 環境で動いています。
### アンインストール
`opensquilla uninstall` で OpenSquilla をアンインストールします。デフォルトではデータを残し、プログラム本体だけを削除します:
```sh
opensquilla uninstall --dry-run # 削除されるものと残されるものをプレビュー
opensquilla uninstall # プログラムを削除し、データは残す
```
データも削除したい場合は、明示的にオプトインしてください:
```sh
opensquilla uninstall --purge-state # セッション、ログ、キャッシュ、スケジューラ、メモリ
opensquilla uninstall --purge-config # config.toml とシークレット(.env
opensquilla uninstall --purge-all # すべて(確認の入力を求められます)
```
まず実行中のゲートウェイがドレインされて停止し、削除は OpenSquilla のホーム内にとどまります。Docker やデスクトップでのインストールの場合は、代わりにガイド付きの削除手順が提示されます。完全なリファレンスは [`docs/cli.md`](docs/cli.md#uninstall) を参照してください。
---
## インストールのプライバシー
OpenSquilla は、インストール数、バージョンの採用状況、ランタイムの互換性を推定するために、匿名のインストールテレメトリを使用します。データはゲートウェイの初回起動時と、OpenSquilla のバージョンごとに 1 回送信されます。アップロードは短いタイムアウトで行われ、起動をブロックすることは決してありません。
送信される内容:
- スキーマバージョン
- ローカルで生成された安定した `install_id` ダイジェスト
- OpenSquilla のバージョン
- イベントタイプ(`install` または `version_seen`
- インストール方法(`pip`、`source`、`docker`、`desktop`、または `unknown`
- オペレーティングシステム、OS のバージョン、CPU アーキテクチャ、Python のメジャー/マイナー
バージョン
- 初回確認時と送信時のタイムスタンプ
- CI/テスト環境のマーカー(`ci_environment`
`install_id` は、利用可能な MAC アドレスから——MAC がない場合はローカル IP アドレスから——導出されるローカルの一方向 SHA-256 ダイジェストで、どちらもない場合はランダムに生成して永続化した値でフォールバックします。生の MAC/IP の値はアップロードされません。
送信されない内容: ユーザー名、ホスト名、パス、API キー、プロバイダ設定、チャット/セッション/メモリ/Agent の内容、ファイル名、ファイルの内容。送信元 IP はトランスポート層で HTTP サーバーから見える場合がありますが、ペイロードには含まれません。
オプトアウトするには:
```sh
OPENSQUILLA_TELEMETRY_DISABLED=true
```
高度なデプロイでは、独自のエンドポイントを使えます:
```sh
OPENSQUILLA_TELEMETRY_ENDPOINT=https://example.com/v1/install
```
---
<a id="configuration"></a>
## 設定
### 初回セットアップ
`opensquilla onboard` は、対話式の初回セットアップウィザードです。これはアクティブな設定ファイルを書き込み、`--api-key-env` を渡すとプロバイダのシークレットを環境変数に残します。ルーターはデフォルトで `recommended`(サポートされているプロバイダでは SquillaRouter)です。単一モデルへの直接ルーティングが必要な場合は `--router disabled` を渡してください。
```sh
opensquilla onboard # 完全な対話式ウィザード
opensquilla onboard --if-needed # 冪等: スクリプトや再インストールでも安全
opensquilla onboard --minimal # プロバイダのみ; チャネルと検索はスキップ
opensquilla onboard status # 書き込まずに各セットアップ項目を確認
```
SSH、CI、または TTY のないあらゆる環境では、非対話形式を使ってください——シークレットは環境に残し、その値ではなく**名前**を渡します:
**Linux / macOS**
```sh
export OPENROUTER_API_KEY="sk-..."
opensquilla onboard --provider openrouter --api-key-env OPENROUTER_API_KEY
```
**Windows PowerShell**
```powershell
$env:OPENROUTER_API_KEY="sk-..."
opensquilla onboard --provider openrouter --api-key-env OPENROUTER_API_KEY
```
OpenRouter はあくまで一例です——サポートされている任意のプロバイダと、その API キー変数に置き換えてください。
後からウィザード全体をやり直さずに、特定の項目だけを設定し直せます(以下の例は、該当する API キーがすでに環境にあることを前提とします):
```sh
opensquilla configure provider --provider openai --model gpt-4o --api-key-env OPENAI_API_KEY
opensquilla configure router --router recommended
opensquilla configure search --search-provider duckduckgo
opensquilla configure search --search-provider exa --api-key-env EXA_API_KEY
opensquilla configure channels
```
各項目: `provider`、`router`、`channels`、`search`、`image-generation`、`memory-embedding`。Web UI は `/control/setup` で同じカタログとステータスモデルを公開しています。Provider と Router が速い経路で、Channels、Search、Image generation、Memory embedding は Capability Center に置かれ、後から設定できます。チャネルが空のままでも、セットアップの失敗ではなくオプトアウトとして扱われます。
**設定の読み込み順:** `OPENSQUILLA_GATEWAY_CONFIG_PATH` →
`./opensquilla.toml` → `~/.opensquilla/config.toml` → 組み込みのデフォルト。個々のシークレットについては、環境変数の値が常にファイルの値より優先されます。
### OpenClaw や Hermes Agent からの移行
`~/.openclaw` や `~/.hermes` の下にすでに状態がある場合は、まずドライランを実行して移行レポートを確認し、その後で明示的に適用してください:
```sh
opensquilla migrate openclaw --json
opensquilla migrate openclaw --apply
opensquilla migrate hermes --json
opensquilla migrate hermes --apply
```
`opensquilla migrate --source openclaw,hermes --apply` を使うと、両方のデフォルトホームを取り込めます。`--migrate-secrets` は、ドライランのレポートを確認してから初めて追加してください。カスタムパスと競合の処理については [`MIGRATION.md`](MIGRATION.md) を参照してください。
### 実行
```sh
opensquilla gateway run # フォアグラウンド、127.0.0.1:18791
opensquilla gateway start --json # バックグラウンド + ヘルスチェック待ち
opensquilla chat # 対話式 REPL
opensquilla agent -m "あなたのプロンプト" # 単発実行、自動化向き
```
<http://127.0.0.1:18791/control/> で Web UI を開きます。**Health** ビューには、OpenSquilla が準備できているか、準備できていない項目は何か、そして次の復旧手順が表示されます。CLI からは次を実行します:
```sh
opensquilla doctor
opensquilla doctor --json
opensquilla doctor --config ./opensquilla.toml --json
```
`/health` と `/healthz` は、プロセス確認のための軽量な稼働確認エンドポイントです。`opensquilla doctor` と Web UI の Health ビューは、プロバイダ設定、メモリ、ログ、検索、チャネル、サンドボックスの状態、ルーター、画像生成、復旧ガイダンスについての準備状況を見る場所です。フォアグラウンドのゲートウェイは `Ctrl+C` で停止します。
その他のコマンドグループには `sessions`、`skills`、`memory`、`migrate`、`cron`、`channels`、`providers`、`models`、`cost` があります。詳細は `opensquilla --help` または `opensquilla <グループ> --help` を実行してください。
<details>
<summary>高度な設定——チャネルの検証、パブリックネットワークへのバインド、Docker</summary>
**メッセージングチャネルを接続して検証する**
チャネルの保存は設定の変更であって、実行時の接続性の証明ではありません。チャネルを編集した後はゲートウェイを再起動し、それから実際のチャネルを検証してください:
```sh
opensquilla gateway restart
opensquilla channels status <name> --json
```
ステータスのペイロードが `enabled=true`、`configured=true`、`connected=true` を報告したときに限り、チャネルが接続済みだと見なしてください。Feishu はデフォルトで websocket モード、Telegram はポーリング、Slack は Socket Mode を使えます——これらのモードはいずれもパブリック URL を必要としません。Feishu の webhook モード、Telegram の webhook モード、Slack の webhook モード、そして WeCom は、パブリックでプロバイダから到達可能な URL を必要とします。
**パブリックネットワークへのバインド**
別のマシンから Web UI に到達するには、ゲートウェイをすべてのインターフェースにバインドし、ホストのパブリック IP を使ってください:
```sh
opensquilla gateway run --listen 0.0.0.0 --port 18791
```
パブリックアクセスにはさらに、そのポートへの受信 TCP をホストのファイアウォールやクラウドのセキュリティグループが許可している必要があります。`[auth] mode = "none"` のままゲートウェイを公開してはいけません——`0.0.0.0` にバインドする前にトークン認証を設定してください。
**Docker**
ビルド済みのマルチアーキテクチャイメージ(`amd64`/`arm64`)は、リリースタグごとに `ghcr.io/opensquilla/opensquilla` に公開されます。コンテナ配備の完全なガイド(ホームサーバー/NAS、トークン認証つきの LAN 公開、アップグレード)は [`docs/docker.md`](docs/docker.md) を参照してください:
```sh
OPENSQUILLA_GATEWAY_IMAGE=ghcr.io/opensquilla/opensquilla:latest docker compose up -d
```
`OPENSQUILLA_GATEWAY_IMAGE` を設定しない場合、compose 経路は自分でビルドした `opensquilla:local` イメージを実行します。Git LFS のルーターアセットを取得済みのソースチェックアウトからビルドしてください(クローンと `git lfs pull` については[ソースからのインストール](#install-from-source)を参照):
```sh
docker build -t opensquilla:local .
```
その後 `./start.sh`Windows では `start.ps1`)が `docker compose up -d` を実行し、ゲートウェイのログを追尾します。Docker が省くのはホストの Python ツールチェーンであって、ローカルイメージのビルドではありません。
</details>
プロバイダの階層、サンドボックスのチューニング、画像生成、並行処理の設定は `opensquilla.toml.example` にあります。
---
## 0.4.1 の新着情報
OpenSquilla 0.4.1 は、デスクトップと Control UI のラインに向けたメンテナンスリリースです:
- **デスクトップの信頼性** - パッケージ版ゲートウェイのチェックが Coding モード、
`code-task`、SquillaRouter の起動までカバーするようになり、デスクトップのウィンドウ/成果物の扱いがより安定しました。
- **6 言語のクライアント対応** - Control UI とデスクトップクライアントが、初回描画と設定の画面全体で
英語、簡体字中国語、日本語、フランス語、ドイツ語、スペイン語に対応します。
- **Coding モードとルーターのパッケージング** - ルーターアセットが欠落しているか、まだ Git LFS の
ポインタのままである場合、デスクトップビルドは早期に失敗し、機能が損なわれたリリースパッケージの生成を防ぎます。
- **テレメトリと Windows の磨き込み** - インストールテレメトリは CI とテスト環境をスキップし、Windows のデスクトップアセットは OpenSquilla のロゴを使います。
- **メインライン運営** - 通常のプルリクエストとリリース統合は `main` を中心にそろえられ、メンテナーブランチはリリース、ホットフィックス、ステージング、統合、サンドボックスの作業用に予約されています。
完全なノート: [`CHANGELOG.md`](CHANGELOG.md) ·
[`docs/releases/0.4.1.md`](docs/releases/0.4.1.md)。
## 0.2.1 の新着情報
OpenSquilla 0.2.1 は、リリースパッケージの起動と、長時間稼働する Agent の信頼性に焦点を当てたメンテナンスリリースです:
- **Windows ポータブル版の起動** —— ポータブル版のランチャーが、同梱された ONNX ルーターに必要な
Visual C++ ランタイムをよりうまく検出してブートストラップします。
- **長時間稼働する Agent のターン** —— ツールを多用する WebUI セッションが、過大なツール結果、不正な形式のツール呼び出し、成果物の引き渡し、品質の落ちた最終応答から、よりきれいに回復します。
- **よりすっきりした WebUI 出力** —— 生成された成果物のマーカーは通常のチャット再生から除かれ、配信されたファイルは引き続き表示されます。
- **メモリ想起のスコアリング** —— ローカルおよび OpenAI 互換の埋め込みベクトルは、セマンティック検索の前に正規化されます。ベクトルのスコアが低いときでも、強いキーワードの一致は依然として有効です。
完全なノート: [`CHANGELOG.md`](CHANGELOG.md) ·
[リリースノート](https://opensquilla.ai/news/)。
## 0.2.0 の新着情報
このリリースは、移行、CLI チャット、チャネル、スケジューリング、長時間稼働するツール作業の各方面で OpenSquilla を拡張します:
- **既存の Agent ホームからの移行経路** —— `opensquilla migrate` は、既存の OpenClaw/Hermes ホームからのインポートをプレビューして適用します。メモリ、ペルソナファイル、スキル、MCP/チャネル設定、競合処理、移行レポートを含みます。
- **実用的なチャット CLI** —— `opensquilla chat` は、安定したターミナル UI、ストリーミング出力、入力のキューイング、スラッシュモードの発見、ツール/ステータスのストリップ、そしてより決定的なライブプロンプトの挙動を備えています。
- **画面横断的な cron 自動化** —— cron ジョブは、構造化されたスケジュール、タイムゾーンを考慮した exact/every/cron の実行、チャネルや webhook への配信、失敗時の送り先、手動実行、そして WebUI/CLI/RPC の同等性をカバーするようになりました。
- **より良い Feishu と Discord のチャネル** —— チャネルアダプターは、より明確な機能メタデータ、より安全な DM/グループの扱い、ネイティブのファイルと成果物の経路、改善された添付/スレッドの挙動を公開する一方、特権操作はスコープが限定されたままです。
- **より頑丈な長時間稼働ターン** —— 失敗したターンはプロバイダの再生から除かれ、不正な形式のツール呼び出しはより安全に扱われ、承認ゲート付きのリトライはオペレーターの判断を待ちます。
- **より賢いコンテキストとツールの予算管理** —— プロバイダ予算に基づくコンパクション、プロンプトキャッシュの保持、ツール結果の上限設定、副作用を考慮した並行処理によって、大規模でツールを多用するセッションの予測可能性が高まります。
- **Web UI とリリースの磨き込み** —— 0.2.0 では、新しさ順の並べ替え、テーブルレイアウト、モバイルのコントロール、重複通知、セットアップフォーム、リリース URL、インストール経路を引き締めました。
完全なノート: [`CHANGELOG.md`](CHANGELOG.md) ·
[リリースノート](https://opensquilla.ai/news/)。
---
## 主な機能
| 機能 | 内容 |
| --- | --- |
| **Token を効率化するルーティング** | `SquillaRouter`——`recommended` extra に含まれるローカルの LightGBM + ONNX 分類器——が、各ターンを長さ、言語、コード、キーワード、セマンティック埋め込みで採点し、4 つの階層(C0〜C3。旧来の T0〜T3 という名前はエイリアス)にわたって、対応できる最も安価なモデルへ振り分けます。分類はデバイス上で実行されます。その判断を下すために、あなたのプロンプトがマシンを離れることはありません。 |
| **適応的な推論とプロンプト** | OpenSquilla は、ルーターが複雑だと採点したターンに対してのみ拡張推論を要求し、システムプロンプトもタスクの複雑さに応じて伸縮します——ささいなターンには軽量に、複雑なターンには完全な指示を。 |
| **20 以上の LLM プロバイダ** | プロバイダレジストリは 20 以上の LLM バックエンドを対象とします——TokenRhythm、OpenRouter、OpenAI、Anthropic、Ollama、DeepSeek、Gemini、DashScope/Qwen、Moonshot、Mistral、Groq、Zhipu、SiliconFlow、vLLM、LM Studio など。プライマリ + フォールバックの選択にも対応し、初回オンボーディングでは検証済みのサブセットが提示されます。 |
| **オンデマンドなスキルと MCP** | 15 個の同梱スキル(coding、GitHub、cron、pptx/docx/xlsx/pdf、要約、tmux、天気など)は、タスクが必要とするときだけ読み込まれます。OpenSquilla は MCP クライアントであり、MCP サーバーとしても動作できます——`opensquilla mcp-server run` には `mcp` extra が必要です(`opensquilla[recommended,mcp]` をインストール)。スキルは CLI から作成、インストール、公開できます。 |
| **永続的なローカルメモリ** | 厳選された `MEMORY.md` に、日付付きの Markdown ノートを加えたもので、SQLite の全文キーワード検索と `sqlite-vec` のセマンティック想起で検索します。埋め込みは同梱の ONNX でデバイス上で実行されますが、OpenAI/Ollama に切り替えることもできます。任意の指数減衰と、オプトインの「dream」整合(コンソリデーション)も利用できます。 |
| **階層化されたセキュリティサンドボックス** | 権限マトリクス上の 3 つのポリシー階層(Standard / Strict / Locked)。Linux では Bubblewrap がコード実行を隔離します。macOS の Seatbelt バックエンドは現在プロファイルを生成するだけで(実行は未対応)、Windows にはまだサンドボックスバックエンドがありません。拒否台帳(denial ledger)は、拒否が繰り返されると自律実行を自動的に一時停止し、拒否された出力は破棄されます。スキルのメタデータとツール結果は、プロンプトインジェクション対策として XML エスケープされます。 |
| **組み込みツール** | ファイルの読み取り/書き込み/編集、シェルとバックグラウンドプロセス、git、SSRF ガードの背後にあるウェブ検索(DuckDuckGo、Bocha、Brave、Tavily、Exa)と取得、スプレッドシート/PPTX/PDF の作成、画像生成、テキスト読み上げ。 |
| **統一ゲートウェイ** | `127.0.0.1:18791` で動作する Starlette ASGI サーバーで、WebSocket RPC と組み込みのコントロールコンソール(`/control/`)を備えます。Web UI、CLI、そして Terminal、WebSocket、Slack、Telegram、Discord、Feishu、DingTalk、WeCom、Matrix、QQ のチャネルが、すべて 1 つの `TurnRunner` を共有します。 |
| **耐久性のあるセッション、サブエージェント、スケジューリング** | SQLite を基盤としたセッション、トランスクリプト、再生のストレージに、Agent ごとのワークスペースを備えます。Agent は深さの制限されたサブエージェントを生成でき、ツリー内蔵の cron パーサーを持つ `SchedulerEngine` が `opensquilla cron` 経由で定期ジョブを実行します。 |
| **オペレーター制御** | ヒューマンインザループの承認により、機微なツール呼び出しを判断のために一時停止できます。ターンごと・セッションごとの Token とコストの集計(`opensquilla cost`)と診断は、CLI と Web UI から利用できます。 |
MetaSkill ドキュメント: [`docs/features/meta-skills.md`](docs/features/meta-skills.md)、
[`docs/features/meta-skill-user-guide.md`](docs/features/meta-skill-user-guide.md)、
[`docs/authoring/meta-skills.md`](docs/authoring/meta-skills.md)。
---
## ベンチマーク結果
PinchBench 1.2.1 の 25 タスクにわたる平均結果:
| Agent | ベースモデル | 平均スコア | 総入力 token | 総出力 token | 総コスト |
| --- | ---: | ---: | ---: | ---: | ---: |
| OpenSquilla | モデルルーター(Opus4.7、GLM5.1、DS4 Flash | 0.9251 | 1,721,328 | 61,475 | $0.688 |
| OpenClaw | Claude Opus 4.7 | 0.9255 | 3,066,243 | 50,890 | $6.233 |
スコアは 25 タスクの平均で、token 数とコストは実行全体の合計です。
---
<a id="troubleshooting"></a>
## トラブルシューティング
<details>
<summary>macOS: <code>Library not loaded: @rpath/libomp.dylib</code></summary>
起動時に `lightgbm/lib/lib_lightgbm.dylib` から `Library not loaded: @rpath/libomp.dylib` が記録された場合、OpenSquilla は単一モデルへの直接ルーティングで動作を続けますが、同梱の `SquillaRouter` ランタイムは、macOS の OpenMP ランタイムがインストールされるまで非アクティブのままです。
デスクトップアプリは、必要なネイティブランタイムを同梱しています。ターミナルからのクイックインストール、またはシェルからのソースインストールを使った場合は、Homebrew で `libomp` をインストールしてからゲートウェイを再起動してください:
```sh
brew install libomp
opensquilla gateway restart
```
</details>
<details>
<summary>Windows: <code>DLL load failed</code> / Visual C++ ランタイム</summary>
起動時に `DLL load failed while importing onnxruntime_pybind11_state` が記録された場合、OpenSquilla は単一モデルへの直接ルーティングで動作を続けますが、同梱の `SquillaRouter` ランタイムは、Visual Studio 2015〜2022x64)向けの Visual C++ Redistributable がインストールされるまで非アクティブのままです。
ソースからの PowerShell インストーラーは、`winget` 経由でこの redistributable のインストールを試みます。ターミナルからのクイックインストールを使った場合、または `winget` が利用できない場合は、手動でインストールしてから PowerShell を再起動してください: <https://aka.ms/vs/17/release/vc_redist.x64.exe>。その後、推奨のルーターを復元します:
```powershell
opensquilla onboard --provider openrouter --api-key-env OPENROUTER_API_KEY --router recommended
opensquilla gateway restart
```
</details>
---
## クレジット
OpenSquilla は [OpenClaw](https://github.com/openclaw/openclaw) に着想を得ています。同梱の第三者コンテンツの出典は [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) に明記されています。
コミュニティのコントリビューターは [`CONTRIBUTORS.md`](CONTRIBUTORS.md) に記載されており、squash マージや再生された作業についての、リリースごとの帰属の注記も含まれています。
---
## コントリビューター
OpenSquilla に貢献してくださったすべての方に感謝します。
<p align="center">
<a href="https://github.com/opensquilla/opensquilla/graphs/contributors">
<img src="https://contrib.rocks/image?repo=opensquilla/opensquilla&max=100&columns=10" alt="OpenSquilla contributors" />
</a>
</p>
---
## 貢献する
あらゆる種類の貢献を歓迎します——バグ報告、機能のアイデア、ドキュメント、新しいプロバイダやチャネルのアダプター、スキル、そしてコアランタイムの開発です。[`CONTRIBUTING.md`](CONTRIBUTING.md) を読んだうえで、[GitHub](https://github.com/opensquilla/opensquilla) で issue やプルリクエストを開いてください。
[行動規範](CODE_OF_CONDUCT.md) · [セキュリティ](SECURITY.md) ·
[サポート](SUPPORT.md) · [ライセンス](LICENSE)Apache-2.0
+812
View File
@@ -0,0 +1,812 @@
# OpenSquilla — Token-Efficient AI Agent
<p align="center">
<img src="assets/opensquilla-long-logo.png" alt="OpenSquilla logo" width="500">
</p>
<p align="center">
<b>Same budget, more capability, better results.</b><br>
A microkernel AI agent for your CLI, Web UI, and chat channels.
</p>
<p align="center">
<a href="https://github.com/opensquilla/opensquilla/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/opensquilla/opensquilla/ci.yml?style=for-the-badge" alt="CI"></a>
<a href="https://opensquilla.ai/"><img src="https://img.shields.io/badge/website-opensquilla.ai-blue?style=for-the-badge" alt="Website"></a>
<a href="https://github.com/opensquilla/opensquilla/releases"><img src="https://img.shields.io/github/v/release/opensquilla/opensquilla?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
<a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?style=for-the-badge" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue?style=for-the-badge" alt="Apache 2.0 License"></a>
</p>
<p align="center">
<b>English</b> · <a href="README.zh-Hans.md">中文</a> · <a href="README.ja.md">日本語</a> · <a href="README.fr.md">Français</a> · <a href="README.de.md">Deutsch</a> · <a href="README.es.md">Español</a>
</p>
---
## News
- 📢 **2026-07-03** — Our technical report **[Agentic Routing: The Harness-Native Data Flywheel](docs/releases/agentic_routing_v0.pdf)** (preview) is out, released alongside OpenSquilla **0.5.0 Preview 1**. It details how the harness-native router turns everyday agent traffic into a self-improving data flywheel.
---
## Overview
OpenSquilla is a token-efficient, microkernel AI agent. A local model
router sends each turn to the cheapest model that can handle it, while
persistent memory, a layered sandbox, built-in web search, and
on-device embeddings round out a single shared turn loop.
Every entry point — Web UI, CLI, and chat channels — runs through that
same loop, so tool dispatch, retries, and decision logging behave
identically everywhere. A pluggable provider layer speaks to
TokenRhythm, OpenRouter, OpenAI, Anthropic, Ollama, DeepSeek, Gemini,
Qwen/DashScope, and 20+ other LLM providers with no change to your code or config
schema.
OpenSquilla 0.5.0 Preview 3 is the current preview release.
For task-oriented product documentation, start with the
[OpenSquilla Product Guide](README.product.md) or the
[documentation index](docs/README.md).
---
## Installation
OpenSquilla runs on Windows, macOS, and Linux. Pick the path that
matches your use case.
Desktop installers and Quick terminal install give you a prebuilt **release**
no Git required. The other two — Install from source and
Develop from source — build **from a Git checkout** (`git clone` + Git LFS).
Release install commands use published GitHub release assets. Python wheel installs use versioned wheel filenames because installers validate the version
embedded in the wheel filename.
For 0.5.0 Preview 3 desktop use, prefer the packaged desktop installers from
the GitHub Release: `OpenSquilla-0.5.0-rc3-mac-arm64.dmg` on macOS and
`OpenSquilla-0.5.0-rc3-win-x64.exe` on Windows.
| Path | Audience | When to use |
| --- | --- | --- |
| [Desktop installers](#desktop-installers) **(recommended desktop)** | macOS and Windows users | Packaged desktop app |
| [Quick terminal install](#quick-terminal-install) **(recommended)** | End users on any OS | Release wheel from a terminal |
| [Install from source](#install-from-source) | Users tracking `main` | Run from a checkout, not edit it |
| [Develop from source](#develop-from-source) | Contributors | Edit, test, or debug the source |
### Prerequisites
| Requirement | Quick terminal install | Install from source | Develop from source |
| --- | :---: | :---: | :---: |
| Python 3.12+ | via `uv` | via `uv` or system | via `uv` |
| Git + Git LFS | — | required | required |
| `uv` | installed if missing | recommended | required |
The default `recommended` profile installs **SquillaRouter**
OpenSquilla's on-device model router — and its model assets;
`OPENSQUILLA_INSTALL_PROFILE=core` omits those dependencies. The
separate `--router disabled` onboarding flag keeps the dependencies
installed but turns the router off at runtime.
On Windows, SquillaRouter's bundled ONNX runtime also needs the Visual
C++ runtime. The from-source PowerShell installer installs it automatically via
`winget`; the **Quick terminal install** (`uv tool install`) path does not — if
startup logs a `DLL load failed` error, install it manually (see
[Troubleshooting](#troubleshooting)). OpenSquilla keeps running with direct
single-model routing until it is installed.
On macOS terminal installs, SquillaRouter's LightGBM runtime may also
need the system OpenMP library. The desktop app bundles the
runtime it needs, but **Quick terminal install** does not install
Homebrew/system libraries. If startup logs `Library not loaded:
@rpath/libomp.dylib`, run `brew install libomp`, then restart the
gateway. OpenSquilla keeps running with direct single-model routing
until it is installed.
Install links: [Git](https://git-scm.com/downloads) ·
[Git LFS](https://git-lfs.com/) ·
[uv](https://docs.astral.sh/uv/getting-started/installation/).
### Desktop installers
The 0.5.0 Preview 3 desktop installers package the Vue control console and
gateway runtime in an Electron shell.
- macOS Apple Silicon: <https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/OpenSquilla-0.5.0-rc3-mac-arm64.dmg>
- Windows x64: <https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/OpenSquilla-0.5.0-rc3-win-x64.exe>
Quit any running OpenSquilla desktop app before upgrading. On macOS, drag the
app from the DMG into Applications for installation or updates, eject the DMG,
then open the Applications copy. Existing `~/.opensquilla/config.toml` and
session data are reused.
When upgrading the Windows Desktop from RC3 to RC4 or later, run the new
installer directly over the existing installation. Do **not** uninstall RC3
first: its uninstaller may remove Desktop user data. Back up
`%APPDATA%\OpenSquilla` before upgrading. RC4 and later installers preserve
profile data during a normal uninstall.
Code signing policy: [`docs/code-signing-policy.md`](docs/code-signing-policy.md).
> [!NOTE]
> Windows builds are currently unsigned. If SmartScreen appears, choose
> **More info** → **Run anyway**. If Smart App Control or enterprise policy
> blocks the unsigned app, use [Quick terminal install](#quick-terminal-install)
> instead.
### Quick terminal install
The recommended path on Windows, macOS, and Linux. `uv` installs
OpenSquilla into its own isolated environment and manages its own
Python — no system Python required. This path installs published
releases only; for `main`, development branches, or local checkouts
use [Install from source](#install-from-source).
**1. Install `uv`** — skip if `uv --version` already works.
Linux / macOS:
```sh
curl -LsSf https://astral.sh/uv/install.sh | sh
. "$HOME/.local/bin/env"
```
Windows PowerShell:
```powershell
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
$env:Path = "$env:USERPROFILE\.local\bin;" + $env:Path
```
**2. Install OpenSquilla** — the same command on every platform.
```sh
uv tool install --python 3.12 "opensquilla[recommended] @ https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/opensquilla-0.5.0rc3-py3-none-any.whl"
```
This installs the OpenSquilla wheel from the release URL, then lets
`uv` download the dependencies declared by the selected extras. The
default `recommended` extra includes SquillaRouter runtime dependencies
such as ONNX Runtime, LightGBM, NumPy, and tokenizers, so a first install
needs network access unless those wheels are already cached. `uv` does
not install system native runtimes such as macOS `libomp` or the Windows
Visual C++ Redistributable; see [Troubleshooting](#troubleshooting) if
the router runtime reports a native-library load error.
**3. Configure and run.**
```sh
opensquilla onboard
opensquilla gateway run
```
> [!NOTE]
> If `opensquilla` is not found right after a fresh `uv` install, open
> a new terminal, or re-run the PATH line from step 1.
For a fully pinned install, use the versioned wheel URL:
`https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/opensquilla-0.5.0rc3-py3-none-any.whl`.
### Install from source
Use this path to run OpenSquilla from a checkout without editing it.
The clone is only the package source for the installer; after install,
use the `opensquilla` command — do not run `uv run`. Choose
[Develop from source](#develop-from-source) instead if you intend to
modify the code.
1. **Clone with LFS assets**
```sh
git lfs install
git clone https://github.com/opensquilla/opensquilla.git
cd opensquilla
git lfs pull --include="src/opensquilla/squilla_router/models/**"
```
2. **Run the installer**
**macOS / Linux**
```sh
bash scripts/install_source.sh
```
**Windows PowerShell**
```powershell
powershell -ExecutionPolicy Bypass -File ./scripts/install_source.ps1
```
The script installs `.[recommended]` (SquillaRouter + memory + local
models) into a dedicated user environment via `uv tool install`,
falling back to `python -m pip install --user` when `uv` is
unavailable. If `opensquilla` is not on `PATH` after install (common
on a fresh host where `~/.local/bin` is not yet on `PATH`), run
`uv tool update-shell` and open a new terminal; see
[Troubleshooting](#troubleshooting) for details.
3. **(optional) Install advanced extras.** Most channels — Feishu,
Telegram, DingTalk, QQ, WeCom, Slack, and Discord — work from the
base install. The opt-in extras are:
- `matrix` — Matrix channel (pulls in `matrix-nio`)
- `matrix-e2e` — Matrix channel with end-to-end encryption (requires
libolm)
- `document-extras` — PDF generation via WeasyPrint
```sh
OPENSQUILLA_INSTALL_EXTRAS=matrix bash scripts/install_source.sh # macOS / Linux
```
```powershell
powershell -ExecutionPolicy Bypass -File ./scripts/install_source.ps1 -Extras matrix # Windows
```
4. **Configure and run** — see [Configuration](#configuration).
<details>
<summary>Install from source — terminal prerequisites and installer options</summary>
**Install prerequisites (Git, Git LFS, uv) from a terminal**
Windows PowerShell:
```powershell
winget install --id Git.Git -e
winget install --id GitHub.GitLFS -e
powershell -ExecutionPolicy Bypass -c "irm https://astral.sh/uv/install.ps1 | iex"
git lfs install
```
macOS (Homebrew):
```sh
brew install git git-lfs uv
git lfs install
```
Debian / Ubuntu:
```sh
sudo apt update && sudo apt install -y git git-lfs
curl -LsSf https://astral.sh/uv/install.sh | sh
git lfs install
```
On Fedora use `sudo dnf install -y git git-lfs`; on Arch use
`sudo pacman -S --needed git git-lfs`; then install `uv` with the
`curl` command above. PATH changes from these installers apply to new
terminal sessions.
**Installer environment variables and PATH checks**
```sh
OPENSQUILLA_INSTALL_PROFILE=core bash scripts/install_source.sh # minimal runtime, no SquillaRouter
OPENSQUILLA_INSTALL_DRY_RUN=1 bash scripts/install_source.sh # print the plan only
```
Verify which `opensquilla` your shell runs with `command -v
opensquilla` (macOS/Linux) or `where.exe opensquilla` (Windows). If it
is not on `PATH`, run `uv tool update-shell`. After reinstalling from a
local checkout, restart the gateway so it loads the updated package.
</details>
### Develop from source
Use this path when you are working on OpenSquilla's source code:
making changes, running tests, or debugging behavior against this
checkout. It is not the normal install path. Unlike
[Install from source](#install-from-source), this path requires `uv`:
`uv sync` creates a repository-local `.venv`, and `uv run` executes
commands against the files in this checkout.
```sh
uv sync --extra recommended --extra dev
uv run opensquilla --help
```
The `recommended` extra includes SquillaRouter for development too;
the `dev` extra installs the test, lint, and typecheck tools. Install
additional extras into the same environment you run:
```sh
uv sync --extra recommended --extra dev --extra matrix
uv run opensquilla channels status matrix --json
```
In this mode, prefix every `opensquilla` command in
[Configuration](#configuration) with `uv run`. Do not debug a
development checkout through a user-local `opensquilla` command — that
command runs in a different Python environment.
### Uninstall
Remove OpenSquilla with `opensquilla uninstall`. It keeps your data by default
and removes only the program:
```sh
opensquilla uninstall --dry-run # preview what would be removed and kept
opensquilla uninstall # remove the program, keep your data
```
To delete data too, opt in explicitly:
```sh
opensquilla uninstall --purge-state # sessions, logs, cache, scheduler, memory
opensquilla uninstall --purge-config # config.toml and secrets (.env)
opensquilla uninstall --purge-all # everything (asks you to type a confirmation)
```
The running gateway is drained and stopped first, deletion stays inside the
OpenSquilla home, and Docker/desktop installs get guided removal steps instead.
Desktop or OS app removal remains platform-specific; the CLI guidance does not
remove a desktop app bundle. See [`docs/cli.md`](docs/cli.md#uninstall) for the
full reference.
---
## Installation Privacy
OpenSquilla uses anonymous installation telemetry to estimate install counts,
version adoption, and runtime compatibility. Data is sent on first gateway
startup and once per OpenSquilla version. OpenSquilla may also make passive
update checks, including desktop startup auto-update checks. Uploads use a
short timeout and never block startup.
See [`PRIVACY.md`](PRIVACY.md) for the full privacy policy covering local data,
provider requests, network observability, logs, release downloads, and deletion.
What is sent:
- schema version
- locally generated stable `install_id` digest
- OpenSquilla version
- event type (`install` or `version_seen`)
- install method (`pip`, `source`, `docker`, `desktop`, or `unknown`)
- operating system, OS version, CPU architecture, and Python major/minor
version
- first-seen and sent timestamps
- CI/test-environment marker (`ci_environment`)
The `install_id` is a local one-way SHA-256 digest derived from usable MAC
addresses, then local IP addresses when no MAC is available, with a random
persisted fallback. Raw MAC/IP values are not uploaded.
What is not sent: usernames, hostnames, paths, API keys, provider config,
chat/session/memory/agent content, file names, or file contents. Source IP may
be visible to HTTP servers at the transport layer, but is not part of the
payload.
To disable non-user-initiated network observability before startup:
```sh
OPENSQUILLA_PRIVACY_DISABLE_NETWORK_OBSERVABILITY=true
```
or set:
```toml
[privacy]
disable_network_observability = true
```
That unified switch covers automatic install telemetry, passive update checks,
and desktop startup auto-update checks. Manual user-initiated actions may still
contact network services after user intent, including manual release, download,
or update checks and configured providers, search, or channels.
Legacy opt-out environment variables remain honored:
```sh
OPENSQUILLA_TELEMETRY_DISABLED=true
OPENSQUILLA_UPDATE_CHECK_DISABLED=true
```
Advanced deployments can use their own endpoint:
```sh
OPENSQUILLA_TELEMETRY_ENDPOINT=https://example.com/v1/install
```
---
## Configuration
### First-run setup
`opensquilla onboard` is the interactive first-run wizard. It writes
the active config file and keeps provider secrets in environment
variables when you pass `--api-key-env`. The router defaults to
`recommended` (SquillaRouter on supported providers); pass
`--router disabled` for direct single-model routing.
```sh
opensquilla onboard # full interactive wizard
opensquilla onboard --if-needed # idempotent: safe for scripts and re-installs
opensquilla onboard --minimal # provider only; skip channels and search
opensquilla onboard status # inspect every setup section without writing
```
In SSH, CI, or any environment without a TTY, use the non-interactive
form — keep the secret in the environment and pass its **name**, not
its value:
**Linux / macOS**
```sh
export OPENROUTER_API_KEY="sk-..."
opensquilla onboard --provider openrouter --api-key-env OPENROUTER_API_KEY
```
**Windows PowerShell**
```powershell
$env:OPENROUTER_API_KEY="sk-..."
opensquilla onboard --provider openrouter --api-key-env OPENROUTER_API_KEY
```
OpenRouter is only an example — substitute any supported provider and
its API-key variable.
Re-configure one section later without redoing the whole wizard (these
examples assume the relevant API key is already in the environment):
```sh
opensquilla configure provider --provider openai --model gpt-4o --api-key-env OPENAI_API_KEY
opensquilla configure router --router recommended
opensquilla configure search --search-provider duckduckgo
opensquilla configure search --search-provider exa --api-key-env EXA_API_KEY
opensquilla configure channels
```
Sections: `provider`, `router`, `channels`, `search`,
`image-generation`, `memory-embedding`. The Web UI exposes the same
catalog and status model at `/control/setup`: Provider and Router are
the fast path, while Channels, Search, Image generation, and Memory
embedding sit in the Capability Center and can be configured later.
Empty channels are treated as an opt-out, not a failed setup.
**Config load order:** `OPENSQUILLA_GATEWAY_CONFIG_PATH` →
`./opensquilla.toml` → `~/.opensquilla/config.toml` → built-in
defaults. Environment values for individual secrets always win over
file values.
### Migrate from OpenClaw or Hermes Agent
If you already have state under `~/.openclaw` or `~/.hermes`, run a
dry run first to inspect the migration report, then apply it explicitly:
```sh
opensquilla migrate openclaw --json
opensquilla migrate openclaw --apply
opensquilla migrate hermes --json
opensquilla migrate hermes --apply
```
Use `opensquilla migrate --source openclaw,hermes --apply` to import
both default homes. Add `--migrate-secrets` only after reviewing the dry-run
report. See [`MIGRATION.md`](MIGRATION.md) for custom paths and conflict
handling.
### Run
```sh
opensquilla gateway run # foreground, 127.0.0.1:18791
opensquilla gateway start --json # background + health wait
opensquilla chat # interactive REPL
opensquilla agent -m "your prompt" # one-shot, automation-friendly
```
> **Preview — the OpenTUI terminal UI.** `opensquilla chat` runs the stable,
> Python-native chat by default. A richer OpenTUI frontend (themes, one-card
> turns, a live router HUD, drag-select copy) is an opt-in preview that runs
> **only from a [Develop from source](#develop-from-source) checkout**: the host
> is loaded from the OpenTUI package next to the running code, and that package
> (plus its [Bun](https://bun.sh) dependencies) is not shipped in the release
> wheel or the `Install from source` install. From the checkout, install the Bun
> deps once, then launch with `uv run` so it runs against that same tree:
>
> ```sh
> bun install --frozen-lockfile --cwd=src/opensquilla/cli/tui/opentui/package
> OPENSQUILLA_TUI_BACKEND=opentui uv run opensquilla chat
> ```
>
> Leave `OPENSQUILLA_TUI_BACKEND` unset for the stable chat. See
> [docs/tui.md](docs/tui.md) for terminal chat usage and
> [docs/features/tui-frontend.md](docs/features/tui-frontend.md) for backend
> details.
Open the Web UI at <http://127.0.0.1:18791/control/>. The **Health**
view shows whether OpenSquilla is ready, what is not ready, and the
next recovery steps. From the CLI, run:
```sh
opensquilla doctor
opensquilla doctor --json
opensquilla doctor --config ./opensquilla.toml --json
```
`/health` and `/healthz` are lightweight liveness endpoints for process
checks. `opensquilla doctor` and the Web UI Health view are the readiness
surfaces for provider config, memory, logs, search, channels, sandbox
posture, router, image generation, and recovery guidance. Press
`Ctrl+C` to stop a foreground gateway.
Other command groups include `sessions`, `skills`, `memory`, `migrate`,
`cron`, `channels`, `providers`, `models`, and `cost`. Run
`opensquilla --help` or `opensquilla <group> --help` for details.
<details>
<summary>Advanced configuration — verify a channel, public network binding, Docker</summary>
**Connect and verify a messaging channel**
Channel saves are config changes, not runtime-connectivity proof.
Restart the gateway after channel edits, then verify the live channel:
```sh
opensquilla gateway restart
opensquilla channels status <name> --json
```
Treat a channel as connected only when the status payload reports
`enabled=true`, `configured=true`, and `connected=true`. Feishu
defaults to websocket mode, Telegram to polling, and Slack can use
Socket Mode — none of those modes needs a public URL. Feishu webhook
mode, Telegram webhook mode, Slack webhook mode, and WeCom require a
public, provider-reachable URL.
**Public network binding**
To reach the Web UI from another machine, bind the gateway to all
interfaces and use the host's public IP:
```sh
opensquilla gateway run --listen 0.0.0.0 --port 18791
```
Public access also requires the host firewall or cloud security group
to allow inbound TCP on that port. Do not expose the gateway with
`[auth] mode = "none"` — configure token auth before binding to
`0.0.0.0`.
**Docker**
Prebuilt multi-arch images (`amd64`/`arm64`) are published to
`ghcr.io/opensquilla/opensquilla` on release tags. Preview 3 is published as
both `v0.5.0rc3` and the moving `latest` tag —
[`docs/docker.md`](docs/docker.md) is the full container guide
(home servers and NAS, LAN exposure with token auth, upgrades):
```sh
OPENSQUILLA_GATEWAY_IMAGE=ghcr.io/opensquilla/opensquilla:latest docker compose up -d
```
Without `OPENSQUILLA_GATEWAY_IMAGE`, the compose path runs an
`opensquilla:local` image you build yourself. Build it from a source
checkout with the Git LFS router assets pulled
(see [Install from source](#install-from-source) for the clone and
`git lfs pull`):
```sh
docker build -t opensquilla:local .
```
`./start.sh` (or `start.ps1` on Windows) then runs `docker compose
up -d` and tails the gateway logs. Docker avoids a host Python
toolchain — not the local image build.
</details>
Provider tiers, sandbox tuning, image generation, and concurrency
settings live in `opensquilla.toml.example`.
---
## What's New in 0.5.0 Preview 3
OpenSquilla 0.5.0 Preview 3 is a broad preview update for migration, routing,
desktop, runtime, and deployment:
- **Legacy-home migration** - detect and transactionally import older CLI,
desktop, portable, relocated, restored, and Docker-volume homes.
- **Providers and routing** - support expands across TokenRhythm, Tencent
TokenHub and Token Plan, and IQS, with live model discovery, probe and context
diagnostics, verified coding presets, richer ensemble configuration, and an
opt-in router self-learning loop.
- **Desktop, terminal, and Control UI** - improved updater behavior, onboarding,
terminal interaction, diagnostics, themes, attachments, chat navigation, and
desktop platform integration.
- **Runtime and safety hardening** - stronger persistence, MCP, session, tool,
sandbox, secret-redaction, same-origin, and provider retry contracts.
- **Container images** - prebuilt `linux/amd64` and `linux/arm64` gateway images
are published as `v0.5.0rc3` and `latest` on GHCR.
- **Simplified release assets** - 0.5 previews publish Electron installers,
updater metadata, the versioned Python wheel, and checksums; Windows portable
archives remain retired.
Full notes: [`CHANGELOG.md`](CHANGELOG.md) ·
[`docs/releases/0.5.0rc3.md`](docs/releases/0.5.0rc3.md).
## What's New in 0.2.1
OpenSquilla 0.2.1 is a maintenance release focused on release-package
startup and long-running agent reliability:
- **Windows portable startup** — the portable launcher better detects and
bootstraps the Visual C++ runtime needed by the bundled ONNX router.
- **Long-running agent turns** — tool-heavy WebUI sessions recover more
cleanly from oversized tool results, malformed tool calls, artifact
delivery handoffs, and degraded final responses.
- **Cleaner WebUI output** — generated artifact markers are kept out of
normal chat replay while delivered files remain visible.
- **Memory recall scoring** — local and OpenAI-compatible embedding vectors
are normalized before semantic search, and strong keyword matches remain
usable when vector scores are low.
Full notes: [`CHANGELOG.md`](CHANGELOG.md) ·
[release notes](https://opensquilla.ai/news/).
## What's New in 0.2.0
This release expands OpenSquilla across migration, CLI chat, channels,
scheduling, and long-running tool work:
- **Migration path from existing agent homes** — `opensquilla migrate` previews
and applies imports from existing OpenClaw/Hermes homes, including memory,
persona files, skills, MCP/channel config, conflict handling, and migration
reports.
- **Usable chat CLI** — `opensquilla chat` has a stable terminal UI, streaming
output, queued input, slash-mode discovery, tool/status strips, and more
deterministic live prompt behavior.
- **Cross-surface cron automation** — cron jobs now cover structured schedules,
timezone-aware exact/every/cron runs, channel or webhook delivery, failure
destinations, manual runs, and WebUI/CLI/RPC parity.
- **Better Feishu and Discord channels** — channel adapters expose clearer
capability metadata, safer DM/group handling, native file and artifact paths,
and improved attachment/thread behavior while privileged actions stay scoped.
- **Sturdier long-running turns** — failed turns are kept out of provider
replay, malformed tool calls are handled more safely, and approval-gated
retries wait for operator decisions.
- **Smarter context and tool budgeting** — provider-budget compaction, prompt
cache preservation, bounded tool results, and side-effect-aware concurrency
make large tool-heavy sessions more predictable.
- **Web UI and release polish** — recency ordering, table layout, mobile
controls, duplicate notifications, setup forms, release URLs, and install
paths are tightened for 0.2.0.
Full notes: [`CHANGELOG.md`](CHANGELOG.md) ·
[release notes](https://opensquilla.ai/news/).
---
## Key Features
| Capability | What it does |
| --- | --- |
| **Token-efficient routing** | `SquillaRouter` — a local LightGBM + ONNX classifier in the `recommended` extra — scores each turn on length, language, code, keywords, and semantic embeddings, then routes it across four tiers (C0C3; legacy T0T3 names are aliases) to the cheapest capable model. Classification runs on-device; your prompt never leaves the machine to make that decision. |
| **Adaptive reasoning and prompts** | OpenSquilla requests extended reasoning only for turns the router scores as complex, and the system prompt scales with task complexity — lightweight for trivial turns, full instructions for complex ones. |
| **20+ LLM providers** | The provider registry targets 20+ LLM backends — TokenRhythm, OpenRouter, OpenAI, Anthropic, Ollama, DeepSeek, Gemini, DashScope/Qwen, Moonshot, Mistral, Groq, Zhipu, SiliconFlow, vLLM, LM Studio, and more, with primary-plus-fallback selection; first-run onboarding exposes the verified subset. |
| **On-demand skills and MCP** | 15 bundled skills (coding, GitHub, cron, pptx/docx/xlsx/pdf, summarization, tmux, weather, and more) load only when the task needs them. OpenSquilla is an MCP client, and can also run as an MCP server — `opensquilla mcp-server run` needs the `mcp` extra (install `opensquilla[recommended,mcp]`). Skills can be authored, installed, and published from the CLI. |
| **Persistent local memory** | A curated `MEMORY.md` plus dated Markdown notes, searched with SQLite full-text keyword search and `sqlite-vec` semantic recall. Embeddings run on-device via bundled ONNX, or swap to OpenAI/Ollama. Optional exponential decay and opt-in "dream" consolidation are available. |
| **Layered security sandbox** | Three policy tiers (Standard / Strict / Locked) on a permission matrix. Bubblewrap isolates code execution on Linux; macOS runs commands through Seatbelt (`sandbox-exec`) with generated SBPL profiles; Windows uses the native `windows_default` backend after setup readiness checks. A denial ledger auto-pauses autonomous runs after repeated denials, rejected outputs are purged, and skill metadata and tool results are XML-escaped against prompt injection. |
| **Built-in tools** | File read/write/edit, shell and background processes, git, web search (DuckDuckGo, Bocha, Brave, IQS, Tavily, or Exa) and fetch behind an SSRF guard, spreadsheet/PPTX/PDF authoring, image generation, and text-to-speech. |
| **Unified gateway** | A Starlette ASGI server on `127.0.0.1:18791` with WebSocket RPC and an embedded control console (`/control/`). Web UI, CLI, and channels for Terminal, WebSocket, Slack, Telegram, Discord, Feishu, DingTalk, WeCom, Matrix, and QQ all share one `TurnRunner`. |
| **Durable sessions, subagents, and scheduling** | SQLite-backed session, transcript, and replay storage with per-agent workspaces. Agents spawn depth-bounded subagents, and a `SchedulerEngine` with an in-tree cron parser runs recurring jobs via `opensquilla cron`. |
| **Operator controls** | Human-in-the-loop approvals can pause sensitive tool calls for a decision; per-turn and per-session token and cost rollups (`opensquilla cost`) and diagnostics are available from the CLI and Web UI. |
MetaSkill docs: [`docs/features/meta-skills.md`](docs/features/meta-skills.md),
[`docs/features/meta-skill-user-guide.md`](docs/features/meta-skill-user-guide.md),
and [`docs/authoring/meta-skills.md`](docs/authoring/meta-skills.md).
---
## Benchmark Results
PinchBench 1.2.1 average results across 25 tasks:
| Agent | Base Model | Avg. score | Total input tokens | Total output tokens | Total cost |
| --- | ---: | ---: | ---: | ---: | ---: |
| OpenSquilla | Model router (Opus4.7, GLM5.1, DS4 Flash) | 0.9251 | 1,721,328 | 61,475 | $0.688 |
| OpenClaw | Claude Opus 4.7 | 0.9255 | 3,066,243 | 50,890 | $6.233 |
Score is the mean across the 25 tasks; token counts and cost are
totals for the full run.
---
## Troubleshooting
<details>
<summary>macOS desktop app keeps bouncing or reports AppTranslocation</summary>
If macOS starts OpenSquilla from a temporary AppTranslocation path, quit
OpenSquilla, drag the app into Applications if you are installing it, eject the
DMG, then open OpenSquilla again. If an old OpenSquilla icon is still bouncing,
force quit the old process first and reopen OpenSquilla.
</details>
<details>
<summary>macOS: <code>Library not loaded: @rpath/libomp.dylib</code></summary>
If startup logs `Library not loaded: @rpath/libomp.dylib` from
`lightgbm/lib/lib_lightgbm.dylib`, OpenSquilla keeps running with
direct single-model routing, but the bundled `SquillaRouter` runtime
stays inactive until the macOS OpenMP runtime is installed.
The desktop app bundles the native runtime it needs. If you used
Quick terminal install or source install from a shell, install `libomp`
with Homebrew and restart the gateway:
```sh
brew install libomp
opensquilla gateway restart
```
</details>
<details>
<summary>Windows: <code>DLL load failed</code> / Visual C++ runtime</summary>
If startup logs `DLL load failed while importing
onnxruntime_pybind11_state`, OpenSquilla keeps running with direct
single-model routing, but the bundled `SquillaRouter` runtime stays
inactive until the Visual C++ Redistributable for Visual Studio
20152022 (x64) is installed.
The from-source PowerShell installer attempts to install the redistributable via
`winget`. If you used Quick terminal install, or `winget` is unavailable,
install it manually and restart PowerShell:
<https://aka.ms/vs/17/release/vc_redist.x64.exe>. Then restore the recommended
router:
```powershell
opensquilla onboard --provider openrouter --api-key-env OPENROUTER_API_KEY --router recommended
opensquilla gateway restart
```
</details>
---
## Credits
OpenSquilla is inspired by
[OpenClaw](https://github.com/openclaw/openclaw). Bundled third-party
content is attributed in
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
Community contributors are acknowledged in
[`CONTRIBUTORS.md`](CONTRIBUTORS.md), including release-specific attribution
notes for squash-merged or replayed work.
---
## Contributors
Thanks to all the people who contribute to OpenSquilla.
<p align="center">
<a href="https://github.com/opensquilla/opensquilla/graphs/contributors">
<img src="https://contrib.rocks/image?repo=opensquilla/opensquilla&max=100&columns=10" alt="OpenSquilla contributors" />
</a>
</p>
---
## Contributing
Contributions of every kind are welcome — bug reports, feature ideas,
documentation, new provider or channel adapters, skills, and core
runtime work. See [`CONTRIBUTING.md`](CONTRIBUTING.md), then open an
issue or pull request on
[GitHub](https://github.com/opensquilla/opensquilla).
[Code of Conduct](CODE_OF_CONDUCT.md) · [Security](SECURITY.md) ·
[Privacy](PRIVACY.md) · [Code signing policy](docs/code-signing-policy.md) ·
[Third-party notices](THIRD_PARTY_NOTICES.md) · [Support](SUPPORT.md) ·
[License](LICENSE) (Apache-2.0)
+229
View File
@@ -0,0 +1,229 @@
# OpenSquilla Product Guide
OpenSquilla is a token-efficient personal agent runtime for the terminal, a
local Web UI, and messaging channels. It is designed for users who want one
agent surface that can chat, use tools, remember useful context, run scheduled
work, publish artifacts, and route work across multiple LLM providers without
rewriting their workflow for each provider.
This guide is the product and usage entry point. The existing
[`README.md`](README.md) remains the package/release README.
## Start Here
1. Install OpenSquilla:
```sh
uv tool install --python 3.12 "opensquilla[recommended] @ https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/opensquilla-0.5.0rc3-py3-none-any.whl"
```
2. Configure your provider:
```sh
opensquilla onboard
```
3. Start the gateway:
```sh
opensquilla gateway run
```
4. Open the control UI:
<http://127.0.0.1:18791/control/>
For platform-specific install paths and recovery steps, see
[`docs/quickstart.md`](docs/quickstart.md).
## Why OpenSquilla
OpenSquilla focuses on cost-per-success and long-running usefulness rather than
single-turn chat alone.
| Product capability | What users get |
| --- | --- |
| SquillaRouter | Local, on-device routing that chooses an appropriate model tier per turn so simple tasks avoid premium-model cost. |
| Tool compression | Large tool outputs stay useful without flooding the model context; raw results can be preserved while compact previews are sent to the model. |
| Meta-skills | Repeatable workflows can be packaged as composable skills, so users can turn recurring multi-step work into reusable agent routines. |
| Unified surfaces | CLI, Web UI, gateway RPC, and channels share the same runtime path, tools, memory, approvals, and usage accounting. |
| Durable sessions | Conversations, transcripts, compaction summaries, artifacts, cost, and replay data are persisted for later inspection. |
| Personal memory | User facts, notes, and task traces can be saved and recalled through local keyword and semantic search. |
| Multi-provider runtime | OpenRouter, OpenAI, Anthropic, Gemini, DeepSeek, DashScope, Ollama, and other provider-compatible backends can be configured through one schema. |
| Safe tool use | File, shell, web, memory, git, artifact, media, channel, and agent tools run behind policy layers and approval surfaces. |
## Documentation Map
| Need | Read |
| --- | --- |
| Install and run OpenSquilla | [`docs/quickstart.md`](docs/quickstart.md) |
| Choose the right workflow for a goal | [`docs/use-cases.md`](docs/use-cases.md) |
| Start, stop, expose, or troubleshoot the gateway | [`docs/gateway.md`](docs/gateway.md) |
| Configure providers, router, search, channels, memory, and permissions | [`docs/configuration.md`](docs/configuration.md) |
| Learn the CLI command groups | [`docs/cli.md`](docs/cli.md) |
| Use the local control console | [`docs/web-ui.md`](docs/web-ui.md) |
| Resume, export, abort, or delete sessions | [`docs/sessions.md`](docs/sessions.md) |
| Choose and inspect LLM providers/models | [`docs/providers-and-models.md`](docs/providers-and-models.md) |
| Configure web search | [`docs/search.md`](docs/search.md) |
| Understand the main product capabilities | [`docs/features.md`](docs/features.md) |
| Use SquillaRouter | [`docs/features/squilla-router.md`](docs/features/squilla-router.md) |
| Understand tool compression and tool-result handles | [`docs/features/tool-compression.md`](docs/features/tool-compression.md) |
| Use MetaSkills | [`docs/features/meta-skills.md`](docs/features/meta-skills.md) |
| Work with memory | [`docs/features/memory.md`](docs/features/memory.md) |
| Work with skills | [`docs/features/skills.md`](docs/features/skills.md) |
| Understand compaction, cache, and long-session continuity | [`docs/features/compaction-and-cache.md`](docs/features/compaction-and-cache.md) |
| Publish artifacts and use media features | [`docs/artifacts-and-media.md`](docs/artifacts-and-media.md) |
| Connect chat channels | [`docs/channels.md`](docs/channels.md) |
| Create durable named agents | [`docs/agents.md`](docs/agents.md) |
| Schedule recurring or one-time work | [`docs/scheduling.md`](docs/scheduling.md) |
| Understand tools, sandboxing, and approvals | [`docs/tools-and-sandbox.md`](docs/tools-and-sandbox.md) |
| Choose permissions and approval posture | [`docs/approvals-and-permissions.md`](docs/approvals-and-permissions.md) |
| Inspect usage and model cost | [`docs/usage-and-cost.md`](docs/usage-and-cost.md) |
| Diagnose and replay a turn | [`docs/diagnostics-and-replay.md`](docs/diagnostics-and-replay.md) |
| Connect MCP-capable clients | [`docs/mcp-server.md`](docs/mcp-server.md) |
| Run sessions, cron, diagnostics, migration, and MCP operations | [`docs/operations.md`](docs/operations.md) |
| Fix common install/runtime problems | [`docs/troubleshooting.md`](docs/troubleshooting.md) |
| Understand OpenSquilla terminology | [`docs/glossary.md`](docs/glossary.md) |
## The Fastest Useful Workflow
After the gateway is running, use the surface that fits the job:
```sh
opensquilla chat
```
Use this for interactive terminal work.
```sh
opensquilla agent -m "Summarize this repo and tell me what to test"
```
Use this for one-shot automation.
```sh
opensquilla gateway start --json
```
Use this for background Web UI, channels, and RPC clients.
```sh
opensquilla sessions list
opensquilla cost
opensquilla doctor
```
Use these to inspect history, cost, and readiness.
## Configuration Essentials
OpenSquilla loads configuration in this order:
1. `OPENSQUILLA_GATEWAY_CONFIG_PATH`
2. `./opensquilla.toml`
3. `~/.opensquilla/config.toml`
4. built-in defaults
Use the CLI for routine changes:
```sh
opensquilla onboard --if-needed
opensquilla configure provider --provider openrouter --api-key-env OPENROUTER_API_KEY
opensquilla configure router --router recommended
opensquilla configure search --search-provider duckduckgo
opensquilla configure search --search-provider tavily --api-key-env TAVILY_API_KEY
opensquilla configure channels
opensquilla config get llm.provider
opensquilla config set gateway.port 18791
```
See [`docs/configuration.md`](docs/configuration.md) for details.
## Feature Highlights
### SquillaRouter
SquillaRouter is OpenSquilla's local routing layer. It keeps lightweight tasks
on cheaper models and reserves stronger tiers for harder turns. Router
decisions stay local; the user's prompt is not sent to a separate external
classifier just to decide the model.
Read: [`docs/features/squilla-router.md`](docs/features/squilla-router.md)
### Tool Compression
Agent work often creates huge tool results: logs, web pages, search results,
spreadsheets, diffs, and JSON. OpenSquilla can keep the raw result available
while projecting a compact model-visible preview, reducing context pressure
without throwing away the user's working state.
Read: [`docs/features/tool-compression.md`](docs/features/tool-compression.md)
### Meta-Skills
Meta-skills let OpenSquilla present higher-level workflows instead of making the
user re-describe the same multi-step process. They are useful for repeatable
research reports, document-to-decision work, daily operating briefs, account
watching, job-search preparation, kid project planning, academic paper drafting,
and MetaSkill proposal creation.
By default, launch them deliberately with `/meta` and `/meta <name>`.
Read: [`docs/features/meta-skills.md`](docs/features/meta-skills.md),
[`docs/features/meta-skill-user-guide.md`](docs/features/meta-skill-user-guide.md),
and [`docs/authoring/meta-skills.md`](docs/authoring/meta-skills.md)
## What OpenSquilla Can Do
- Run chat from Web UI, CLI, gateway RPC, terminal channels, and supported
messaging platforms.
- Use tools for files, shell commands, code execution, git, web search/fetch,
memory, sessions, artifacts, media, Feishu, scheduled jobs, and subagents.
- Install, inspect, publish, and compose skills.
- Schedule recurring runs with `opensquilla cron`.
- Save durable memory and search previous sessions.
- Track usage and estimated cost with `opensquilla cost`.
- Diagnose readiness with `opensquilla doctor` and `/control/` health views.
- Export reproducible install state with `opensquilla dist`.
- Remove OpenSquilla cleanly with `opensquilla uninstall` — keeps your data
unless you pass `--purge-state` / `--purge-config` / `--purge-all`.
- Bridge OpenSquilla into MCP-capable clients with `opensquilla mcp-server run`
when the `mcp` extra is installed.
- Create and deliver artifacts such as HTML files, PDF reports, slides,
spreadsheets, generated images, and channel-delivered files.
## Safety Defaults
The gateway binds to `127.0.0.1` by default. Binding to public interfaces is
opt-in:
```sh
opensquilla gateway run --listen 0.0.0.0 --port 18791
```
Do not expose a public gateway without token auth and a network boundary you
trust. For tool behavior, approval flow, and workspace containment, see
[`docs/tools-and-sandbox.md`](docs/tools-and-sandbox.md).
## Existing Reference Docs
- [`README.md`](README.md) - release/package README
- [`MIGRATION.md`](MIGRATION.md) - migration from OpenClaw and Hermes Agent
- [`docs/features/meta-skill-user-guide.md`](docs/features/meta-skill-user-guide.md) - MetaSkill user guide
- [`docs/authoring/meta-skills.md`](docs/authoring/meta-skills.md) - MetaSkill authoring guide
- [`CONTRIBUTING.md`](CONTRIBUTING.md) - contributor workflow
- [`CHANGELOG.md`](CHANGELOG.md) - release history
## Improve the Documentation
OpenSquilla documentation is part of the product. If a setup step is confusing,
a command is stale, or a feature guide needs a clearer example, open a small
pull request against `main`. Existing `dev` pull requests may continue during
the branch transition.
Read [`docs/contributing-docs.md`](docs/contributing-docs.md) for docs-specific
guidance.
---
[Docs index](docs/README.md) · [Improve these docs](docs/contributing-docs.md) · [Report a docs issue](https://github.com/opensquilla/opensquilla/issues/new?template=docs_report.yml) · [Contributing](CONTRIBUTING.md)
+33
View File
@@ -0,0 +1,33 @@
# OpenSquilla
OpenSquilla is a Python agent runtime with MCP-native tools, durable sessions,
local memory, multi-channel messaging, and a local web control UI.
The package is published as part of an OpenSquilla release zip. Install from the
release bundle rather than from a source checkout so the wheel, dependency
wheelhouse, install scripts, and third-party notices stay together.
## Requirements
- Python 3.12 or newer.
- A configured model provider for live model calls.
- Optional channel credentials only for the channel integrations you enable.
## After Install
Run the onboarding command before starting the gateway:
```sh
opensquilla onboard --if-needed
```
Then start the local gateway:
```sh
opensquilla gateway run
```
## Project Links
Repository, license, release, and third-party notice information are included in
the release bundle and in the public OpenSquilla repository.
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`opensquilla/opensquilla`
- 原始仓库:https://github.com/opensquilla/opensquilla
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+665
View File
@@ -0,0 +1,665 @@
<!-- 本文件译自 README.md @ 8794ffbe。英文 README 为权威来源。 -->
<!-- 检查是否过期: git log 8794ffbe..HEAD -- README.md -->
# OpenSquilla — 高效省 Token 的 AI Agent
<p align="center">
<img src="assets/opensquilla-long-logo.png" alt="OpenSquilla logo" width="500">
</p>
<p align="center">
<b>同样的预算,让 Agent 做更多事、做更好的事。</b><br>
微内核 AI Agent——智能路由、持久记忆、安全沙箱、开箱即用的搜索与本地嵌入。
</p>
<p align="center">
<a href="https://github.com/opensquilla/opensquilla/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/opensquilla/opensquilla/ci.yml?style=for-the-badge" alt="CI"></a>
<a href="https://opensquilla.ai/"><img src="https://img.shields.io/badge/website-opensquilla.ai-blue?style=for-the-badge" alt="Website"></a>
<a href="https://github.com/opensquilla/opensquilla/releases"><img src="https://img.shields.io/github/v/release/opensquilla/opensquilla?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
<a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/python-3.12%2B-blue?style=for-the-badge" alt="Python 3.12+"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue?style=for-the-badge" alt="Apache 2.0 License"></a>
</p>
<p align="center">
<a href="README.md">English</a> · <b>中文</b> · <a href="README.ja.md">日本語</a> · <a href="README.fr.md">Français</a> · <a href="README.de.md">Deutsch</a> · <a href="README.es.md">Español</a>
</p>
> 本文档译自英文 [`README.md`](README.md),如有出入请以英文版为准。
---
## 最新动态
- 📢 **2026-07-03** —— 我们的技术报告 **[Agentic Routing: The Harness-Native Data Flywheel](docs/releases/agentic_routing_v0.pdf)**(预览版)已发布,随 OpenSquilla **0.5.0 Preview 1** 一同放出。报告详细介绍了 harness 原生路由如何把日常 Agent 流量转化为自我改进的数据飞轮。
---
## 概览
OpenSquilla 是一个高效利用 Token 的微内核 AI Agent。本地模型路由会把每一轮都发给能处理它的最便宜模型;持久记忆、分层沙箱、内置网络搜索和设备端嵌入共同构成了这个
统一共享的轮次循环。
每个入口——Web UI、CLI 和聊天渠道——都跑在同一个循环里,因此工具调度、重试和决策日志的行为处处一致。可插拔的提供商层对接 TokenRhythm、OpenRouter、OpenAI、Anthropic、
Ollama、DeepSeek、Gemini、Qwen/DashScope 等 20 多个 LLM 提供商,无需改动你的代码或
配置结构。
OpenSquilla 0.5.0 Preview 3 是当前预览发布版本。
如需面向任务的产品文档,请从
[OpenSquilla 产品指南](README.product.md)或[文档索引](docs/README.md)开始。
---
## 安装
OpenSquilla 可运行于 Windows、macOS 和 Linux。请选择与你的使用场景匹配的安装方式。
桌面安装包和终端快速安装会直接给你一个预构建的**发布版**,无需 Git。另外两种——从源码安装和从源码开发——则需要克隆 Git 仓库后再构建(`git clone` + Git LFS)。
发布版安装命令使用 GitHub 上已发布的 release 资源。Python wheel 安装使用带版本号的 wheel
文件名,因为安装器会校验嵌入在 wheel 文件名中的版本号。
对于 0.5.0 Preview 3 的桌面使用,建议从 GitHub Release 下载打包桌面安装包:macOS 上为
`OpenSquilla-0.5.0-rc3-mac-arm64.dmg`Windows 上为 `OpenSquilla-0.5.0-rc3-win-x64.exe`
| 安装方式 | 适合人群 | 何时使用 |
| --- | --- | --- |
| [桌面安装包](#desktop-installers)**(推荐桌面用户)** | macOS 和 Windows 用户 | 打包桌面应用 |
| [终端快速安装](#quick-terminal-install)**(推荐)** | 任意系统的最终用户 | 在终端中安装发布版 wheel |
| [从源码安装](#install-from-source) | 跟踪 `main` 分支的用户 | 从检出运行,而非修改它 |
| [从源码开发](#develop-from-source) | 贡献者 | 编辑、测试或调试源码 |
### 前置条件
| 要求 | 终端快速安装 | 从源码安装 | 从源码开发 |
| --- | :---: | :---: | :---: |
| Python 3.12+ | 通过 `uv` | 通过 `uv` 或系统 | 通过 `uv` |
| Git + Git LFS | — | 必需 | 必需 |
| `uv` | 缺失则自动安装 | 推荐 | 必需 |
默认的 `recommended` 安装档会安装 **SquillaRouter**——OpenSquilla 的设备端模型路由
——及其模型资源;`OPENSQUILLA_INSTALL_PROFILE=core` 则会省略这些依赖。而 `--router disabled` 这个独立的 onboarding 标志则会保留已装好的依赖,只在运行时关闭路由。
在 Windows 上,SquillaRouter 内置的 ONNX 运行时还需要 Visual C++ 运行库。源码安装用的
PowerShell 安装器会通过 `winget` 自动装好它;而**终端快速安装**(`uv tool install`)这条路径不会——如果启动时记录了 `DLL load failed`
错误,请手动安装(见[故障排查](#troubleshooting))。在装好之前,OpenSquilla 会以直连单一模型的路由方式继续运行。
在 macOS 终端安装时,SquillaRouter 的 LightGBM 运行时可能还需要系统的 OpenMP 库。
桌面应用会内置它所需的运行库,但**终端快速安装**不会安装 Homebrew/系统库。
如果启动时记录了 `Library not loaded: @rpath/libomp.dylib`,请运行
`brew install libomp`,然后重启网关。在装好之前,OpenSquilla 会以直连单一模型的路由方式
继续运行。
安装链接:[Git](https://git-scm.com/downloads) ·
[Git LFS](https://git-lfs.com/) ·
[uv](https://docs.astral.sh/uv/getting-started/installation/)。
<a id="desktop-installers"></a>
### 桌面安装包
0.5.0 Preview 3 桌面安装包将 Vue 控制台和网关运行时打包在一个 Electron 外壳中。
- macOS Apple Silicon:<https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/OpenSquilla-0.5.0-rc3-mac-arm64.dmg>
- Windows x64:<https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/OpenSquilla-0.5.0-rc3-win-x64.exe>
升级前请退出任何正在运行的 OpenSquilla 桌面应用。已有的
`~/.opensquilla/config.toml` 和会话数据会被复用。
在 Windows 上从 RC3 升级到 RC4 或更高版本时,请直接运行新安装包覆盖现有安装。
请勿先卸载 RC3:RC3 的卸载程序可能会删除桌面端用户数据。升级前请备份
`%APPDATA%\OpenSquilla`。RC4 及更高版本的安装包在常规卸载时会保留用户配置数据。
<a id="quick-terminal-install"></a>
### 终端快速安装
在 Windows、macOS 和 Linux 上的推荐路径。`uv` 会把 OpenSquilla 装进独立的隔离环境,并自带一个专用的 Python,不依赖系统 Python。此路径仅安装已发布的版本;如需 `main`、开发分支
或本地检出,请使用[从源码安装](#install-from-source)。
**1. 安装 `uv`**——如果 `uv --version` 已经可用则跳过。
Linux / macOS:
```sh
curl -LsSf https://astral.sh/uv/install.sh | sh
. "$HOME/.local/bin/env"
```
Windows PowerShell:
```powershell
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
$env:Path = "$env:USERPROFILE\.local\bin;" + $env:Path
```
**2. 安装 OpenSquilla**——所有平台命令相同。
```sh
uv tool install --python 3.12 "opensquilla[recommended] @ https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/opensquilla-0.5.0rc3-py3-none-any.whl"
```
这会从 release URL 安装 OpenSquilla wheel,再由 `uv` 下载所选 extra 所声明的依赖。
默认的 `recommended` extra 包含 SquillaRouter 运行时依赖,如 ONNX Runtime、LightGBM、
NumPy 和 tokenizers,因此首次安装需要联网,除非这些 wheel 已经缓存好了。`uv` 不会安装
系统原生运行库,如 macOS 的 `libomp` 或 Windows 的 Visual C++ Redistributable;若路由
运行时报告原生库加载错误,请参见[故障排查](#troubleshooting)。
**3. 配置并运行。**
```sh
opensquilla onboard
opensquilla gateway run
```
> [!NOTE]
> 如果在全新 `uv` 安装后立即找不到 `opensquilla`,请打开一个新终端,或重新执行第 1 步中的
> PATH 设置命令。
如需完全锁定版本的安装,请使用带版本号的 wheel URL:
`https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/opensquilla-0.5.0rc3-py3-none-any.whl`
<a id="install-from-source"></a>
### 从源码安装
用这条路径可以直接从克隆下来的代码运行 OpenSquilla,而不去改动它。这份克隆只是给安装器当包源用;装好之后
请使用 `opensquilla` 命令——不要运行 `uv run`。如果你打算修改代码,请改用
[从源码开发](#develop-from-source)。
1. **带 LFS 资源克隆**
```sh
git lfs install
git clone https://github.com/opensquilla/opensquilla.git
cd opensquilla
git lfs pull --include="src/opensquilla/squilla_router/models/**"
```
2. **运行安装器**
**macOS / Linux**
```sh
bash scripts/install_source.sh
```
**Windows PowerShell**
```powershell
powershell -ExecutionPolicy Bypass -File ./scripts/install_source.ps1
```
该脚本会用 `uv tool install` 把 `.[recommended]`(SquillaRouter + 记忆 +
本地模型)装进一个专用的用户环境;如果 `uv` 不可用,就退回到
`python -m pip install --user`。如果安装后 `opensquilla` 不在 `PATH` 上,请打开
一个新终端。
3. **(可选)安装进阶 extra。** 大多数渠道——Feishu(飞书)、Telegram、DingTalk(钉钉)、
QQ、WeCom(企业微信)、Slack 和 Discord——在基础安装下即可使用。可选的 extra 有:
- `matrix` — Matrix 渠道(引入 `matrix-nio`)
- `matrix-e2e` — 带端到端加密的 Matrix 渠道(需要 libolm
- `document-extras` — 通过 WeasyPrint 生成 PDF
```sh
OPENSQUILLA_INSTALL_EXTRAS=matrix bash scripts/install_source.sh # macOS / Linux
```
```powershell
powershell -ExecutionPolicy Bypass -File ./scripts/install_source.ps1 -Extras matrix # Windows
```
4. **配置并运行**——见[配置](#configuration)。
<details>
<summary>从源码安装——终端前置条件与安装器选项</summary>
**从终端安装前置条件(Git、Git LFS、uv)**
Windows PowerShell:
```powershell
winget install --id Git.Git -e
winget install --id GitHub.GitLFS -e
powershell -ExecutionPolicy Bypass -c "irm https://astral.sh/uv/install.ps1 | iex"
git lfs install
```
macOS(Homebrew):
```sh
brew install git git-lfs uv
git lfs install
```
Debian / Ubuntu:
```sh
sudo apt update && sudo apt install -y git git-lfs
curl -LsSf https://astral.sh/uv/install.sh | sh
git lfs install
```
在 Fedora 上使用 `sudo dnf install -y git git-lfs`;在 Arch 上使用
`sudo pacman -S --needed git git-lfs`;然后用上面的 `curl` 命令安装 `uv`。这些安装器
对 PATH 的修改会在新的终端会话中生效。
**安装器环境变量与 PATH 检查**
```sh
OPENSQUILLA_INSTALL_PROFILE=core bash scripts/install_source.sh # 最小运行时,无 SquillaRouter
OPENSQUILLA_INSTALL_DRY_RUN=1 bash scripts/install_source.sh # 仅打印计划
```
用 `command -v opensquilla`(macOS/Linux)或 `where.exe opensquilla`(Windows)确认
你的 shell 实际运行的是哪个 `opensquilla`。如果它不在 `PATH` 上,请运行
`uv tool update-shell`。从本地检出重新安装后,请重启网关以加载更新后的包。
</details>
<a id="develop-from-source"></a>
### 从源码开发
如果你要动 OpenSquilla 的源代码——改代码、跑测试,或在这份克隆里调试行为——就用这条路径。
它不是常规的安装路径。与[从源码安装](#install-from-source)不同,此路径需要 `uv`:
`uv sync` 会创建一个仓库本地的 `.venv`,而 `uv run` 会针对此检出中的文件执行命令。
```sh
uv sync --extra recommended --extra dev
uv run opensquilla --help
```
`recommended` extra 在开发时也包含 SquillaRouter;`dev` extra 会安装测试、lint 和
类型检查工具。把额外的 extra 安装到你运行的同一个环境中:
```sh
uv sync --extra recommended --extra dev --extra matrix
uv run opensquilla channels status matrix --json
```
在这种模式下,请为[配置](#configuration)中的每一条 `opensquilla` 命令加上 `uv run`
前缀。不要用用户本地的 `opensquilla` 命令去调试开发版代码——那个命令跑在另一个 Python 环境里。
### 卸载
用 `opensquilla uninstall` 卸载 OpenSquilla。它默认保留你的数据,只移除程序本身:
```sh
opensquilla uninstall --dry-run # 预览将被移除和保留的内容
opensquilla uninstall # 移除程序,保留你的数据
```
如需同时删除数据,请显式选择:
```sh
opensquilla uninstall --purge-state # 会话、日志、缓存、调度器、记忆
opensquilla uninstall --purge-config # config.toml 和密钥(.env
opensquilla uninstall --purge-all # 全部(会要求你输入确认)
```
运行中的网关会先被清空并停止,删除只在 OpenSquilla 主目录内进行;若是 Docker 或桌面安装,则会改为提供引导式的卸载步骤。桌面或操作系统应用的移除仍按各平台方式处理;CLI 引导不会删除桌面 app bundle。完整参考见 [`docs/cli.md`](docs/cli.md#uninstall)。
---
## 安装隐私
OpenSquilla 使用匿名安装遥测来估算安装数量、版本采纳情况和运行时兼容性。数据只在网关
首次启动时上报,并且每个 OpenSquilla 版本只上报一次。OpenSquilla 也可能执行被动更新
检查,包括桌面启动时的自动更新检查。上传设了很短的超时,绝不会阻塞启动。
发送的内容:
- schema 版本
- 本地生成的稳定 `install_id` 摘要
- OpenSquilla 版本
- 事件类型(`install` 或 `version_seen`)
- 安装方式(`pip`、`source`、`docker`、`desktop` 或 `unknown`)
- 操作系统、系统版本、CPU 架构,以及 Python 主/次版本号
- 首次见到与发送的时间戳
- CI/测试环境标记(`ci_environment`)
`install_id` 是一个本地单向 SHA-256 摘要,由可用的 MAC 地址派生;无 MAC 时使用本地 IP
地址,并以一个随机持久化值兜底。原始 MAC/IP 值不会被上传。
不发送的内容:用户名、主机名、路径、API key、提供商配置、聊天/会话/记忆/Agent 内容、
文件名或文件内容。源 IP 在传输层可能会被 HTTP 服务器看到,但它不在上传的数据内。
要在启动前关闭非用户主动触发的网络可观测性:
```sh
OPENSQUILLA_PRIVACY_DISABLE_NETWORK_OBSERVABILITY=true
```
或在配置中设置:
```toml
[privacy]
disable_network_observability = true
```
这个统一开关覆盖自动安装遥测、被动更新检查和桌面启动自动更新检查。用户主动触发的操作仍可能在明确意图后访问网络服务,例如手动发布/下载/更新检查,以及已配置的提供商、搜索或渠道。
旧环境变量仍兼容:
```sh
OPENSQUILLA_TELEMETRY_DISABLED=true
OPENSQUILLA_UPDATE_CHECK_DISABLED=true
```
进阶部署可以使用自己的端点:
```sh
OPENSQUILLA_TELEMETRY_ENDPOINT=https://example.com/v1/install
```
---
<a id="configuration"></a>
## 配置
### 首次配置
`opensquilla onboard` 是交互式的首次配置向导。它会写入当前配置文件;当你传入 `--api-key-env` 时,提供商密钥会留在环境变量里。路由默认为 `recommended`
(在受支持的提供商上启用 SquillaRouter);如需直连单一模型,使用 `--router disabled`。
```sh
opensquilla onboard # 完整交互式向导
opensquilla onboard --if-needed # 幂等:适用于脚本和重装
opensquilla onboard --minimal # 仅配置提供商;跳过渠道与搜索
opensquilla onboard status # 查看每个配置项,但不写入
```
在 SSH、CI 或任何没有 TTY 的环境中,请使用非交互形式——把密钥放在环境变量里,
并传入它的**变量名**,而不是它的值:
**Linux / macOS**
```sh
export OPENROUTER_API_KEY="sk-..."
opensquilla onboard --provider openrouter --api-key-env OPENROUTER_API_KEY
```
**Windows PowerShell**
```powershell
$env:OPENROUTER_API_KEY="sk-..."
opensquilla onboard --provider openrouter --api-key-env OPENROUTER_API_KEY
```
OpenRouter 仅作示例——可替换为任意受支持的提供商及其对应的 API key 变量。
之后无需重做整个向导即可重新配置某一个部分(以下示例假设相关 API key 已在环境中):
```sh
opensquilla configure provider --provider openai --model gpt-4o --api-key-env OPENAI_API_KEY
opensquilla configure router --router recommended
opensquilla configure search --search-provider duckduckgo
opensquilla configure search --search-provider exa --api-key-env EXA_API_KEY
opensquilla configure channels
```
各部分:`provider`、`router`、`channels`、`search`、`image-generation`、
`memory-embedding`。Web UI 在 `/control/setup` 暴露相同的目录和状态模型:Provider 和
Router 是快速路径,而 Channels、Search、Image generation 和 Memory embedding 位于
能力中心(Capability Center),可以稍后配置。渠道留空会被当作主动跳过,而不是配置失败。
**配置加载顺序:**`OPENSQUILLA_GATEWAY_CONFIG_PATH` → `./opensquilla.toml` →
`~/.opensquilla/config.toml` → 内置默认值。对单个密钥来说,环境变量里的值始终优先于配置文件里的值。
### 从 OpenClaw 或 Hermes Agent 迁移
如果你已经在 `~/.openclaw` 或 `~/.hermes` 下有状态,请先执行一次 dry run 查看迁移报告,
然后再显式应用:
```sh
opensquilla migrate openclaw --json
opensquilla migrate openclaw --apply
opensquilla migrate hermes --json
opensquilla migrate hermes --apply
```
使用 `opensquilla migrate --source openclaw,hermes --apply` 可同时导入两个默认主目录。
只有在看过 dry-run 报告之后,才加上 `--migrate-secrets`。自定义路径和冲突处理见
[`MIGRATION.md`](MIGRATION.md)。
### 运行
```sh
opensquilla gateway run # 前台运行,127.0.0.1:18791
opensquilla gateway start --json # 后台运行 + 健康检查等待
opensquilla chat # 交互式 REPL
opensquilla agent -m "你的提示词" # 一次性执行,便于自动化
```
在 <http://127.0.0.1:18791/control/> 打开 Web UI。**Health(健康)** 视图会显示
OpenSquilla 是否就绪、哪些项尚未就绪,以及下一步的恢复建议。在 CLI 中,运行:
```sh
opensquilla doctor
opensquilla doctor --json
opensquilla doctor --config ./opensquilla.toml --json
```
`/health` 和 `/healthz` 是用于进程检查的轻量存活探针。`opensquilla doctor` 和 Web UI 的
Health 视图则是检查就绪状态的地方,涵盖提供商配置、记忆、日志、搜索、渠道、沙箱状态、
路由、图像生成以及恢复指引。按 `Ctrl+C` 可停止前台网关。
其他命令组包括 `sessions`、`skills`、`memory`、`migrate`、`cron`、`channels`、
`providers`、`models` 和 `cost`。运行 `opensquilla --help` 或
`opensquilla <组名> --help` 查看详情。
<details>
<summary>进阶配置——验证渠道、公网绑定、Docker</summary>
**连接并验证一个消息渠道**
保存渠道只是改了配置,并不代表它在运行时真的连得上。编辑渠道后请重启网关,然后验证实时渠道:
```sh
opensquilla gateway restart
opensquilla channels status <name> --json
```
只有当状态数据里 `enabled=true`、`configured=true` 且 `connected=true` 时,才算渠道已连上。Feishu(飞书)默认使用 websocket 模式,Telegram 使用轮询,Slack 可使用
Socket Mode——这些模式都不需要公网 URL。Feishu webhook 模式、Telegram webhook 模式、
Slack webhook 模式以及 WeCom(企业微信)则需要一个公网可达、提供商可访问的 URL。
**公网绑定**
要从另一台机器访问 Web UI,请把网关绑定到所有网络接口,并使用主机的公网 IP:
```sh
opensquilla gateway run --listen 0.0.0.0 --port 18791
```
公网访问还要求主机防火墙或云安全组允许该端口的入站 TCP。不要在
`[auth] mode = "none"` 的情况下暴露网关——在绑定到 `0.0.0.0` 之前请先配置 token 认证。
**Docker**
预构建的多架构镜像(`amd64`/`arm64`)会随每个发布标签发布到
`ghcr.io/opensquilla/opensquilla`——完整的容器部署指南见
[`docs/docker.md`](docs/docker.md)(家庭服务器与 NAS、带 token 认证的局域网访问、升级):
```sh
OPENSQUILLA_GATEWAY_IMAGE=ghcr.io/opensquilla/opensquilla:latest docker compose up -d
```
不设置 `OPENSQUILLA_GATEWAY_IMAGE` 时,compose 路径运行一个你自己构建的
`opensquilla:local` 镜像。请从一份已拉取 Git LFS 路由
资源的源码检出来构建它(克隆与 `git lfs pull` 见[从源码安装](#install-from-source)):
```sh
docker build -t opensquilla:local .
```
随后 `./start.sh`(Windows 上为 `start.ps1`)会运行 `docker compose up -d` 并跟踪网关
日志。Docker 省掉的是宿主机上的 Python 工具链,而不是本地镜像构建那一步。
</details>
提供商分级、沙箱调优、图像生成和并发设置位于 `opensquilla.toml.example`。
---
## 0.4.1 更新内容
OpenSquilla 0.4.1 是面向桌面端与 Control UI 方向的维护版本:
- **桌面可靠性** —— 打包后的网关检查现在覆盖 Coding 模式、`code-task` 和 SquillaRouter
启动,桌面窗口/产物处理更加稳定。
- **六种语言客户端支持** —— Control UI 和桌面客户端在首屏和设置界面支持英语、简体中文、
日语、法语、德语和西班牙语。
- **Coding 模式与路由打包** —— 如果路由资源缺失或仍是 Git LFS 指针,桌面构建会快速失败,
防止生成功能受损的发布包。
- **遥测与 Windows 打磨** —— 安装遥测会跳过 CI 和测试环境,Windows 桌面资源使用
OpenSquilla 徽标。
- **主线治理** —— 普通 pull request 和发布集成统一围绕 `main`,维护者分支则保留用于发布、
热修复、预发布、集成和沙箱工作。
完整说明:[`CHANGELOG.md`](CHANGELOG.md) ·
[`docs/releases/0.4.1.md`](docs/releases/0.4.1.md)。
## 0.2.1 更新内容
OpenSquilla 0.2.1 是一个专注于发布包启动和长时运行 Agent 可靠性的维护版本:
- **Windows 便携版启动** —— 便携版启动器能更好地检测并引导安装内置 ONNX 路由所需的
Visual C++ 运行库。
- **长时运行的 Agent 轮次** —— 工具密集的 WebUI 会话现在能更利落地从各种状况中恢复:超大工具结果、格式错误的工具调用、产物交付环节,以及降级的最终响应。
- **更干净的 WebUI 输出** —— 生成的产物标记不会出现在普通聊天回放中,而已交付的文件仍然
可见。
- **记忆召回评分** —— 本地以及 OpenAI 兼容的嵌入向量,会在语义搜索前先做归一化;当向量得分偏低时,强关键词匹配依然能派上用场。
完整说明:[`CHANGELOG.md`](CHANGELOG.md) ·
[发布说明](https://opensquilla.ai/news/)。
## 0.2.0 更新内容
此版本在迁移、CLI 聊天、渠道、调度和长时运行的工具工作等方面扩展了 OpenSquilla:
- **从现有 Agent 主目录迁移** —— `opensquilla migrate` 可以预览并执行从现有 OpenClaw/Hermes 主目录的导入,包括记忆、persona 文件、技能、MCP/渠道配置、冲突处理
和迁移报告。
- **可用的聊天 CLI** —— `opensquilla chat` 拥有稳定的终端 UI、流式输出、排队输入、
斜杠模式发现、工具/状态条,以及更具确定性的实时提示行为。
- **跨界面的 cron 自动化** —— cron 作业现在涵盖结构化排程、时区感知的精确/周期性/cron 运行、
渠道或 webhook 投递、失败目标、手动运行,以及 WebUI/CLI/RPC 的一致性。
- **更好的 Feishu 和 Discord 渠道** —— 渠道适配器暴露更清晰的能力元数据、更安全的
私信/群组处理、原生的文件与产物路径,以及改进的附件/线程行为,同时特权操作保持受限作用域。
- **更稳健的长时运行轮次** —— 失败的轮次不会进入提供商回放,格式错误的工具调用也会得到更稳妥的处理;需要审批的重试则会等操作者拍板。
- **更智能的上下文与工具预算** —— 提供商预算压缩、提示缓存保留、对工具结果做大小限制,以及能感知副作用的并发,让大型、工具密集的会话更可预测。
- **Web UI 与发布打磨** —— 在 0.2.0 中对按时间排序、表格布局、移动端控件、重复通知、配置表单、release URL 和安装路径都做了打磨收紧。
完整说明:[`CHANGELOG.md`](CHANGELOG.md) ·
[发布说明](https://opensquilla.ai/news/)。
---
## 核心功能
| 能力 | 它做什么 |
| --- | --- |
| **省 Token 的路由** | `SquillaRouter`——`recommended` extra 中一个本地的 LightGBM + ONNX 分类器——会按长度、语言、代码、关键词和语义嵌入给每一轮打分,再在四个分级(C0–C3;旧的 T0–T3 命名是其别名)里把它分派给能胜任的最便宜模型。分类在本机上完成;为了做这个判断,你的提示词不会离开本机。 |
| **自适应推理与提示** | OpenSquilla 仅对路由判定为复杂的轮次请求扩展推理,系统提示也随任务复杂度伸缩——简单轮次用轻量提示,复杂轮次用完整指令。 |
| **20+ 个 LLM 提供商** | 提供商注册表面向 20 多个 LLM 后端——TokenRhythm、OpenRouter、OpenAI、Anthropic、Ollama、DeepSeek、Gemini、DashScope/Qwen、Moonshot、Mistral、Groq、Zhipu、SiliconFlow、vLLM、LM Studio 等等,并支持主用 + 回退选择;首次 onboarding 会暴露已验证的子集。 |
| **按需技能与 MCP** | 15 个内置技能(coding、GitHub、cron、pptx/docx/xlsx/pdf、摘要、tmux、天气等)仅在任务需要时加载。OpenSquilla 是 MCP 客户端,也可以作为 MCP 服务端运行——`opensquilla mcp-server run` 需要 `mcp` extra(安装 `opensquilla[recommended,mcp]`)。技能可以从 CLI 编写、安装和发布。 |
| **持久化本地记忆** | 一份精选的 `MEMORY.md` 加上带日期的 Markdown 笔记,通过 SQLite 全文关键词搜索和 `sqlite-vec` 语义召回来检索。嵌入通过内置 ONNX 在设备端运行,也可切换到 OpenAI/Ollama。可选的指数衰减和需主动启用的“做梦(dream)”记忆整合也可用。 |
| **分层安全沙箱** | 基于权限矩阵的三档策略(Standard / Strict / Locked)。在 Linux 上 Bubblewrap 隔离代码执行;macOS 的 Seatbelt 后端目前只生成 profile(尚未真正执行),Windows 上则还没有沙箱后端。拒绝账本(denial ledger)会在反复拒绝后自动暂停自主运行,清除被拒的输出;技能元数据和工具结果也会做 XML 转义,以防提示注入。 |
| **内置工具** | 文件读/写/编辑、shell 与后台进程、git、网络搜索(DuckDuckGo、Bocha、Brave、Tavily 或 Exa),以及带 SSRF 防护的网页抓取、电子表格/PPTX/PDF 创作、图像生成,以及文本转语音。 |
| **统一网关** | 一个运行在 `127.0.0.1:18791` 上、带 WebSocket RPC 和内嵌控制台(`/control/`)的 Starlette ASGI 服务。Web UI、CLI,以及 Terminal、WebSocket、Slack、Telegram、Discord、Feishu、DingTalk、WeCom、Matrix、QQ 这些渠道,都共用同一个 `TurnRunner`。 |
| **持久会话、子 Agent 与调度** | 由 SQLite 支撑的会话、转录和回放存储,并带有按 Agent 隔离的工作区。Agent 可以派生深度受限的子 Agent;`SchedulerEngine` 内置了 cron 解析器,会通过 `opensquilla cron` 运行周期性作业。 |
| **操作者控制** | 人在环路(human-in-the-loop)审批可以暂停敏感的工具调用,等人来决定;按轮次和按会话的 Token 与成本汇总(`opensquilla cost`)及诊断信息均可从 CLI 和 Web UI 获取。 |
MetaSkill 文档:[`docs/features/meta-skills.md`](docs/features/meta-skills.md)、
[`docs/features/meta-skill-user-guide.md`](docs/features/meta-skill-user-guide.md)
和 [`docs/authoring/meta-skills.md`](docs/authoring/meta-skills.md)。
---
## 基准测试结果
PinchBench 1.2.1 在 25 个任务上的平均结果:
| Agent | 基座模型 | 平均分 | 总输入 token | 总输出 token | 总成本 |
| --- | ---: | ---: | ---: | ---: | ---: |
| OpenSquilla | 模型路由(Opus4.7、GLM5.1、DS4 Flash | 0.9251 | 1,721,328 | 61,475 | $0.688 |
| OpenClaw | Claude Opus 4.7 | 0.9255 | 3,066,243 | 50,890 | $6.233 |
分数是 25 个任务的均值;token 数和成本是整次运行的总计。
---
<a id="troubleshooting"></a>
## 故障排查
<details>
<summary>macOS<code>Library not loaded: @rpath/libomp.dylib</code></summary>
如果启动时从 `lightgbm/lib/lib_lightgbm.dylib` 记录了
`Library not loaded: @rpath/libomp.dylib`,OpenSquilla 会以直连单一模型的路由方式继续
运行,但内置的 `SquillaRouter` 运行时会保持不活动,直到安装 macOS 的 OpenMP 运行库。
桌面应用会内置它所需的原生运行库。如果你是通过终端快速安装或从 shell 进行源码安装,
请用 Homebrew 安装 `libomp` 并重启网关:
```sh
brew install libomp
opensquilla gateway restart
```
</details>
<details>
<summary>Windows<code>DLL load failed</code> / Visual C++ 运行库</summary>
如果启动时记录了 `DLL load failed while importing onnxruntime_pybind11_state`,
OpenSquilla 会以直连单一模型的路由方式继续运行,但内置的 `SquillaRouter` 运行时会保持
不活动,直到安装适用于 Visual Studio 20152022(x64)的 Visual C++ Redistributable。
从源码安装的 PowerShell 安装器会尝试通过 `winget` 安装该 redistributable。如果你使用的是终端快速安装,或 `winget` 不可用,请手动安装它并重启
PowerShell:<https://aka.ms/vs/17/release/vc_redist.x64.exe>。然后恢复推荐的路由:
```powershell
opensquilla onboard --provider openrouter --api-key-env OPENROUTER_API_KEY --router recommended
opensquilla gateway restart
```
</details>
---
## 致谢
OpenSquilla 的灵感来自
[OpenClaw](https://github.com/openclaw/openclaw)。内置的第三方内容在
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) 中注明出处。
社区贡献者都记在 [`CONTRIBUTORS.md`](CONTRIBUTORS.md) 里,其中也包含针对 squash 合并或回放工作、按发布版本给出的署名说明。
---
## 贡献者
感谢所有为 OpenSquilla 做出贡献的人。
<p align="center">
<a href="https://github.com/opensquilla/opensquilla/graphs/contributors">
<img src="https://contrib.rocks/image?repo=opensquilla/opensquilla&max=100&columns=10" alt="OpenSquilla contributors" />
</a>
</p>
---
## 参与贡献
我们欢迎各种形式的贡献——bug 报告、功能想法、文档、新的提供商或渠道适配器、技能,以及
核心运行时方面的开发。请参阅 [`CONTRIBUTING.md`](CONTRIBUTING.md),然后到
[GitHub](https://github.com/opensquilla/opensquilla) 上提 issue 或 pull request。
[行为准则](CODE_OF_CONDUCT.md) · [安全](SECURITY.md) ·
[支持](SUPPORT.md) · [许可证](LICENSE)Apache-2.0
+164
View File
@@ -0,0 +1,164 @@
# OpenSquilla Releases
| Version | Tag | Date | Notes |
|---|---|---|---|
| 0.5.0rc3 | v0.5.0rc3 | 2026-07-10 | Preview: legacy-home migration, provider and routing expansion, desktop/Web UI improvements, runtime hardening, and container images |
| 0.5.0rc2 | v0.5.0rc2 | 2026-07-06 | Preview: provider/router recovery, Web UI upload refresh, desktop/session fixes, and CI contract repair |
| 0.5.0rc1 | v0.5.0rc1 | 2026-07-04 | Preview: Model Ensemble routing, Control UI, managed execution, OpenTUI, and portable retirement |
| 0.4.1 | v0.4.1 | 2026-06-30 | Desktop reliability, six-language client support, telemetry accuracy, router packaging, and mainline governance |
| 0.4.0 | v0.4.0 | 2026-06-27 | Control UI refresh, manual MetaSkills, coding mode, search expansion, and runtime hardening |
| 0.3.0 | v0.3.0 | 2026-05-31 | MetaSkills, Health Doctor, tool compression, and docs release |
| 0.2.1 | v0.2.1 | 2026-05-21 | 0.2 maintenance release |
| 0.2.0 | v0.2.0 | 2026-05-20 | 0.2 release |
| 0.2.0rc1 | v0.2.0rc1 | 2026-05-19 | Second public preview |
| 0.1.0rc1 | v0.1.0rc1 | 2026-05-12 | First public preview |
0.5.x preview releases publish Electron desktop installers, updater metadata,
the versioned Python wheel, and `SHA256SUMS`:
- `OpenSquilla-<version>-mac-arm64.dmg`
- `OpenSquilla-<version>-mac-arm64.zip`
- `OpenSquilla-<version>-win-x64.exe`
- `latest-mac.yml`
- `latest.yml`
- `*.blockmap`
- `opensquilla-<version>-py3-none-any.whl`
- `SHA256SUMS`
0.5.x preview releases are GitHub pre-releases and must not be marked as Latest.
They do not publish Windows portable zips, Windows portable latest aliases,
public wheelhouse zips, or separately branded macOS or Linux portable bundles.
The listed macOS `.zip` is the Electron desktop and updater artifact, not a
portable distribution.
Existing 0.4.x release pages keep their legacy Windows portable downloads for
historical compatibility, while new 0.5.x releases publish only the listed
Electron desktop artifacts, updater metadata, versioned wheel, and checksums.
For Windows Desktop upgrades from RC3 to RC4 or later, users must install the
new version directly over the existing installation and must not uninstall RC3
first. The RC3 uninstaller may remove `%APPDATA%\OpenSquilla`; release guidance
must tell users to back up that directory. RC4 and later NSIS packages set
`deleteAppDataOnUninstall=false`.
Container tags follow a separate policy: each release publishes
`ghcr.io/opensquilla/opensquilla:<git-tag>`, and Docker `:latest`
tracks the most recently pushed release tag, including previews and backports.
If a backport moves `:latest`, rerun the container workflow from the newest tag
to restore the intended ordering. The fixed release tag is the rollback and
reproducibility contract.
The Windows desktop installer is currently unsigned; release notes and download
sections must link to `docs/code-signing-policy.md` until a signing workflow is
approved and enabled. Windows browser downloads may carry Mark-of-the-Web, and
SmartScreen, Smart App Control, enterprise policy, and unsigned binary
reputation must be checked on a real Windows machine before broad promotion.
GitHub source archives remain available for code review and developer
reference; source installs should use `git clone` plus Git LFS. Python wheel
filenames must remain versioned because installers validate the version segment
inside the wheel filename.
Release docs must describe the unified non-user-initiated network observability
switch. `OPENSQUILLA_PRIVACY_DISABLE_NETWORK_OBSERVABILITY=true` or:
```toml
[privacy]
disable_network_observability = true
```
disables automatic install telemetry, passive update checks, and desktop
startup auto-update checks. The legacy compatibility environment variables
`OPENSQUILLA_TELEMETRY_DISABLED=true` and
`OPENSQUILLA_UPDATE_CHECK_DISABLED=true` remain honored. Manual user-initiated
release, download, or update checks may still contact GitHub after user intent.
Preview README install commands must use tag-pinned URLs such as:
- `https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/OpenSquilla-0.5.0-rc3-mac-arm64.dmg`
- `https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/OpenSquilla-0.5.0-rc3-win-x64.exe`
- `https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/opensquilla-0.5.0rc3-py3-none-any.whl`
## Release SOP
1. Verify `git status` is clean before starting release prep.
2. Confirm the latest `origin/main` SHA is the intended release baseline and
that its required CI run completed successfully.
3. Prepare a release PR from `origin/main`: update version metadata,
`CHANGELOG.md`, `RELEASES.md`, `CONTRIBUTORS.md`, release notes, README
download sections, install scripts, workflow asset contracts, and release
tests.
4. Confirm release notes and README download sections link to `PRIVACY.md`,
`THIRD_PARTY_NOTICES.md`, and `docs/code-signing-policy.md`; do not claim
Windows code signing before it is enabled. Confirm privacy wording documents
the unified network observability switch and legacy opt-out environment
variables.
5. Bump `pyproject.toml`, `uv.lock`, `desktop/electron/package.json`,
`desktop/electron/package-lock.json`, `install.sh`, and `install.ps1` to the
release version.
6. Run the focused release contract tests locally, then open and merge the
release PR only after review and CI pass.
7. Fetch `origin main --tags`, verify the merged `origin/main` SHA and CI one
more time, then create the annotated tag on that exact SHA:
```sh
git tag -a v0.5.0rc3 <verified-sha> -m "OpenSquilla 0.5.0 Preview 3"
git push origin v0.5.0rc3
```
8. Wait for both `.github/workflows/wheelhouse-release.yml` and
`.github/workflows/docker-image.yml`. Review the draft GitHub Release. For
`v0.5.0rc3`, confirm it is a pre-release, is not marked
Latest, and contains only the Electron installers, updater metadata,
versioned wheel, `SHA256SUMS`, plus GitHub's generated source archives. It
must not contain `OpenSquilla-*-portable.zip` or
`OpenSquilla-windows-x64-portable.zip`.
9. Verify GHCR before publishing broadly. For the first container release, make
the newly created `ghcr.io/opensquilla/opensquilla` package public, then
confirm both `v0.5.0rc3` and `latest` resolve to an amd64/arm64 manifest and
pass a gateway health smoke test.
10. Publish the GitHub Release only after maintainer confirmation, then run the
post-publish tag URL checks:
```sh
curl --fail --head --location https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/OpenSquilla-0.5.0-rc3-mac-arm64.dmg
curl --fail --head --location https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/OpenSquilla-0.5.0-rc3-win-x64.exe
curl --fail --head --location https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/opensquilla-0.5.0rc3-py3-none-any.whl
curl --fail --head --location https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc3/SHA256SUMS
```
11. If a release tag is wrong before publication, stop and report its peeled
SHA, the intended SHA, CI result, tag message, and protected-tag ruleset.
Move it only through the protected-tag repair procedure, restore protection,
and verify both workflows and the remote peeled tag before continuing.
12. For subsequent previews: bump the package version, docs, workflow
contracts, and tag to the next preview version, for example `0.5.0rc4` /
`v0.5.0rc4`. Preview GitHub Releases must remain pre-releases and use
tag-pinned README URLs until a later stable release is intentionally
promoted.
## GitHub-only release checks
These checks cannot be fully proven by local artifact generation:
- The tag exists on GitHub and matches `pyproject.toml`.
- The release workflow can fetch hydrated Git LFS router assets.
- The draft GitHub Release title is `OpenSquilla 0.5.0 Preview 3`.
- The draft GitHub Release is marked Pre-release and is not marked Latest.
- Preview GitHub Releases contain the Electron installers, updater metadata,
versioned wheel, and `SHA256SUMS` after `gh release upload --clobber`.
- Preview GitHub Releases do not contain Windows portable zips or portable
latest aliases.
- The GHCR package is public, and `v0.5.0rc3` plus `latest` expose both amd64
and arm64 images that pass the gateway health smoke test.
- After a preview GitHub Release is published, the tag-pinned release asset URLs
resolve.
- Windows browser downloads may carry Mark-of-the-Web; SmartScreen,
Smart App Control, enterprise policy, and unsigned binary reputation must be
checked on a real Windows machine.
## Why preview package versions use rc
Release assets are distributed as built artifacts, so the package filename,
installer name, wheel name, and tag should describe the same preview build.
PEP 440 accepts `0.5.0rc3`, while the public GitHub Release title can use the
friendlier name "OpenSquilla 0.5.0 Preview 3".
+30
View File
@@ -0,0 +1,30 @@
# Security Policy
## Supported Versions
Security fixes are evaluated against the current `main` branch and the latest
public release. Older snapshots may receive a fix only when the affected code is
still present in the current release line.
## Reporting a Vulnerability
Do not open a public issue with exploit details, credentials, provider tokens,
local transcripts, or account identifiers.
Use GitHub private vulnerability reporting for this repository when it is
available. If private reporting is not available, open a minimal public issue
asking for a secure maintainer contact path and do not include technical exploit
details.
Helpful reports include:
- Affected version or commit.
- A concise description of the vulnerable behavior.
- Reproduction steps using placeholders instead of real credentials.
- Expected impact and any known mitigations.
## Handling
Maintainers will acknowledge valid reports, triage severity, prepare a fix on a
restricted branch when appropriate, and publish public details after a release or
mitigation is available.
+26
View File
@@ -0,0 +1,26 @@
# Support
OpenSquilla support happens through public repository channels unless the report
is security-sensitive.
## Where to Ask
- Use the bug report template for reproducible defects.
- Use the feature request template for scoped product requests.
- Use pull requests for focused fixes with public tests.
- Use `SECURITY.md` for suspected vulnerabilities.
## What to Include
- OpenSquilla version or commit.
- Operating system and Python version.
- Command or workflow you ran.
- Expected behavior and actual behavior.
- Minimal logs with credentials, tokens, account ids, and local paths removed.
## Scope
Maintainers can help with OpenSquilla behavior, install issues, release artifacts,
and public contribution workflow. Provider account setup, channel account
administration, and private deployment operations are outside the public support
scope unless a reproducible OpenSquilla defect is involved.
+548
View File
@@ -0,0 +1,548 @@
# Third Party Notices
This file records third-party attribution for assets bundled with OpenSquilla.
It covers:
- The bundled skill descriptors under `src/opensquilla/skills/bundled/`, which
include OpenClaw-derived MIT descriptors and OpenSquilla-original descriptors.
- The bundled pptx skill references the python-pptx and PptxGenJS libraries;
OpenSquilla does not vendor those libraries, but the skill instructs the
agent runtime to invoke them and is documented here for transparency.
- The local SquillaRouter V4 Phase 3 model bundle under
`src/opensquilla/squilla_router/models/v4.2_phase3_inference/`.
- Static frontend vendor assets and fonts distributed with the gateway Control
UI and generated Web UI assets.
- The Web UI "Arctic" theme color palette, adapted from the Nord palette under
the MIT license; see the dedicated section below.
- The built-in tokenjuice tool-result projection backend and bundled
reduction rules under `src/opensquilla/plugins/tokenjuice/`.
- The cron prompt-injection scanner was reviewed against Hermes Agent
reference material; the MIT notice is reproduced below for conservative
attribution.
## Static frontend vendor assets and fonts
OpenSquilla distributes a built Control UI and selected static assets in the
gateway package. These assets are used for markdown rendering, math rendering,
syntax highlighting, sanitization, and local typography. They are not
proprietary components.
| Component | Distributed files | License and attribution |
|---|---|---|
| KaTeX | `src/opensquilla/gateway/static/vendor/katex.min.js`, `src/opensquilla/gateway/static/vendor/katex.min.css`, generated `KaTeX_*` font files under `src/opensquilla/gateway/static/dist/assets/fonts/` | MIT. Copyright (c) 2013-2020 Khan Academy and other contributors. |
| PrismJS | `src/opensquilla/gateway/static/vendor/prism-core.min.js`, `prism-autoloader.min.js`, and `prism-langs/*.min.js` | MIT. Copyright (c) 2012 Lea Verou. |
| marked | `src/opensquilla/gateway/static/vendor/marked.min.js` | MIT. Copyright (c) 2011-2025, Christopher Jeffrey. |
| DOMPurify | `src/opensquilla/gateway/static/vendor/purify.min.js` | MPL-2.0 OR Apache-2.0. OpenSquilla distributes this asset under the Apache-2.0 option. Copyright belongs to Cure53 and other contributors. |
| IBM Plex Sans and IBM Plex Mono | `opensquilla-webui/src/assets/fonts/ibm-plex-*.woff2` and generated Web UI font assets | SIL Open Font License 1.1. Copyright 2017 IBM Corp. with Reserved Font Name "Plex". |
| Space Grotesk | `opensquilla-webui/src/assets/fonts/space-grotesk-*.woff2` and generated Web UI font assets | SIL Open Font License 1.1. Copyright 2020 The Space Grotesk Project Authors. |
| Inter | `src/opensquilla/gateway/static/fonts/Inter-Variable.woff2` | SIL Open Font License 1.1. Copyright (c) 2016 The Inter Project Authors. |
| JetBrains Mono | `src/opensquilla/gateway/static/fonts/JetBrainsMono-Variable.woff2` | SIL Open Font License 1.1. Copyright 2020 The JetBrains Mono Project Authors. |
Current static vendor files are audited as distributed assets. Some manually
vendored files intentionally record their own upstream version in-file; for
example, the static `marked.min.js` header identifies marked 15.0.7,
`purify.min.js` identifies DOMPurify 3.2.5, and `katex.min.css` identifies
KaTeX 0.16.21. The Web UI npm lockfile may contain newer build-time dependency
versions. Future updates should either regenerate these files from npm or
record the exact vendored source and version here.
## Feather Icons
- Component: `music`, `pause`, and `volume` icon path data in
`opensquilla-webui/src/utils/icons.ts` and the generated Web UI assets.
- Upstream project: https://github.com/feathericons/feather
- License: MIT
- Copyright notice: Copyright (c) 2013-2017 Cole Bemis
```
MIT License
Copyright (c) 2013-2017 Cole Bemis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
The MIT license text is reproduced in this file for MIT-licensed bundled
components. The Apache License 2.0 text is reproduced in the repository
`LICENSE` file. The SIL Open Font License 1.1 text for the bundled fonts is
reproduced below.
```
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any Modified
Version, except to acknowledge the contribution(s) of the Copyright
Holder(s) and the Author(s) or with their explicit written permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to remain
under this license does not apply to any document created using the Font
Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
```
## Arctic theme color palette (Nord)
- Component: Web UI "Arctic" value theme color palette in
`opensquilla-webui/src/themes/arctic/tokens.css` (and its generated Web UI CSS
assets).
- Upstream project: https://www.nordtheme.com
- License: MIT
- Copyright notice: Copyright (c) Sven Greb and the Nord contributors
The "Arctic" theme's color values are adapted from the Nord palette. OpenSquilla
is not affiliated with, sponsored by, or endorsed by the Nord project; the
palette is reused under the MIT license solely for its color values, with a
matching attribution header reproduced at the top of the theme's `tokens.css`.
Some values are lightened from the upstream palette so the theme passes
OpenSquilla's WCAG contrast guards. The MIT license text is reproduced below.
```
MIT License
Copyright (c) Sven Greb and the Nord contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## npm and Python dependency packaging strategy
OpenSquilla uses npm lockfiles for the Web UI and Electron shell and `uv.lock`
for Python release environments. Lockfiles pin dependency resolution for
reproducible builds, but lockfiles are not a replacement for third-party license
notices when source, minified JavaScript, fonts, model files, or adapted code
are copied into OpenSquilla release artifacts.
Build-time npm dependencies are installed from the package registry during CI.
The Electron and gateway release artifacts distribute the compiled Control UI,
selected static assets, updater metadata, and packaged runtime outputs rather
than the full npm source tree. Any static npm-derived file copied into
`src/opensquilla/gateway/static/` must be listed in this notices file or in a
component-local notice file.
Python dependencies are resolved from `pyproject.toml` and `uv.lock` during
wheel, portable, and packaged-gateway builds. Python packages remain governed by
their upstream package metadata and licenses. If OpenSquilla vendors or adapts
Python package code, model files, rule files, binary assets, or generated
runtime artifacts into the repository, the component must be recorded here or
in a package-local provenance file.
## OpenClaw-derived bundled skill descriptors
- Component: SKILL.md frontmatter and instruction text for these bundled skills:
- `sub-agent`
- `cron`
- `github`
- `nano-pdf`
- `skill-creator`
- `summarize`
- `tmux`
- `weather`
- Upstream project: https://github.com/openclaw/openclaw
- License: MIT
- Copyright notice: Copyright (c) 2025 Peter Steinberger
Note: `sub-agent` was renamed from `coding-agent` on 2026-05-23; the
descriptor retains the same OpenClaw upstream lineage and MIT attribution.
The descriptor text instructs the agent runtime how to use built-in skill
surfaces and external tools; OpenSquilla does not redistribute third-party CLIs
through these descriptors. Per the MIT license, the upstream copyright and
permission notice are reproduced below in their entirety and apply to the
OpenClaw-derived bundled descriptor files.
```
MIT License
Copyright (c) 2025 Peter Steinberger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## OpenSquilla-original bundled skills
These bundled skill descriptors are authored and maintained by OpenSquilla and
are released under OpenSquilla's repository license (Apache-2.0; see `LICENSE`):
- `cron`
- `code-task`
- `AwesomeWebpageMetaSkill`
- `awesome-webpage-image-download`
- `awesome-webpage-research`
- `deep-research`
- `docx`
- `git-diff`
- `github`
- `history-explorer`
- `html-to-pdf`
- `http-fetch`
- `latex-compile`
- `memory`
- `meta-kid-project-planner`
- `meta-paper-write`
- `meta-short-drama`
- `meta-skill-creator`
- `multi-search-engine`
- `nano-pdf`
- `openrouter-video-generator`
- `paper-abstract-author`
- `paper-citation-planner`
- `paper-experiment-stub`
- `paper-outline-author`
- `paper-plot-stub`
- `paper-preference-planner`
- `paper-refbib-stub`
- `paper-revision-author`
- `paper-section-author`
- `paper-source-curator`
- `pdf-toolkit`
- `pptx`
- `skill-creator`
- `skill-creator-linter`
- `skill-creator-proposals`
- `skill-creator-smoke-test`
- `stack-trace-generic-probe`
- `stack-trace-go-probe`
- `stack-trace-js-probe`
- `stack-trace-python-probe`
- `swe-bench`
- `stack-trace-rust-probe`
- `sub-agent`
- `srt-from-script`
- `subtitle-burner`
- `summarize`
- `text-file-read`
- `title-card-image`
- `tmux`
- `video-still-animator`
- `weather`
- `xlsx`
- `advanced-dubbing-studio`
- `music-and-singing-studio`
- `voice-clone-lab`
- `voice-conversion-studio`
- `voiceover-studio`
## tokenjuice adapted reduction rules
- Component: built-in tokenjuice tool-result projection backend and bundled
reduction rules under `src/opensquilla/plugins/tokenjuice/`.
- Upstream project: https://github.com/vincentkoc/tokenjuice
- License: MIT
- Copyright notice: Copyright (c) 2026 Vincent Koc
OpenSquilla includes a Python adaptation of tokenjuice's rule-driven reducer
and bundles reduction rules derived from the upstream project. OpenSquilla does
not depend on the upstream tokenjuice npm package at runtime. Additional
provenance is recorded in
`src/opensquilla/plugins/tokenjuice/PROVENANCE.md`; the MIT license text is
also shipped with that package as `LICENSE.tokenjuice`.
```
MIT License
Copyright (c) 2026 Vincent Koc
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## Hermes Agent reference material
- Component: cron prompt-injection scanner reference material.
- Upstream project: https://github.com/NousResearch/hermes-agent
- License: MIT
- Copyright notice: Copyright (c) 2025 Nous Research
OpenSquilla does not redistribute Hermes Agent. This notice records conservative
attribution for reference material reviewed while hardening OpenSquilla's cron
prompt scanner.
```
MIT License
Copyright (c) 2025 Nous Research
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## ClawHub-derived bundled skill descriptors
- Component: SKILL.md frontmatter and instruction text for these bundled skills:
- `ai-video-script`
- `audio-cog`
- `deep-research`
- `docx`
- `html-coder`
- `html-to-pdf`
- `multi-search-engine`
- `nano-banana-pro`
- `nano-banana-pro-openrouter`
- `pdf-toolkit`
- `pptx`
- `seedance-2-prompt`
- `video-merger`
- `web-search`
- `xlsx`
- Upstream registry: https://clawhub.ai
- License: MIT-0 (Public-domain-equivalent; no attribution required, but
each skill records its specific upstream slug in its own
`THIRD_PARTY_NOTICES.md` for transparency)
These bundled skills record their ClawHub source slug in SKILL.md frontmatter
and, when present, the skill-local `THIRD_PARTY_NOTICES.md`. ClawHub's MIT-0
default license permits unlimited use, modification, and redistribution without
attribution.
```
MIT No Attribution
Copyright
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## ClawHub MIT bundled skill descriptors
- Component: SKILL.md frontmatter and instruction text for these bundled skills:
- `filesystem`
- Upstream registry: https://clawhub.ai
- Upstream package: https://clawhub.ai/gtrusler/clawdbot-filesystem
- License: MIT
- Copyright notice: Copyright (c) 2026 Clawdbot Community
The `filesystem` bundled skill metadata, package manifest, and skill card
identify this upstream artifact as MIT licensed. OpenSquilla excludes
skill-local `LICENSE.md` files from wheels as non-runtime skill resources, so
the required MIT notice for this copied descriptor is reproduced here in the
top-level notices distributed with release artifacts.
```
MIT License
Copyright (c) 2026 Clawdbot Community
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## BAAI bge-small-zh-v1.5 / FlagEmbedding
- Component: BAAI/bge-small-zh-v1.5 embedding model and tokenizer assets.
- Upstream model: https://huggingface.co/BAAI/bge-small-zh-v1.5
- Upstream project: https://github.com/FlagOpen/FlagEmbedding
- License: MIT
- Copyright notice: Copyright (c) 2022 staoxiao
The bundled router contains an ONNX export and tokenizer files derived from
the BAAI bge-small-zh-v1.5 model. The upstream Hugging Face model card marks
the model as MIT licensed and states that the released models can be used for
commercial purposes free of charge.
MIT License
Copyright (c) 2022 staoxiao
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## Router Artifact Safety Note
The SquillaRouter bundle contains `.pkl` and `.joblib` artifacts used by the
current V4 Phase 3 runtime. Treat these artifacts as executable-code-equivalent
inputs: load only assets shipped with a trusted OpenSquilla release or assets
whose checksums match `artifact_manifest.json`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

+45
View File
@@ -0,0 +1,45 @@
services:
gateway:
# Defaults to a self-built image; to use a prebuilt release image instead:
# OPENSQUILLA_GATEWAY_IMAGE=ghcr.io/opensquilla/opensquilla:latest docker compose up -d
# Full container guide: docs/docker.md
image: ${OPENSQUILLA_GATEWAY_IMAGE:-opensquilla:local}
environment:
# In-container bind address. Keep it 0.0.0.0 — Docker port publishing
# needs the wildcard bind, and what the network can actually reach is
# decided by `ports` below, not by this value.
OPENSQUILLA_LISTEN: "0.0.0.0"
# Administering a containerized gateway through the Web UI (/control/)
# requires token auth, even from this host. Enable it with:
# OPENSQUILLA_AUTH_MODE: token
# OPENSQUILLA_AUTH_TOKEN: ${OPENSQUILLA_AUTH_TOKEN:?generate one with openssl rand -hex 32}
# and put the value in a git-ignored .env next to this file.
# See docs/docker.md for the /control/?token=... login flow.
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
BRAVE_SEARCH_API_KEY: ${BRAVE_SEARCH_API_KEY:-}
TZ: ${TZ:-UTC}
volumes:
# Config, state, logs, and workspace persist in a Docker-managed
# named volume mounted at the image's OPENSQUILLA_STATE_DIR. The
# image pre-creates that path owned by the non-root container user,
# so persistence works consistently on Linux, macOS, Windows, and WSL2.
- opensquilla-state:/var/lib/opensquilla
ports:
# Published on the host loopback only: the gateway is reachable from
# this machine and invisible to the rest of the network. To reach the
# Web UI from other devices (home server / NAS), publish on all
# interfaces AND configure token auth above first:
# - "18791:18791"
# Exposure is controlled here — do not change OPENSQUILLA_LISTEN.
# Never forward this port to the internet; see docs/docker.md.
- "127.0.0.1:18791:18791"
healthcheck:
test: ["CMD-SHELL", "curl --fail --silent --show-error http://127.0.0.1:18791/healthz || exit 1"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
restart: unless-stopped
volumes:
opensquilla-state:
+4
View File
@@ -0,0 +1,4 @@
dist/
node_modules/
.pyinstaller/
runtime/
+91
View File
@@ -0,0 +1,91 @@
# OpenSquilla Electron Desktop Shell
This package is the macOS desktop shell for the existing OpenSquilla Control UI.
It does not rewrite the Vue frontend. The Electron main process configures a
desktop credential, starts a local gateway, and loads the backend-served
`/control/` app.
## Development Flow
From the repository root:
```bash
cd opensquilla-webui
npm run build
cd ../desktop/electron
npm install
npm run dev
```
On first run, the shell opens a setup window for provider, model, base URL, and
API key. The key is encrypted with Electron `safeStorage` when available, and a
desktop-specific gateway config is written under Electron `userData`.
The shell looks for the checkout root automatically. To point it at a different
checkout:
```bash
OPENSQUILLA_DESKTOP_REPO_ROOT=/path/to/opensquilla npm run dev
```
During development, the shell starts a gateway from the selected checkout by
default. To force a specific local port:
```bash
OPENSQUILLA_DESKTOP_GATEWAY_PORT=18793 npm run dev
```
To attach to an already-running gateway instead of spawning one:
```bash
OPENSQUILLA_DESKTOP_GATEWAY_URL=http://127.0.0.1:18791 npm run dev
```
## Local Release Build
```bash
cd desktop/electron
npm run dist:local
```
This builds the Vue Control UI, bundles the gateway with PyInstaller, and emits
desktop artifacts for the current platform under `dist/desktop-electron/`.
For a faster rebuild after the runtime already exists:
```bash
cd desktop/electron
npm run build:web
npm run dist
```
## Windows Release Signing
Windows release builds are currently unsigned. The release workflow builds the
NSIS installer with electron-builder and uploads the unsigned `.exe`,
`.blockmap`, and `latest.yml` artifacts together so updater metadata matches
the exact installer bytes.
Do not sign the `.exe` after `latest.yml` is emitted; that changes the
installer bytes and invalidates the updater hash. If Windows code signing is
enabled later, it must run inside the release build before updater metadata,
blockmaps, and `SHA256SUMS` are finalized. See
[`docs/code-signing-policy.md`](../../docs/code-signing-policy.md) for the
current policy.
## Current Scope
- Reuses `opensquilla-webui` and the Python gateway exactly as they run in the
browser.
- Starts a bundled `runtime/gateway/opensquilla-gateway` in packaged builds.
- Falls back to `uv run opensquilla gateway run --bind 127.0.0.1 --port <port>`
during development when no bundled runtime exists.
- Uses `contextIsolation: true`, `nodeIntegration: false`, and a minimal preload
bridge.
- Writes credential, config, state, and gateway logs under the Electron
`userData` directory.
## Release Work Still Needed
- Enable the runtime updater flow once the published feed is ready.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+3796
View File
File diff suppressed because it is too large Load Diff
+127
View File
@@ -0,0 +1,127 @@
{
"name": "@opensquilla/desktop-electron",
"version": "0.5.0-rc3",
"private": true,
"description": "Electron desktop shell for the OpenSquilla Control UI.",
"repository": {
"type": "git",
"url": "https://github.com/opensquilla/opensquilla.git"
},
"type": "module",
"main": "dist/main.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"build:gateway": "node scripts/build-gateway.mjs",
"build:web": "cd ../../opensquilla-webui && npm run build",
"dev": "npm run build && electron .",
"dist:local": "npm run build:web && npm run build:gateway && npm run dist",
"pack:local": "npm run build:web && npm run build:gateway && npm run pack",
"verify:icons": "node scripts/verify-icon-config.mjs",
"verify:package": "npm run verify:icons && node scripts/verify-package.mjs",
"verify:gateway-smoke": "node scripts/smoke-gateway.mjs",
"test:cli-invocation": "npm run build && node scripts/test-cli-invocation.mjs",
"test:secret-storage": "npm run build && node scripts/test-secret-storage-policy.mjs",
"test:update-resolver": "npm run build && node scripts/test-update-resolver.mjs",
"test:desktop-locale": "npm run build && node scripts/test-desktop-locale.mjs",
"test:profile-substrate": "npm run build && node scripts/test-desktop-profile-substrate.mjs && node scripts/test-desktop-profile-context.mjs",
"test:profile-recovery-flow": "npm run build && node scripts/test-profile-recovery-flow.mjs",
"test:profile-recovery-accessibility": "npm run build && node scripts/test-profile-recovery-accessibility.mjs",
"test:profile-import-flow": "npm run build && node scripts/test-profile-import-flow.mjs",
"test:unsafe-profile-no-write": "npm run build && node scripts/test-unsafe-profile-no-write.mjs",
"test:profile-recovery": "npm run build && node scripts/test-profile-recovery-flow.mjs && node scripts/test-profile-recovery-accessibility.mjs && node scripts/test-unsafe-profile-no-write.mjs",
"test:desktop-cleanup": "npm run build && node scripts/test-desktop-cleanup-contract.mjs",
"test:desktop-cleanup-flow": "npm run build && node scripts/test-desktop-cleanup-flow.mjs",
"test:mock-update-flow": "npm run build && node scripts/test-mock-update-flow.mjs",
"test:onboarding-flow": "npm run build && node scripts/test-onboarding-flow.mjs",
"start": "electron .",
"pack": "npm run build && electron-builder --dir",
"dist": "npm run build && electron-builder"
},
"dependencies": {
"electron-updater": "^6.6.2"
},
"devDependencies": {
"@types/node": "^25.9.1",
"electron": "^42.3.3",
"electron-builder": "^26.8.1",
"playwright": "^1.60.0",
"typescript": "^6.0.3"
},
"build": {
"appId": "ai.opensquilla.desktop",
"productName": "OpenSquilla",
"artifactName": "OpenSquilla-${version}-${os}-${arch}.${ext}",
"publish": [
{
"provider": "github",
"owner": "opensquilla",
"repo": "opensquilla"
}
],
"directories": {
"output": "../../dist/desktop-electron"
},
"files": [
"dist/**",
"assets/**",
"package.json"
],
"extraResources": [
{
"from": "src/boot.html",
"to": "boot.html"
},
{
"from": "runtime",
"to": "runtime",
"filter": [
"**/*"
]
}
],
"mac": {
"category": "public.app-category.developer-tools",
"icon": "assets/icon.icns",
"target": [
"dmg",
"zip"
]
},
"dmg": {
"icon": "assets/icon.icns",
"backgroundColor": "#ffffff",
"iconSize": 92,
"window": {
"width": 540,
"height": 380
},
"contents": [
{
"x": 170,
"y": 205,
"type": "file"
},
{
"x": 370,
"y": 205,
"type": "link",
"path": "/Applications"
}
]
},
"win": {
"icon": "assets/icon.ico",
"target": [
"nsis"
]
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"deleteAppDataOnUninstall": false,
"installerIcon": "assets/icon.ico",
"uninstallerIcon": "assets/icon.ico"
}
}
}
+317
View File
@@ -0,0 +1,317 @@
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
const scriptDir = dirname(fileURLToPath(import.meta.url))
const packageRoot = resolve(scriptDir, '..')
const repoRoot = resolve(packageRoot, '..', '..')
const runtimeGatewayDir = join(packageRoot, 'runtime', 'gateway')
const pyinstallerWorkDir = join(packageRoot, '.pyinstaller')
const entryPath = join(scriptDir, 'gateway-entry.py')
const controlUiDistDir = join(repoRoot, 'src', 'opensquilla', 'gateway', 'static', 'dist')
const routerBundleDir = join(repoRoot, 'src', 'opensquilla', 'squilla_router', 'models', 'v4.2_phase3_inference')
const addDataSeparator = process.platform === 'win32' ? ';' : ':'
const gitLfsPointerHeader = 'version https://git-lfs.github.com/spec/v1'
function findFilesByName(root, fileName) {
const matches = []
function walk(dir) {
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const path = join(dir, entry.name)
if (entry.isDirectory()) {
walk(path)
} else if (entry.isFile() && entry.name === fileName) {
matches.push(path)
}
}
}
walk(root)
return matches
}
function findMacLibomp() {
const candidates = []
candidates.push(...findFilesByName(runtimeGatewayDir, 'libomp.dylib'))
const brew = spawnSync('brew', ['--prefix', 'libomp'], {
encoding: 'utf8',
windowsHide: true,
})
if (brew.status === 0) {
const prefix = brew.stdout.trim()
if (prefix) candidates.push(join(prefix, 'lib', 'libomp.dylib'))
}
candidates.push(
'/opt/homebrew/opt/libomp/lib/libomp.dylib',
'/usr/local/opt/libomp/lib/libomp.dylib',
'/opt/local/lib/libomp/libomp.dylib',
)
return candidates.find((candidate) => existsSync(candidate))
}
function signMacBinary(path) {
if (process.platform !== 'darwin') return
const result = spawnSync('codesign', ['--force', '--sign', '-', path], {
encoding: 'utf8',
windowsHide: true,
})
if (result.error) throw result.error
if (result.status !== 0) {
throw new Error(`codesign failed for ${path} with exit ${result.status}:\n${result.stderr || result.stdout}`)
}
}
function pythonPackageFile(packageName, relativePath) {
const code = [
'import importlib.util',
'import pathlib',
'import sys',
`spec = importlib.util.find_spec(${JSON.stringify(packageName)})`,
'if spec is None or spec.origin is None:',
' sys.exit(1)',
`path = pathlib.Path(spec.origin).parent / ${JSON.stringify(relativePath)}`,
'if not path.exists():',
' sys.exit(2)',
'print(path)',
].join('\n')
const result = spawnSync(
'uv',
['run', '--extra', 'recommended', 'python', '-c', code],
{
cwd: repoRoot,
env: {
...process.env,
PYTHONUNBUFFERED: '1',
PYTHONUTF8: '1',
PYTHONIOENCODING: 'utf-8:replace',
},
encoding: 'utf8',
windowsHide: true,
},
)
if (result.error) throw result.error
if (result.status !== 0) {
throw new Error(
`Could not locate ${packageName}/${relativePath} for the desktop gateway bundle.\n${result.stderr || result.stdout}`,
)
}
const path = result.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1)
if (!path || !existsSync(path)) {
throw new Error(`Resolved ${packageName}/${relativePath} to an invalid path: ${path || '<empty>'}`)
}
return path
}
function addBinaryArg(sourcePath, destinationDir) {
return ['--add-binary', `${sourcePath}${addDataSeparator}${destinationDir}`]
}
function platformLightgbmLibraryPath() {
if (process.platform === 'win32') return join('bin', 'lib_lightgbm.dll')
if (process.platform === 'darwin') return join('lib', 'lib_lightgbm.dylib')
return join('lib', 'lib_lightgbm.so')
}
function platformLightgbmBundleDir() {
return process.platform === 'win32' ? 'lightgbm/bin' : 'lightgbm/lib'
}
function readFileHead(path, bytes = 96) {
return readFileSync(path).subarray(0, bytes).toString('utf8')
}
function assertRouterAssetsReady() {
const manifestPath = join(routerBundleDir, 'artifact_manifest.json')
if (!existsSync(manifestPath)) {
throw new Error(`Router artifact manifest not found at ${manifestPath}.`)
}
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
const problems = []
for (const file of manifest.files || []) {
const relPath = String(file.path || '')
if (!relPath) continue
const path = join(routerBundleDir, relPath)
if (!existsSync(path)) {
problems.push(`${relPath}: missing`)
continue
}
const actualSize = statSync(path).size
if (typeof file.size_bytes === 'number' && actualSize !== file.size_bytes) {
problems.push(`${relPath}: size ${actualSize} != manifest ${file.size_bytes}`)
continue
}
if (readFileHead(path).startsWith(gitLfsPointerHeader)) {
problems.push(`${relPath}: Git LFS pointer file, not the real router artifact`)
}
}
if (problems.length > 0) {
throw new Error(
[
'Router V4 Phase 3 assets are incomplete; refusing to build a desktop gateway that degrades routing.',
'Run `git lfs pull --include="src/opensquilla/squilla_router/models/v4.2_phase3_inference/**"` and rebuild.',
...problems.map((problem) => `- ${problem}`),
].join('\n'),
)
}
}
function patchMacLightgbmRuntime() {
if (process.platform !== 'darwin') return
const lightgbmLibs = findFilesByName(runtimeGatewayDir, 'lib_lightgbm.dylib')
if (lightgbmLibs.length === 0) {
throw new Error('LightGBM was requested for the desktop gateway, but lib_lightgbm.dylib was not bundled.')
}
const libomp = findMacLibomp()
if (!libomp) {
throw new Error(
'macOS LightGBM runtime requires libomp.dylib. Ensure scikit-learn is bundled, or install libomp on the build host, for example `brew install libomp`, then rebuild the desktop gateway.',
)
}
for (const lightgbmLib of lightgbmLibs) {
const bundledLibomp = join(dirname(lightgbmLib), 'libomp.dylib')
copyFileSync(libomp, bundledLibomp)
signMacBinary(bundledLibomp)
const result = spawnSync(
'install_name_tool',
['-change', '@rpath/libomp.dylib', '@loader_path/libomp.dylib', lightgbmLib],
{ encoding: 'utf8', windowsHide: true },
)
if (result.error) throw result.error
if (result.status !== 0) {
throw new Error(
`install_name_tool failed for ${lightgbmLib} with exit ${result.status}:\n${result.stderr || result.stdout}`,
)
}
signMacBinary(lightgbmLib)
}
}
if (!existsSync(join(controlUiDistDir, 'index.html'))) {
throw new Error(`Built Control UI not found at ${controlUiDistDir}. Run npm run build:web before npm run build:gateway.`)
}
assertRouterAssetsReady()
rmSync(runtimeGatewayDir, { recursive: true, force: true })
mkdirSync(runtimeGatewayDir, { recursive: true })
mkdirSync(pyinstallerWorkDir, { recursive: true })
const lightgbmBinaryArgs = addBinaryArg(
pythonPackageFile('lightgbm', platformLightgbmLibraryPath()),
platformLightgbmBundleDir(),
)
const macOpenMpBinaryArgs = process.platform === 'darwin'
? addBinaryArg(pythonPackageFile('sklearn', join('.dylibs', 'libomp.dylib')), '.')
: []
const args = [
'run',
'--extra',
'recommended',
'--extra',
'mcp',
'--extra',
'msg',
'--extra',
'matrix',
'--extra',
'document-extras',
'--with',
'pyinstaller',
'pyinstaller',
'--noconfirm',
'--clean',
'--onedir',
'--name',
'opensquilla-gateway',
'--distpath',
runtimeGatewayDir,
'--workpath',
pyinstallerWorkDir,
'--specpath',
pyinstallerWorkDir,
'--collect-all',
'opensquilla',
'--collect-all',
'sqlite_vec',
'--collect-binaries',
'sklearn',
'--copy-metadata',
'opensquilla',
'--copy-metadata',
'scikit-learn',
'--copy-metadata',
'lightgbm',
'--copy-metadata',
'yoyo-migrations',
'--hidden-import',
'joblib',
'--hidden-import',
'sklearn',
'--hidden-import',
'sklearn.feature_extraction.text',
'--hidden-import',
'sklearn.decomposition._truncated_svd',
'--hidden-import',
'sklearn.decomposition._pca',
'--hidden-import',
'sklearn.preprocessing._data',
'--hidden-import',
'lightgbm',
'--hidden-import',
'tokenizers',
'--hidden-import',
'tiktoken',
'--hidden-import',
'onnxruntime',
'--hidden-import',
'mcp',
'--hidden-import',
'yoyo.backends.core.sqlite3',
'--add-data',
`${join(repoRoot, 'migrations')}${addDataSeparator}opensquilla/_migrations`,
'--add-data',
`${controlUiDistDir}${addDataSeparator}opensquilla/gateway/static/dist`,
...lightgbmBinaryArgs,
...macOpenMpBinaryArgs,
entryPath,
]
const result = spawnSync('uv', args, {
cwd: repoRoot,
env: {
...process.env,
PYTHONUNBUFFERED: '1',
PYTHONUTF8: '1',
PYTHONIOENCODING: 'utf-8:replace',
},
stdio: 'inherit',
})
if (result.error) {
throw result.error
}
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
patchMacLightgbmRuntime()
@@ -0,0 +1,4 @@
from opensquilla.cli.main import app
if __name__ == "__main__":
app()
+391
View File
@@ -0,0 +1,391 @@
import { spawn } from 'node:child_process'
import { createServer } from 'node:net'
import { mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises'
import { statSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import process from 'node:process'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
const scriptDir = dirname(fileURLToPath(import.meta.url))
const packageRoot = resolve(scriptDir, '..')
const repoRoot = resolve(packageRoot, '..', '..')
const desktopOutputDir = join(repoRoot, 'dist', 'desktop-electron')
const sourceRuntimeGatewayDir = join(packageRoot, 'runtime', 'gateway')
const binaryName = process.platform === 'win32' ? 'opensquilla-gateway.exe' : 'opensquilla-gateway'
const deadlineMs = Number.parseInt(process.env.OPENSQUILLA_GATEWAY_SMOKE_TIMEOUT_MS || '90000', 10)
const pollIntervalMs = 250
const killGraceMs = 3_000
const maxTailLines = 80
function appendTail(tail, chunk) {
const lines = chunk
.toString()
.split(/\r?\n/)
.filter((line) => line.length > 0)
if (lines.length === 0) return tail
return [...tail, ...lines].slice(-maxTailLines)
}
function formatTail(stdoutTail, stderrTail) {
const parts = []
if (stdoutTail.length > 0) parts.push(`stdout tail:\n${stdoutTail.join('\n')}`)
if (stderrTail.length > 0) parts.push(`stderr tail:\n${stderrTail.join('\n')}`)
return parts.length > 0 ? `\n\n${parts.join('\n\n')}` : ''
}
function pathIsFile(path) {
try {
return statSync(path).isFile()
} catch {
return false
}
}
function pathIsDirectory(path) {
try {
return statSync(path).isDirectory()
} catch {
return false
}
}
function gatewayBinaryCandidates(runtimeGatewayDir) {
return [join(runtimeGatewayDir, 'opensquilla-gateway', binaryName), join(runtimeGatewayDir, binaryName)]
}
function findGatewayBinary(runtimeGatewayDir) {
return gatewayBinaryCandidates(runtimeGatewayDir).find(pathIsFile)
}
async function findGeneratedBundleRuntimes(root) {
const runtimes = []
const seenResourcesDirs = new Set()
if (!pathIsDirectory(root)) return runtimes
function addRuntime(label, resourcesDir, platform) {
if (platform !== process.platform || seenResourcesDirs.has(resourcesDir)) return
seenResourcesDirs.add(resourcesDir)
runtimes.push({
label,
runtimeGatewayDir: join(resourcesDir, 'runtime', 'gateway'),
})
}
async function walk(dir, depth) {
if (depth > 5) return
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
for (const entry of entries) {
if (!entry.isDirectory()) continue
const path = join(dir, entry.name)
if (entry.name.endsWith('.app')) {
addRuntime(`generated app bundle ${path}`, join(path, 'Contents', 'Resources'), 'darwin')
} else if (entry.name === 'win-unpacked' || entry.name === 'linux-unpacked') {
addRuntime(`generated bundle ${path}`, join(path, 'resources'), entry.name === 'win-unpacked' ? 'win32' : 'linux')
} else {
await walk(path, depth + 1)
}
}
}
await walk(root, 0)
return runtimes.sort((left, right) => left.runtimeGatewayDir.localeCompare(right.runtimeGatewayDir))
}
async function selectRuntimeGateway() {
const generatedRuntimes = await findGeneratedBundleRuntimes(desktopOutputDir)
if (generatedRuntimes.length > 0) {
const selected = generatedRuntimes[0]
if (generatedRuntimes.length > 1) {
console.log(`Found ${generatedRuntimes.length} generated bundle runtimes; selecting first sorted path.`)
for (const runtime of generatedRuntimes) console.log(`- ${runtime.runtimeGatewayDir}`)
}
console.log(`Smoking packaged gateway runtime from ${selected.label}: ${selected.runtimeGatewayDir}`)
return selected.runtimeGatewayDir
}
if (process.env.OPENSQUILLA_REQUIRE_PACKAGED_GATEWAY_SMOKE === '1') {
throw new Error(`No current-platform generated Electron bundle runtime found under ${desktopOutputDir}.`)
}
console.log(`No current-platform generated Electron bundle runtime found under ${desktopOutputDir}; falling back to source runtime ${sourceRuntimeGatewayDir}.`)
return sourceRuntimeGatewayDir
}
function smokeEnv(tempHome, stateDir, config) {
const env = {}
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith('OPENSQUILLA_')) continue
env[key] = value
}
return {
...env,
HOME: tempHome,
USERPROFILE: tempHome,
OPENSQUILLA_DESKTOP: '1',
OPENSQUILLA_INSTALL_METHOD: 'desktop',
OPENSQUILLA_STATE_DIR: stateDir,
OPENSQUILLA_GATEWAY_CONFIG_PATH: config,
PYTHONUNBUFFERED: '1',
PYTHONUTF8: '1',
PYTHONIOENCODING: 'utf-8:replace',
}
}
async function findFreePort() {
return await new Promise((resolvePort, reject) => {
const server = createServer()
server.unref()
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') {
server.close(() => reject(new Error('Could not determine an available loopback port.')))
return
}
const { port } = address
server.close((error) => {
if (error) reject(error)
else resolvePort(port)
})
})
})
}
async function sleep(ms) {
await new Promise((resolveSleep) => setTimeout(resolveSleep, ms))
}
function bodySnippet(body) {
return body.length > 300 ? `${body.slice(0, 300)}...` : body
}
async function healthCheck(url) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(1_000) })
const body = await response.text()
if (!response.ok) {
return { ok: false, detail: `${url} returned HTTP ${response.status}: ${bodySnippet(body)}` }
}
let payload
try {
payload = JSON.parse(body)
} catch (error) {
return {
ok: false,
detail: `${url} returned HTTP ${response.status} with non-JSON body: ${bodySnippet(body)} (${error instanceof Error ? error.message : String(error)})`,
}
}
if (payload?.ok === true) return { ok: true, detail: '' }
return { ok: false, detail: `${url} returned JSON without ok=true: ${bodySnippet(body)}` }
} catch (error) {
return { ok: false, detail: `${url} request failed: ${error instanceof Error ? error.message : String(error)}` }
}
}
async function fetchText(url) {
const response = await fetch(url, { signal: AbortSignal.timeout(2_000) })
const body = await response.text()
if (!response.ok) {
throw new Error(`${url} returned HTTP ${response.status}: ${bodySnippet(body)}`)
}
return body
}
function controlAssetUrls(html, baseUrl) {
const urls = []
for (const match of html.matchAll(/<script\b[^>]*\btype="module"[^>]*\bsrc="([^"]+)"/g)) {
urls.push(new URL(match[1], baseUrl).toString())
}
for (const match of html.matchAll(/<link\b[^>]*\brel="stylesheet"[^>]*\bhref="([^"]+)"/g)) {
urls.push(new URL(match[1], baseUrl).toString())
}
return urls
}
async function verifyControlUi(port, stdoutTail, stderrTail) {
const controlUrl = `http://127.0.0.1:${port}/control/`
const html = await fetchText(controlUrl)
const assetUrls = controlAssetUrls(html, controlUrl)
const hasModule = assetUrls.some((url) => url.includes('/static/dist/') && url.endsWith('.js'))
const hasStylesheet = assetUrls.some((url) => url.includes('/static/dist/') && url.endsWith('.css'))
if (!hasModule || !hasStylesheet) {
throw new Error(
`${controlUrl} did not inject Vite JS/CSS assets from /static/dist/. ` +
`Found assets: ${assetUrls.length > 0 ? assetUrls.join(', ') : '(none)'}.` +
formatTail(stdoutTail, stderrTail)
)
}
for (const url of assetUrls.filter((assetUrl) => assetUrl.includes('/static/dist/'))) {
await fetchText(url)
}
}
async function waitForGateway(port, childExit, stdoutTail, stderrTail) {
const deadline = Date.now() + deadlineMs
const healthzUrl = `http://127.0.0.1:${port}/healthz`
const healthUrl = `http://127.0.0.1:${port}/health`
let lastHealthFailure = ''
while (Date.now() < deadline) {
if (childExit.value) {
const { code, signal } = childExit.value
throw new Error(
`Gateway exited before becoming healthy (code=${code ?? 'null'} signal=${signal ?? 'null'}).` +
formatTail(stdoutTail, stderrTail)
)
}
const healthz = await healthCheck(healthzUrl)
if (healthz.ok) return
const health = await healthCheck(healthUrl)
if (health.ok) return
lastHealthFailure = `${healthz.detail}; ${health.detail}`
await sleep(pollIntervalMs)
}
const detail = lastHealthFailure ? ` Last health failure: ${lastHealthFailure}` : ''
throw new Error(`Timed out after ${deadlineMs / 1000}s waiting for ${healthzUrl} or ${healthUrl}.${detail}` + formatTail(stdoutTail, stderrTail))
}
async function terminateChild(child, childClosed) {
if (childClosed.value) return
await new Promise((resolveTerminate) => {
let settled = false
let forceTimer = null
let abandonTimer = null
function finish() {
if (settled) return
settled = true
if (forceTimer) clearTimeout(forceTimer)
if (abandonTimer) clearTimeout(abandonTimer)
resolveTerminate()
}
if (process.platform !== 'win32') child.once('close', finish)
if (child.exitCode === null && child.signalCode === null) {
if (process.platform === 'win32' && child.pid) {
const finishAfterTaskkill = () => {
if (childClosed.value || child.exitCode !== null || child.signalCode !== null) {
finish()
return
}
abandonTimer = setTimeout(finish, 1_000)
}
const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
stdio: 'ignore',
windowsHide: true,
})
const taskkillTimer = setTimeout(() => {
console.warn(`taskkill timed out while terminating gateway process ${child.pid}.`)
killer.kill()
finishAfterTaskkill()
}, killGraceMs)
killer.once('error', (error) => {
clearTimeout(taskkillTimer)
console.warn(`taskkill failed while terminating gateway process ${child.pid}: ${error.message}`)
finishAfterTaskkill()
})
killer.once('close', (code, signal) => {
clearTimeout(taskkillTimer)
if (code !== 0) {
console.warn(`taskkill exited while terminating gateway process ${child.pid} with code=${code ?? 'null'} signal=${signal ?? 'null'}.`)
}
finishAfterTaskkill()
})
} else if (process.platform === 'win32') {
console.warn('Gateway process had no PID for taskkill fallback.')
child.kill()
} else {
child.kill('SIGTERM')
}
}
forceTimer = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
if (process.platform !== 'win32') {
child.kill('SIGKILL')
abandonTimer = setTimeout(finish, 1_000)
} else {
finish()
}
} else {
finish()
}
}, killGraceMs)
})
}
async function main() {
const runtimeGatewayDir = await selectRuntimeGateway()
const candidates = gatewayBinaryCandidates(runtimeGatewayDir)
const gatewayBinary = findGatewayBinary(runtimeGatewayDir)
if (!gatewayBinary) {
throw new Error(
`Packaged gateway binary is missing. Checked: ${candidates.join(', ')}. Run npm run build:gateway first; release CI should run this after electron-builder.`
)
}
const tempHome = await mkdtemp(join(tmpdir(), 'opensquilla-gateway-smoke-'))
const config = join(tempHome, 'config.toml')
const stateDir = join(tempHome, 'state')
let child = null
const stdoutTail = []
const stderrTail = []
const childExit = { value: null }
const childClosed = { value: false }
try {
await mkdir(stateDir, { recursive: true })
await writeFile(
config,
[
'[auth]',
'mode = "none"',
'',
].join('\n'),
'utf8'
)
const port = await findFreePort()
child = spawn(gatewayBinary, ['gateway', 'run', '--port', String(port), '--bind', '127.0.0.1', '--config', config], {
cwd: dirname(gatewayBinary),
env: smokeEnv(tempHome, stateDir, config),
windowsHide: true,
})
child.stdout.on('data', (chunk) => {
stdoutTail.splice(0, stdoutTail.length, ...appendTail(stdoutTail, chunk))
})
child.stderr.on('data', (chunk) => {
stderrTail.splice(0, stderrTail.length, ...appendTail(stderrTail, chunk))
})
child.once('close', (code, signal) => {
childClosed.value = true
childExit.value = { code, signal }
})
child.once('error', (error) => {
childExit.value = { code: null, signal: `spawn error: ${error.message}` }
})
await waitForGateway(port, childExit, stdoutTail, stderrTail)
await verifyControlUi(port, stdoutTail, stderrTail)
console.log('OpenSquilla packaged gateway smoke passed.')
} finally {
if (child) await terminateChild(child, childClosed)
await rm(tempHome, { recursive: true, force: true })
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
})
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict'
import { buildCliInvocation } from '../dist/cli-invocation.js'
// --- bundled posix: env pair + quoted binary, spaces survive quoting ---
{
const result = buildCliInvocation({
platform: 'darwin',
mode: 'bundled',
binaryPath: '/Applications/OpenSquilla.app/Contents/Resources/runtime/gateway/opensquilla-gateway/opensquilla-gateway',
// OPENSQUILLA_STATE_DIR is the OpenSquilla home root; runtime databases
// remain under the config-pinned <home>/state directory.
stateDir: '/opt/OpenSquilla Data',
configPath: '/opt/OpenSquilla Data/config.toml',
})
assert.equal(result.mode, 'bundled')
assert.equal(
result.prefix,
"OPENSQUILLA_STATE_DIR='/opt/OpenSquilla Data' "
+ "OPENSQUILLA_GATEWAY_CONFIG_PATH='/opt/OpenSquilla Data/config.toml' "
+ "'/Applications/OpenSquilla.app/Contents/Resources/runtime/gateway/opensquilla-gateway/opensquilla-gateway'",
)
}
// --- posix: single quotes inside paths get the '\'' escape ---
{
const result = buildCliInvocation({
platform: 'linux',
mode: 'bundled',
binaryPath: "/opt/o'brien apps/opensquilla-gateway",
stateDir: "/opt/o'brien data",
configPath: "/opt/o'brien data/config.toml",
})
assert.ok(result.prefix.includes("'/opt/o'\\''brien apps/opensquilla-gateway'"))
assert.ok(result.prefix.includes("OPENSQUILLA_STATE_DIR='/opt/o'\\''brien data'"))
}
// --- windows: PowerShell $env: syntax, '' doubling, & call operator ---
{
const result = buildCliInvocation({
platform: 'win32',
mode: 'bundled',
binaryPath: 'C:\\Program Files\\OpenSquilla\\resources\\runtime\\gateway\\opensquilla-gateway.exe',
stateDir: "C:\\Users\\o'brien\\AppData\\Roaming\\OpenSquilla\\opensquilla",
configPath: 'C:\\Users\\jo\\AppData\\Roaming\\OpenSquilla\\opensquilla\\config.toml',
})
assert.ok(result.prefix.startsWith("$env:OPENSQUILLA_STATE_DIR = 'C:\\Users\\o''brien\\AppData"))
assert.ok(result.prefix.includes("$env:OPENSQUILLA_GATEWAY_CONFIG_PATH = 'C:\\Users\\jo\\AppData"))
assert.ok(result.prefix.includes("& 'C:\\Program Files\\OpenSquilla\\resources\\runtime\\gateway\\opensquilla-gateway.exe'"))
}
// --- windows: unicode smart quotes are single-quote delimiters in PowerShell ---
{
const result = buildCliInvocation({
platform: 'win32',
mode: 'bundled',
binaryPath: 'C:\\Apps\\OpenSquilla\\opensquilla-gateway.exe',
stateDir: 'C:\\Users\\OBrien\\AppData\\Roaming\\OpenSquilla\\opensquilla',
configPath: 'C:\\Users\\OBrien\\AppData\\Roaming\\OpenSquilla\\opensquilla\\config.toml',
})
assert.ok(result.prefix.includes("$env:OPENSQUILLA_STATE_DIR = 'C:\\Users\\O’’Brien\\AppData"))
assert.ok(result.prefix.includes("$env:OPENSQUILLA_GATEWAY_CONFIG_PATH = 'C:\\Users\\O’’Brien\\AppData"))
}
// --- windows dev mode: PowerShell env syntax composes with the uv runner ---
{
const result = buildCliInvocation({
platform: 'win32',
mode: 'dev',
repoRoot: 'C:\\Dev Projects\\opensquilla',
stateDir: 'C:\\Users\\jo\\AppData\\Roaming\\OpenSquilla\\opensquilla',
configPath: 'C:\\Users\\jo\\AppData\\Roaming\\OpenSquilla\\opensquilla\\config.toml',
})
assert.equal(result.mode, 'dev')
assert.ok(result.prefix.startsWith("$env:OPENSQUILLA_STATE_DIR = 'C:\\Users\\jo\\AppData"))
assert.ok(result.prefix.endsWith("uv run --directory 'C:\\Dev Projects\\opensquilla' opensquilla"))
}
// --- dev mode: uv run with an explicit checkout directory, no cwd dependence ---
{
const result = buildCliInvocation({
platform: 'darwin',
mode: 'dev',
repoRoot: '/opt/dev projects/opensquilla',
stateDir: '/opt/OpenSquilla Data',
configPath: '/opt/OpenSquilla Data/config.toml',
})
assert.equal(result.mode, 'dev')
assert.ok(result.prefix.endsWith("uv run --directory '/opt/dev projects/opensquilla' opensquilla"))
}
console.log('cli-invocation: all assertions passed')
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict'
import {
cleanupSelectorArgs,
DesktopCleanupPreviewStore,
desktopCleanupScopeIsContained,
parseDesktopCleanupMode,
parseDesktopCleanupProtocol,
sameDesktopCleanupScope,
} from '../dist/desktop-cleanup.js'
const report = parseDesktopCleanupProtocol({
schema_version: 1,
outcome: 'ready',
stable_code: 'cleanup_ready',
mode: 'delete-current-profile',
items: [{
kind: 'primary-home',
path: '/synthetic/user-data/opensquilla',
exists: true,
identity: '1:2',
}],
transaction_id: 'synthetic-transaction',
revision: 42,
scope_fingerprint: 'a'.repeat(64),
})
assert.equal(parseDesktopCleanupMode('reset-current-settings'), 'reset-current-settings')
assert.equal(parseDesktopCleanupMode('delete-current-profile'), 'delete-current-profile')
assert.equal(parseDesktopCleanupMode('delete-all-user-data'), 'delete-all-user-data')
assert.equal(parseDesktopCleanupMode('purge'), null)
assert.equal(report.items[0].identity, '1:2')
assert.equal(desktopCleanupScopeIsContained(report, '/synthetic/user-data'), true)
assert.equal(desktopCleanupScopeIsContained({
...report,
items: [{ ...report.items[0], path: '/synthetic/outside' }],
}, '/synthetic/user-data'), false)
assert.equal(desktopCleanupScopeIsContained({
...report,
items: [{ ...report.items[0], path: '/synthetic/user-data/..cache' }],
}, '/synthetic/user-data'), true)
assert.equal(sameDesktopCleanupScope(report, {
...report,
revision: 99,
items: report.items.map((item) => ({ ...item, identity: '3:4' })),
}, '/synthetic/user-data'), true, 'content metadata may change while the gateway stops')
assert.equal(sameDesktopCleanupScope(report, {
...report,
items: [...report.items, {
kind: 'new-user-data-entry',
path: '/synthetic/user-data/new-entry',
exists: true,
identity: '5:6',
}],
}, '/synthetic/user-data'), false, 'new paths require a new visible inventory')
assert.throws(
() => parseDesktopCleanupProtocol({ ...report, schema_version: 2 }),
/unsupported protocol schema/,
)
assert.throws(
() => parseDesktopCleanupProtocol({ ...report, revision: Number.MAX_SAFE_INTEGER + 1 }),
/invalid revision/,
)
assert.throws(
() => parseDesktopCleanupProtocol({ ...report, scope_fingerprint: 'not-a-digest' }),
/invalid scope fingerprint/,
)
assert.throws(
() => parseDesktopCleanupProtocol({ ...report, items: [{ path: '/unbound' }] }),
/invalid inventory item/,
)
const primarySelection = {
mode: 'delete-current-profile',
profileKind: 'primary',
recoveryId: null,
profileKey: 'primary',
}
assert.deepEqual(cleanupSelectorArgs(primarySelection), [
'--mode', 'delete-current-profile',
'--profile-kind', 'primary',
])
const recoverySelection = {
mode: 'reset-current-settings',
profileKind: 'recovery',
recoveryId: '01234567-89ab-4cde-8fab-0123456789ab',
profileKey: 'recovery:01234567-89ab-4cde-8fab-0123456789ab',
}
assert.deepEqual(cleanupSelectorArgs(recoverySelection), [
'--mode', 'reset-current-settings',
'--profile-kind', 'recovery',
'--recovery-id', recoverySelection.recoveryId,
])
const store = new DesktopCleanupPreviewStore(1_000)
const preview = store.create(report, primarySelection, 100)
assert.equal(
store.consume(preview.id, 'recovery:other', 200),
null,
'a preview must be bound to the exact active profile',
)
assert.equal(
store.consume(preview.id, 'primary', 200),
null,
'a rejected preview is consumed and cannot be replayed',
)
const fresh = store.create(report, primarySelection, 100)
assert.equal(store.consume(fresh.id, 'primary', 1_101), null, 'expired previews fail closed')
const discardable = store.create(report, primarySelection, 100)
assert.equal(store.discard(discardable.id, 'recovery:other'), false)
assert.equal(
store.consume(discardable.id, 'primary', 200)?.id,
discardable.id,
'a stale renderer cannot discard another profile preview',
)
const explicitlyDiscarded = store.create(report, primarySelection, 100)
assert.equal(store.discard(explicitlyDiscarded.id, 'primary'), true)
assert.equal(
store.consume(explicitlyDiscarded.id, 'primary', 200),
null,
'cancelled previews cannot be replayed',
)
const accepted = store.create(report, primarySelection, 100)
assert.equal(
store.consume(accepted.id, 'primary', 200)?.report.transaction_id,
'synthetic-transaction',
)
assert.equal(store.consume(accepted.id, 'primary', 201), null, 'previews are one-shot')
console.log('desktop cleanup contract checks passed')
@@ -0,0 +1,274 @@
import { strict as assert } from 'node:assert'
import {
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
realpath,
rm,
writeFile,
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { fileURLToPath } from 'node:url'
import { _electron as electron } from 'playwright'
const scriptDir = dirname(fileURLToPath(import.meta.url))
const packageRoot = resolve(scriptDir, '..')
const repoRoot = resolve(packageRoot, '../..')
const RECOVERY_ID = '31234567-89ab-4cde-8fab-0123456789ab'
async function exists(path) {
try {
await lstat(path)
return true
} catch (error) {
if (error?.code === 'ENOENT') return false
throw error
}
}
async function waitFor(check, label, timeoutMs = 90_000) {
const startedAt = Date.now()
let lastError
while (Date.now() - startedAt < timeoutMs) {
try {
const value = await check()
if (value) return value
} catch (error) {
lastError = error
}
await delay(250)
}
throw new Error(`Timed out waiting for ${label}: ${lastError?.message || lastError || ''}`)
}
function launchEnvironment(isolatedHome) {
const inherited = { ...process.env }
for (const name of Object.keys(inherited)) {
const upperName = name.toUpperCase()
if (
upperName.startsWith('OPENSQUILLA_')
|| ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY'].includes(upperName)
|| /(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH)/i.test(name)
) delete inherited[name]
}
return {
...inherited,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
OPENSQUILLA_DESKTOP_REPO_ROOT: repoRoot,
OPENSQUILLA_DESKTOP_SECRET_STORAGE: 'plain',
OPENSQUILLA_USER_STATE_DIR: join(isolatedHome, 'user-state'),
OPENSQUILLA_TEST_PROFILE_LOCK_ROOT: '1',
OPENSQUILLA_DESKTOP_GATEWAY_PORT: '18895',
OPENSQUILLA_DESKTOP_DISABLE_AUTO_UPDATE: '1',
OPENSQUILLA_OPENROUTER_LIVE_PRICING: '0',
OPENSQUILLA_GATEWAY_WORKSPACE_DIR: '',
OPENSQUILLA_WORKSPACE_DIR: '',
OPENSQUILLA_GATEWAY_STATE_DIR: '',
HTTP_PROXY: 'http://127.0.0.1:1',
HTTPS_PROXY: 'http://127.0.0.1:1',
ALL_PROXY: 'http://127.0.0.1:1',
NO_PROXY: '127.0.0.1,localhost',
}
}
const root = await realpath(await mkdtemp(join(tmpdir(), 'opensquilla-cleanup-e2e-')))
const userData = join(root, 'user-data')
const isolatedHome = join(root, 'home')
const primaryHome = join(userData, 'opensquilla')
const workspace = join(primaryHome, 'workspace')
const state = join(primaryHome, 'state')
const recoveryRoot = join(userData, 'recovery-profiles', RECOVERY_ID)
const recoveryHome = join(recoveryRoot, 'opensquilla')
const recoveryMarker = join(recoveryHome, 'keep-recovery-profile.txt')
const cleanupJournal = join(userData, '.opensquilla.profile-cleanup.json')
let desktopApp
try {
await mkdir(workspace, { recursive: true })
await mkdir(state, { recursive: true })
await mkdir(recoveryHome, { recursive: true })
await mkdir(isolatedHome, { recursive: true })
await writeFile(join(workspace, 'IDENTITY.md'), 'synthetic cleanup identity\n', 'utf8')
await writeFile(join(state, 'sessions.db.keep'), 'synthetic chat state\n', 'utf8')
await writeFile(recoveryMarker, 'must remain\n', 'utf8')
await writeFile(
join(primaryHome, 'config.toml'),
`workspace_dir = ${JSON.stringify(join(root, 'missing-workspace'))}\n`,
'utf8',
)
await writeFile(join(userData, 'desktop-locale'), 'en', 'utf8')
await writeFile(cleanupJournal, 'synthetic interrupted cleanup authority\n', 'utf8')
await writeFile(join(userData, 'desktop-profile-context.json'), `${JSON.stringify({
schema_version: 1,
active_profile_kind: 'primary',
active_recovery_id: null,
attention_acknowledgement: null,
updated_at: '2026-07-13T00:00:00.000Z',
}, null, 2)}\n`, 'utf8')
desktopApp = await electron.launch({
args: ['--use-mock-keychain', `--user-data-dir=${userData}`, packageRoot],
env: launchEnvironment(isolatedHome),
})
const page = await waitFor(async () => {
for (const candidate of desktopApp.windows()) {
if (candidate.isClosed()) continue
if (await candidate.locator('#recoveryPanel.visible').count().catch(() => 0)) {
return candidate
}
}
return null
}, 'cleanup test recovery page')
// Keep production confirmation intact; cancel the first click and accept the
// second so the real recovery-page button proves its busy state is cleared.
await desktopApp.evaluate(({ dialog }) => {
let cleanupDialogCount = 0
dialog.showMessageBox = async () => ({
response: cleanupDialogCount++ === 0 ? 0 : 1,
checkboxChecked: false,
})
})
const identityBeforeAbandon = await readFile(join(workspace, 'IDENTITY.md'))
const stateBeforeAbandon = await readFile(join(state, 'sessions.db.keep'))
const abandonButton = page.locator('#abandonCleanup')
await abandonButton.click()
await waitFor(async () => (
await abandonButton.isVisible() && await abandonButton.isEnabled()
), 'abandon button to recover after native-dialog cancellation')
assert.equal(await exists(cleanupJournal), true, 'cancel must preserve the cleanup journal')
await abandonButton.click()
await waitFor(async () => !await exists(cleanupJournal), 'cleanup journal abandonment')
await waitFor(async () => (
await page.locator('#recoveryCode').textContent() === 'effective_workspace_missing'
), 'post-abandon recovery inspection')
assert.equal(await page.locator('#cleanupAbandonGroup').isHidden(), true)
assert((await readdir(userData)).some((name) => (
name.startsWith('.opensquilla.profile-cleanup.abandoned.') && name.endsWith('.json')
)))
assert.deepEqual(await readFile(join(workspace, 'IDENTITY.md')), identityBeforeAbandon)
assert.deepEqual(await readFile(join(state, 'sessions.db.keep')), stateBeforeAbandon)
const preview = await page.evaluate(() => (
window.opensquillaDesktop.inspectDesktopCleanup({ mode: 'delete-current-profile' })
))
assert.equal(preview.ok, true)
assert.equal(preview.report.mode, 'delete-current-profile')
assert(preview.report.items.some((item) => (
item.kind === 'primary-home' && resolve(item.path) === resolve(primaryHome)
)), JSON.stringify(preview.report.items))
assert(preview.report.items.some((item) => item.kind === 'primary-logs'))
const process = desktopApp.process()
const exited = new Promise((resolveExit) => process.once('exit', resolveExit))
void page.evaluate((previewId) => (
window.opensquillaDesktop.applyDesktopCleanup({
previewId,
acknowledged: true,
confirmation: '',
})
), preview.previewId).catch(() => {})
await Promise.race([
exited,
delay(30_000).then(() => {
throw new Error('Desktop did not exit after deleting the current profile.')
}),
])
assert.equal(await exists(primaryHome), false)
assert.equal(await exists(join(userData, 'desktop-credential.json')), false)
assert.equal(await exists(join(userData, 'desktop-profile-context.json')), false)
assert.equal(await exists(join(userData, 'logs')), false, 'app.exit must not recreate desktop logs')
assert.equal(await readFile(recoveryMarker, 'utf8'), 'must remain\n')
// Recreate a synthetic primary profile, then verify delete-all is handed to
// the detached offline helper and does not begin until this second Electron
// process has actually exited.
desktopApp = null
await mkdir(workspace, { recursive: true })
await mkdir(state, { recursive: true })
await writeFile(join(workspace, 'IDENTITY.md'), 'synthetic delete-all identity\n', 'utf8')
await writeFile(
join(primaryHome, 'config.toml'),
`workspace_dir = ${JSON.stringify(join(root, 'still-missing-workspace'))}\n`,
'utf8',
)
await writeFile(join(userData, 'desktop-locale'), 'en', 'utf8')
await writeFile(join(userData, 'desktop-profile-context.json'), `${JSON.stringify({
schema_version: 1,
active_profile_kind: 'primary',
active_recovery_id: null,
attention_acknowledgement: null,
updated_at: '2026-07-13T00:00:01.000Z',
}, null, 2)}\n`, 'utf8')
await delay(200)
desktopApp = await electron.launch({
args: ['--use-mock-keychain', `--user-data-dir=${userData}`, packageRoot],
env: launchEnvironment(isolatedHome),
})
const deleteAllPage = await waitFor(async () => {
for (const candidate of desktopApp.windows()) {
if (candidate.isClosed()) continue
if (await candidate.locator('#recoveryPanel.visible').count().catch(() => 0)) {
return candidate
}
}
return null
}, 'delete-all test recovery page')
await desktopApp.evaluate(({ dialog }) => {
dialog.showMessageBox = async () => ({ response: 1, checkboxChecked: false })
})
const deleteAllPreview = await deleteAllPage.evaluate(() => (
window.opensquillaDesktop.inspectDesktopCleanup({ mode: 'delete-all-user-data' })
))
assert.equal(deleteAllPreview.ok, true)
assert.equal(await exists(primaryHome), true, 'inspection must remain read-only')
assert.equal(await exists(recoveryRoot), true, 'inspection must not touch recovery data')
const deleteAllProcess = desktopApp.process()
const deleteAllExited = new Promise((resolveExit) => deleteAllProcess.once('exit', resolveExit))
void deleteAllPage.evaluate((previewId) => (
window.opensquillaDesktop.applyDesktopCleanup({
previewId,
acknowledged: true,
confirmation: 'DELETE ALL OPENSQUILLA DATA',
})
), deleteAllPreview.previewId).catch(() => {})
await Promise.race([
deleteAllExited,
delay(30_000).then(() => {
throw new Error('Desktop did not exit before the delete-all helper handoff.')
}),
])
await waitFor(async () => (
!await exists(primaryHome)
&& !await exists(recoveryRoot)
&& !await exists(join(userData, 'logs'))
), 'post-exit delete-all helper completion', 30_000)
console.log(JSON.stringify({
ok: true,
trustedPreviewVerified: true,
postStopReinspectionVerified: true,
currentProfileDeleted: true,
recoveryProfilePreserved: true,
noLogWritebackAfterDelete: true,
abandonCleanupVerified: true,
deleteAllStartedAfterExit: true,
allProfilesDeletedByOfflineHelper: true,
}))
} catch (error) {
const desktopLog = await readFile(join(userData, 'logs', 'desktop.log'), 'utf8').catch(() => '')
if (desktopLog) console.error(desktopLog)
throw error
} finally {
await desktopApp?.close().catch(() => {})
await rm(root, { recursive: true, force: true }).catch(() => {})
}
@@ -0,0 +1,57 @@
import assert from 'node:assert/strict'
import { resolveLocaleFromTags } from '../dist/desktop-locale.js'
// --- preference order: first bundled match wins ---
// Regression: en-* is bundled and must match in place. Before the fix a Hong
// Kong system list [en-HK, zh-Hans-HK, zh-Hant-HK, fr-HK] resolved to 'fr' —
// the top two preferences both fell through and the fourth won.
assert.equal(resolveLocaleFromTags(['en-HK', 'zh-Hans-HK', 'zh-Hant-HK', 'fr-HK']), 'en')
assert.equal(resolveLocaleFromTags(['en', 'ja']), 'en')
assert.equal(resolveLocaleFromTags(['en_US']), 'en')
assert.equal(resolveLocaleFromTags(['ja-JP', 'en-US']), 'ja')
// --- Chinese: explicit script subtag wins over region ---
// Regression: zh-Hans with a Traditional-default region is still Simplified.
assert.equal(resolveLocaleFromTags(['zh-Hans-HK']), 'zh-Hans')
assert.equal(resolveLocaleFromTags(['zh-Hans-TW']), 'zh-Hans')
assert.equal(resolveLocaleFromTags(['zh-Hans-MO']), 'zh-Hans')
assert.equal(resolveLocaleFromTags(['zh-CN']), 'zh-Hans')
assert.equal(resolveLocaleFromTags(['zh']), 'zh-Hans')
assert.equal(resolveLocaleFromTags(['zh-Hans-CN']), 'zh-Hans')
assert.equal(resolveLocaleFromTags(['zh_Hans_HK']), 'zh-Hans')
// Traditional variants (explicit Hant, or bare Traditional-default regions)
// skip to the next preference rather than forcing Simplified text.
assert.equal(resolveLocaleFromTags(['zh-Hant']), 'en')
assert.equal(resolveLocaleFromTags(['zh-Hant-HK', 'ja-JP']), 'ja')
assert.equal(resolveLocaleFromTags(['zh-TW']), 'en')
assert.equal(resolveLocaleFromTags(['zh-HK', 'fr-FR']), 'fr')
assert.equal(resolveLocaleFromTags(['zh-MO', 'de']), 'de')
assert.equal(resolveLocaleFromTags(['zh_HK', 'fr-FR']), 'fr')
// Script detection is structural, never a substring match against extensions
// or private-use subtags.
assert.equal(resolveLocaleFromTags(['zh-Hant-x-hans', 'ja-JP']), 'ja')
assert.equal(resolveLocaleFromTags(['zh-CN-x-hant', 'fr-FR']), 'zh-Hans')
// --- other bundled languages and fallback ---
assert.equal(resolveLocaleFromTags(['fr-CA']), 'fr')
assert.equal(resolveLocaleFromTags(['fr_CA']), 'fr')
assert.equal(resolveLocaleFromTags(['de-AT']), 'de')
assert.equal(resolveLocaleFromTags(['es-419']), 'es')
// Unsupported languages skip to the next preference; nothing matches → 'en'.
assert.equal(resolveLocaleFromTags(['ko-KR', 'es-ES']), 'es')
assert.equal(resolveLocaleFromTags(['ko-KR', 'th-TH']), 'en')
assert.equal(resolveLocaleFromTags([]), 'en')
// Malformed entries (Electron APIs can surface non-strings) are skipped.
assert.equal(resolveLocaleFromTags([undefined, null, 42, 'ja']), 'ja')
assert.equal(resolveLocaleFromTags(['en--US', 'ja']), 'ja')
assert.equal(resolveLocaleFromTags(['zh-Hans-Hant', 'fr']), 'fr')
assert.equal(resolveLocaleFromTags(['zha', 'ja']), 'ja')
assert.equal(resolveLocaleFromTags(['zh!', 'de']), 'de')
console.log('desktop-locale resolution tests passed')
@@ -0,0 +1,227 @@
import assert from 'node:assert/strict'
import {
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
allProfileContexts,
contextForProfile,
desktopProfileContextPath,
loadDesktopProfileContext,
persistDesktopProfileContextFile,
primaryProfilePaths,
profileKindEnvironment,
serializeDesktopProfileContext,
updateDesktopProfileContextFile,
} from '../dist/desktop-profile-context.js'
const root = mkdtempSync(join(tmpdir(), 'opensquilla-profile-context-'))
try {
const primary = primaryProfilePaths(root)
assert.equal(primary.home, join(root, 'opensquilla'))
assert.equal(primary.credentialPath, join(root, 'desktop-credential.json'))
assert.equal(primary.logsDir, join(root, 'logs'))
assert.equal(profileKindEnvironment('primary'), 'desktop-primary')
assert.equal(profileKindEnvironment('recovery'), 'desktop-recovery')
assert.equal(loadDesktopProfileContext(root).issue, null, 'a genuinely missing context is a fresh primary')
const recoveryId = '01234567-89ab-4cde-8fab-0123456789ab'
const recovery = contextForProfile(root, 'recovery', recoveryId)
mkdirSync(recovery.active.home, { recursive: true })
await persistDesktopProfileContextFile(root, recovery)
if (process.platform !== 'win32') {
assert.equal(
statSync(desktopProfileContextPath(root)).mode & 0o077,
0,
'context permissions must not grant group/other access',
)
}
assert.equal(
readdirSync(root).some((entry) => entry.includes('desktop-profile-context.json.') && entry.endsWith('.tmp')),
false,
'durable writes must not leave a temp file behind',
)
const loaded = loadDesktopProfileContext(root)
assert.equal(loaded.issue, null)
assert.equal(loaded.active.kind, 'recovery')
assert.equal(loaded.active.recoveryId, recoveryId)
assert.equal(loaded.active.home, join(root, 'recovery-profiles', recoveryId, 'opensquilla'))
assert.equal(loaded.active.credentialPath, join(root, 'recovery-profiles', recoveryId, 'desktop-credential.json'))
assert.equal(loaded.active.logsDir, join(root, 'recovery-profiles', recoveryId, 'logs'))
const profiles = allProfileContexts(root)
assert.deepEqual(profiles.map((profile) => profile.kind), ['primary', 'recovery'])
const concurrentRecoveryId = '31234567-89ab-4cde-8fab-0123456789ab'
mkdirSync(contextForProfile(root, 'recovery', concurrentRecoveryId).active.home, {
recursive: true,
})
const acknowledgement = {
stable_code: 'workspace_conflict',
candidates: [{
path: join(root, 'opensquilla', 'workspace'),
identity: '1:2',
modified_at_ns: 123,
}],
}
await Promise.all([
updateDesktopProfileContextFile(root, async (current) => {
await new Promise((resolve) => setTimeout(resolve, 20))
return contextForProfile(
root,
'recovery',
concurrentRecoveryId,
new Date().toISOString(),
current.persisted.attention_acknowledgement,
)
}),
updateDesktopProfileContextFile(root, (current) => contextForProfile(
root,
current.active.kind,
current.active.recoveryId,
new Date().toISOString(),
acknowledgement,
)),
])
const concurrentlyUpdated = loadDesktopProfileContext(root)
assert.equal(concurrentlyUpdated.active.recoveryId, concurrentRecoveryId)
assert.deepEqual(
concurrentlyUpdated.persisted.attention_acknowledgement,
acknowledgement,
'serialized read-modify-write must preserve both concurrent decisions',
)
let releaseLockedUpdate = () => {}
let markLockedUpdateStarted = () => {}
const lockedUpdateMayFinish = new Promise((resolve) => {
releaseLockedUpdate = resolve
})
const lockedUpdateStarted = new Promise((resolve) => {
markLockedUpdateStarted = resolve
})
const lockedUpdate = updateDesktopProfileContextFile(root, async (current) => {
markLockedUpdateStarted()
await lockedUpdateMayFinish
return contextForProfile(
root,
current.active.kind,
current.active.recoveryId,
new Date().toISOString(),
current.persisted.attention_acknowledgement,
)
})
await lockedUpdateStarted
let directPersistSettled = false
const directPersist = persistDesktopProfileContextFile(
root,
contextForProfile(root, 'primary'),
).then(() => {
directPersistSettled = true
})
await new Promise((resolve) => setTimeout(resolve, 50))
assert.equal(
directPersistSettled,
false,
'direct persistence must join the updater lock instead of publishing mid-transaction',
)
assert.equal(
loadDesktopProfileContext(root).active.recoveryId,
concurrentRecoveryId,
'a queued direct persistence must leave the updater snapshot untouched',
)
releaseLockedUpdate()
await lockedUpdate
await directPersist
assert.equal(loadDesktopProfileContext(root).active.kind, 'primary')
const externalChoice = contextForProfile(root, 'primary')
await assert.rejects(
updateDesktopProfileContextFile(root, async (current) => {
writeFileSync(
desktopProfileContextPath(root),
serializeDesktopProfileContext(externalChoice),
'utf8',
)
return contextForProfile(
root,
current.active.kind,
current.active.recoveryId,
new Date().toISOString(),
acknowledgement,
)
}),
/changed while it was being updated/,
)
assert.equal(
loadDesktopProfileContext(root).active.kind,
'primary',
'CAS rejection must preserve the external writer\'s newer choice',
)
const linkedId = '11234567-89ab-4cde-8fab-0123456789ab'
const outside = join(root, 'outside')
mkdirSync(outside)
const profileCountBeforeLinkedRoot = allProfileContexts(root).length
symlinkSync(outside, join(root, 'recovery-profiles', linkedId))
assert.equal(
allProfileContexts(root).length,
profileCountBeforeLinkedRoot,
'linked recovery roots must be ignored',
)
writeFileSync(desktopProfileContextPath(root), serializeDesktopProfileContext(
contextForProfile(root, 'recovery', linkedId),
), 'utf8')
assert.equal(
loadDesktopProfileContext(root).issue,
'desktop_selected_recovery_profile_unsafe',
'a selected linked recovery root must never be activated',
)
const corrupt = '{truncated'
writeFileSync(desktopProfileContextPath(root), corrupt, 'utf8')
const corruptLoaded = loadDesktopProfileContext(root)
assert.equal(corruptLoaded.active.kind, 'primary')
assert.equal(corruptLoaded.issue, 'desktop_profile_context_corrupt')
assert.equal(readFileSync(desktopProfileContextPath(root), 'utf8'), corrupt, 'inspection preserves corrupt context')
writeFileSync(desktopProfileContextPath(root), JSON.stringify({
schema_version: 2,
active_profile_kind: 'primary',
active_recovery_id: null,
attention_acknowledgement: null,
updated_at: new Date().toISOString(),
}), 'utf8')
assert.equal(
loadDesktopProfileContext(root).issue,
'desktop_profile_context_schema_too_new',
)
const missingId = '21234567-89ab-4cde-8fab-0123456789ab'
writeFileSync(desktopProfileContextPath(root), serializeDesktopProfileContext(
contextForProfile(root, 'recovery', missingId),
), 'utf8')
const missingLoaded = loadDesktopProfileContext(root)
assert.equal(missingLoaded.active.kind, 'recovery', 'the vanished selection is not silently changed')
assert.equal(missingLoaded.active.recoveryId, missingId)
assert.equal(missingLoaded.issue, 'desktop_selected_recovery_profile_missing')
// An explicit user choice is the only thing that replaces an invalid
// context. The durable write then clears the blocked selection state.
await persistDesktopProfileContextFile(root, contextForProfile(root, 'primary'))
assert.equal(loadDesktopProfileContext(root).issue, null)
assert.equal(loadDesktopProfileContext(root).active.kind, 'primary')
} finally {
rmSync(root, { recursive: true, force: true })
}
console.log('desktop profile context checks passed')
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict'
import { DesktopContextLock } from '../dist/desktop-context-lock.js'
import { DesktopWriterAdmission } from '../dist/desktop-writer-admission.js'
const writers = new DesktopWriterAdmission()
const finishExistingWriter = writers.begin('existing recovery writer')
const lifecycleOwner = writers.close('apply downloaded update')
assert.equal(writers.closed, true)
assert.equal(writers.hasOwner(lifecycleOwner), true)
assert.throws(
() => writers.begin('late context writer'),
/writer admission is closed/,
)
let drained = false
const drain = writers.waitForAtMost(0).then(() => {
drained = true
})
await Promise.resolve()
assert.equal(drained, false, 'lifecycle operation must wait for the active writer')
finishExistingWriter()
finishExistingWriter()
await drain
assert.equal(writers.activeCount, 0, 'writer completion must be idempotent')
assert.equal(writers.reopen(Symbol('unrelated owner')), false)
assert.equal(writers.closed, true, 'an unrelated owner must not reopen admission')
assert.equal(writers.reopen(lifecycleOwner), true)
assert.equal(writers.closed, false)
assert.throws(() => writers.waitForAtMost(-1), /non-negative integer/)
const exclusive = writers.tryBeginExclusive('recovery selection')
assert(exclusive, 'exclusive admission must atomically close and reserve a writer')
assert.equal(writers.closed, true)
assert.equal(writers.activeCount, 1)
assert.equal(writers.tryBeginExclusive('second recovery selection'), null)
exclusive.finish()
writers.reopen(exclusive.admissionToken)
assert.equal(writers.closed, false)
assert.equal(writers.activeCount, 0)
const contextLock = new DesktopContextLock()
let releaseFirst = () => {}
let markFirstStarted = () => {}
const firstMayFinish = new Promise((resolve) => {
releaseFirst = resolve
})
const firstStarted = new Promise((resolve) => {
markFirstStarted = resolve
})
const order = []
const first = contextLock.runExclusive('profile-context', async () => {
order.push('first-start')
markFirstStarted()
await firstMayFinish
order.push('first-end')
})
const second = contextLock.runExclusive('profile-context', () => {
order.push('second')
})
await firstStarted
assert.deepEqual(order, ['first-start'], 'same-key operations must not overlap')
releaseFirst()
await Promise.all([first, second])
assert.deepEqual(order, ['first-start', 'first-end', 'second'])
await assert.rejects(
contextLock.runExclusive('reentrant', () => contextLock.runExclusive('reentrant', () => {})),
/cannot be re-entered/,
)
await assert.rejects(
contextLock.runExclusive('failure-recovery', () => {
throw new Error('synthetic failure')
}),
/synthetic failure/,
)
await contextLock.runExclusive('failure-recovery', () => {
order.push('recovered')
})
assert.equal(order.at(-1), 'recovered', 'a rejected operation must not poison the queue')
console.log('desktop profile substrate checks passed')
@@ -0,0 +1,190 @@
import { strict as assert } from 'node:assert'
import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { fileURLToPath } from 'node:url'
import { _electron as electron } from 'playwright'
const scriptDir = dirname(fileURLToPath(import.meta.url))
const packageRoot = resolve(scriptDir, '..')
const repoRoot = resolve(packageRoot, '../..')
const mockVersion = process.env.OPENSQUILLA_DESKTOP_MOCK_UPDATE_VERSION || '99.0.0'
const relaunchLabels = ['Relaunch to Update', '重启以更新']
async function waitFor(check, label, timeoutMs = 45_000) {
const startedAt = Date.now()
let lastError
while (Date.now() - startedAt < timeoutMs) {
try {
const value = await check()
if (value) return value
} catch (error) {
lastError = error
}
await delay(250)
}
const suffix = lastError ? ` Last error: ${lastError.message || lastError}` : ''
throw new Error(`Timed out waiting for ${label}.${suffix}`)
}
async function menuLabels(app) {
return await app.evaluate(({ Menu }) => {
const menu = Menu.getApplicationMenu()
const labels = []
function walk(items) {
for (const item of items || []) {
if (item.label) labels.push(item.label)
if (item.submenu) walk(item.submenu.items)
}
}
if (menu) walk(menu.items)
return labels
})
}
async function clickRelaunchToUpdate(app) {
return await app.evaluate(({ BrowserWindow, Menu }, labels) => {
const menu = Menu.getApplicationMenu()
function find(items) {
for (const item of items || []) {
if (item.label && labels.includes(item.label)) return item
const child = item.submenu ? find(item.submenu.items) : null
if (child) return child
}
return null
}
const item = menu ? find(menu.items) : null
if (!item || typeof item.click !== 'function') return false
item.click(undefined, BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0], undefined)
return true
}, relaunchLabels)
}
const isolationRoot = await mkdtemp(join(tmpdir(), 'opensquilla-electron-mock-update-test-'))
const userDataDir = join(isolationRoot, 'chromium-user-data')
const isolatedHome = join(isolationRoot, 'home')
let app
try {
await mkdir(userDataDir, { recursive: true })
await mkdir(isolatedHome, { recursive: true })
// Seed a keyless, entirely synthetic profile so this update test reaches the
// Control UI without depending on a developer's real desktop credential.
const now = new Date().toISOString()
await writeFile(join(userDataDir, 'desktop-credential.json'), JSON.stringify({
provider: 'ollama',
model: 'opensquilla-update-test-model',
baseUrl: 'http://127.0.0.1:11434',
apiKeyEnv: '',
encryptedApiKey: '',
modelRoutingMode: 'direct',
routerMode: 'disabled',
routerDefaultTier: 'c1',
routerTiers: {},
searchProvider: 'duckduckgo',
searchApiKeyEnv: '',
encryptedSearchApiKey: '',
encryption: 'plain',
disableNetworkObservability: false,
createdAt: now,
updatedAt: now,
}, null, 2), { mode: 0o600 })
app = await electron.launch({
args: [
'--use-mock-keychain',
`--user-data-dir=${userDataDir}`,
packageRoot,
],
env: {
...process.env,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
OPENSQUILLA_DESKTOP_REPO_ROOT: repoRoot,
OPENSQUILLA_DESKTOP_SECRET_STORAGE: 'plain',
OPENSQUILLA_DESKTOP_MOCK_UPDATE_VERSION: mockVersion,
// mock install: OK. Availability/download now stay in renderer state.
OPENSQUILLA_DESKTOP_MOCK_UPDATE_DIALOG_RESPONSES: '0',
},
})
const runtimeIsolation = await app.evaluate(({ app: electronApp }) => ({
userData: electronApp.getPath('userData'),
home: process.env.HOME,
userProfile: process.env.USERPROFILE,
}))
assert.equal(await realpath(runtimeIsolation.userData), await realpath(userDataDir))
assert.equal(resolve(runtimeIsolation.home), resolve(isolatedHome))
assert.equal(resolve(runtimeIsolation.userProfile), resolve(isolatedHome))
const page = await app.firstWindow({ timeout: 60_000 })
await page.waitForLoadState('domcontentloaded', { timeout: 60_000 }).catch(() => {})
await waitFor(
async () => page.url().includes('/control/chat'),
'Control UI to load on Chat',
60_000,
)
const nativeAutoUpdateEnabled = await page.evaluate(
() => window.opensquillaDesktop.isAutoUpdateEnabled(),
)
assert.equal(nativeAutoUpdateEnabled, true, 'mock update should enable native update bridge')
const updateBannerCount = await page.locator('[data-testid="update-banner"]').count()
assert.equal(updateBannerCount, 0, 'desktop native update should suppress the web release banner')
const availableState = await waitFor(async () => {
return await page.evaluate(async () => {
const api = window.opensquillaDesktop
if (!api.getUpdateState) return null
const state = await api.getUpdateState()
return state?.status === 'available' ? state : null
})
}, 'mock update available renderer state')
assert.equal(availableState.latestVersion, mockVersion)
await page.locator('[data-testid="desktop-update-indicator"]').waitFor({ state: 'visible', timeout: 30_000 })
await page.locator('[data-testid="desktop-update-indicator"]').click()
await page.locator('[data-testid="desktop-update-download"]').click()
const downloadedState = await waitFor(async () => {
return await page.evaluate(async () => {
const state = await window.opensquillaDesktop.getUpdateState()
return state?.status === 'downloaded' ? state : null
})
}, 'mock update downloaded renderer state')
assert.equal(downloadedState.latestVersion, mockVersion)
const relaunchLabel = await waitFor(async () => {
const labels = await menuLabels(app)
return labels.find((label) => relaunchLabels.includes(label))
}, 'Relaunch to Update menu item')
assert.ok(relaunchLabel, 'pending mock update should expose relaunch menu item')
const clicked = await clickRelaunchToUpdate(app)
assert.equal(clicked, true, 'Relaunch to Update menu item should be clickable')
await delay(500)
assert.equal(page.isClosed(), false, 'mock install should not quit the app')
assert.match(await page.title(), /OpenSquilla/, 'Control UI should remain available after mock install')
const labelsAfterClick = await menuLabels(app)
assert.ok(
labelsAfterClick.some((label) => relaunchLabels.includes(label)),
'mock install keeps the pending relaunch menu available for repeated inspection',
)
console.log(JSON.stringify({
ok: true,
version: mockVersion,
updateState: downloadedState.status,
relaunchLabel,
url: page.url(),
title: await page.title(),
}, null, 2))
} finally {
await app?.close().catch(() => {})
await rm(isolationRoot, { recursive: true, force: true }).catch(() => {})
}
@@ -0,0 +1,278 @@
import { strict as assert } from 'node:assert'
import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { fileURLToPath } from 'node:url'
import { _electron as electron } from 'playwright'
const scriptDir = dirname(fileURLToPath(import.meta.url))
const packageRoot = resolve(scriptDir, '..')
const repoRoot = resolve(packageRoot, '../..')
const screenshotPath = String(process.env.OPENSQUILLA_DESKTOP_ONBOARDING_SCREENSHOT || '').trim()
async function waitFor(check, label, timeoutMs = 60_000) {
const startedAt = Date.now()
let lastError
while (Date.now() - startedAt < timeoutMs) {
try {
const value = await check()
if (value) return value
} catch (error) {
lastError = error
}
await delay(250)
}
const suffix = lastError ? ` Last error: ${lastError.message || lastError}` : ''
throw new Error(`Timed out waiting for ${label}.${suffix}`)
}
async function setupWindow(app) {
return await waitFor(async () => {
for (const page of app.windows()) {
if (page.isClosed()) continue
await page.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => {})
const hasSetupForm = await page.locator('#setup-form').count().catch(() => 0)
if (hasSetupForm > 0) return page
}
return null
}, 'desktop onboarding window')
}
const userDataRoot = await mkdtemp(join(tmpdir(), 'opensquilla-electron-onboarding-test-'))
const userDataDir = join(userDataRoot, 'chromium-user-data')
const isolatedHome = join(userDataRoot, 'home')
await mkdir(isolatedHome, { recursive: true })
const app = await electron.launch({
args: [
'--use-mock-keychain',
`--user-data-dir=${userDataDir}`,
packageRoot,
],
env: {
...process.env,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
OPENSQUILLA_DESKTOP_REPO_ROOT: repoRoot,
OPENSQUILLA_DESKTOP_SECRET_STORAGE: 'plain',
OPENSQUILLA_DESKTOP_GATEWAY_PORT: '18897',
OPENSQUILLA_DESKTOP_DISABLE_AUTO_UPDATE: '1',
OPENSQUILLA_DESKTOP_MOCK_UPDATE_VERSION: '',
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
},
})
try {
const page = await setupWindow(app)
await page.locator('#onboardingLocale').selectOption('zh-Hans')
assert.equal(await page.evaluate(() => document.documentElement.lang), 'zh-Hans')
assert.equal(await page.locator('[data-screen="0"] h2').innerText(), '选择设置深度')
assert.equal(await page.title(), '设置 OpenSquilla')
assert.doesNotMatch(await page.locator('[data-setup-mode="advanced"]').innerText(), /Smart Router mode/)
await page.locator('[data-setup-mode="advanced"]').click()
await page.locator('[data-screen="0"].active .next-button').click()
await page.locator('[data-screen="1"].active').waitFor({ state: 'visible', timeout: 10_000 })
assert.equal(await page.locator('[data-step-label="2"]').count(), 1, 'advanced setup should expose the routing-mode progress step')
assert.equal(await page.locator('#provider').inputValue(), 'tokenrhythm')
assert.equal(await page.locator('#baseUrl').inputValue(), 'https://tokenrhythm.studio/v1')
assert.equal(await page.locator('#model').inputValue(), 'deepseek-v4-pro')
assert.equal(await page.locator('#modelRoutingMode').inputValue(), 'squilla_router')
assert.equal(await page.locator('#routerMode').inputValue(), 'recommended')
const tokenRhythmFeature = page.locator('[data-provider-feature="tokenrhythm"]')
assert.equal(await tokenRhythmFeature.count(), 1)
assert.equal(await tokenRhythmFeature.locator('[data-tokenrhythm-title]').innerText(), '推荐使用 TokenRhythm')
assert.equal(
await tokenRhythmFeature.locator('[data-tokenrhythm-value]').innerText(),
'TokenRhythm API 调用限时免费。',
)
assert.equal(
await tokenRhythmFeature.locator('[data-tokenrhythm-registration]').innerText(),
'活动期间,注册并获取 API Key,即可免费调用 DeepSeek、GLM、MiniMax、Kimi 等主流模型。',
)
const tokenRhythmCta = tokenRhythmFeature.locator('#tokenrhythmRegister')
assert.equal(await tokenRhythmCta.innerText(), '注册并获取 API Key')
assert.equal(await tokenRhythmCta.getAttribute('href'), 'https://tokenrhythm.studio/register')
assert.equal(await tokenRhythmCta.getAttribute('target'), '_blank')
assert.equal(await tokenRhythmCta.getAttribute('rel'), 'noopener noreferrer')
assert.equal(
await tokenRhythmCta.getAttribute('aria-label'),
'注册并获取 API Key — TokenRhythm(在外部浏览器中打开)',
)
assert.equal(await tokenRhythmFeature.locator('img, svg, canvas').count(), 0)
assert.equal(await tokenRhythmFeature.locator('[data-provider="tokenrhythm"]').getAttribute('aria-pressed'), 'true')
const providerMoreToggle = page.locator('#providerMoreToggle')
const providerMorePanel = page.locator('#providerMorePanel')
assert.equal(await providerMoreToggle.getAttribute('aria-expanded'), 'false')
assert.equal(await providerMoreToggle.getAttribute('aria-controls'), 'providerMorePanel')
assert.equal(await providerMorePanel.isHidden(), true)
await page.locator('#onboardingLocale').selectOption('en')
assert.equal(await page.evaluate(() => document.documentElement.lang), 'en')
assert.equal(await page.locator('[data-screen="1"] h2').innerText(), 'Connect a provider')
assert.equal(await page.locator('#provider').inputValue(), 'tokenrhythm', 'locale changes should preserve the selected provider')
assert.equal(await tokenRhythmFeature.locator('[data-tokenrhythm-title]').innerText(), 'Recommended: TokenRhythm')
assert.equal(
await tokenRhythmFeature.locator('[data-tokenrhythm-value]').innerText(),
'TokenRhythm API calls are free for a limited time.',
)
assert.equal(
await tokenRhythmFeature.locator('[data-tokenrhythm-registration]').innerText(),
'During the promotion, register and get an API key to call DeepSeek, GLM, MiniMax, Kimi, and other leading models for free.',
)
assert.equal(await tokenRhythmCta.innerText(), 'Register and get an API key')
assert.equal(
await tokenRhythmCta.getAttribute('aria-label'),
'Register and get an API key — TokenRhythm (opens in external browser)',
)
await providerMoreToggle.click()
assert.equal(await providerMoreToggle.getAttribute('aria-expanded'), 'true')
assert.equal(await providerMorePanel.isVisible(), true)
const openRouterProvider = page.locator('#providerGrid [data-provider="openrouter"]')
await openRouterProvider.click()
assert.equal(await page.locator('#provider').inputValue(), 'openrouter')
assert.equal(await openRouterProvider.getAttribute('aria-pressed'), 'true')
assert.equal(await tokenRhythmFeature.locator('[data-provider="tokenrhythm"]').getAttribute('aria-pressed'), 'false')
await page.locator('#onboardingLocale').selectOption('zh-Hans')
assert.equal(await page.locator('#provider').inputValue(), 'openrouter', 'locale changes should preserve another provider selection')
await page.locator('#onboardingLocale').selectOption('en')
assert.equal(await page.locator('#provider').inputValue(), 'openrouter')
await tokenRhythmFeature.locator('[data-provider="tokenrhythm"]').click()
assert.equal(await page.locator('#provider').inputValue(), 'tokenrhythm', 'TokenRhythm should remain re-selectable')
assert.equal(await page.locator('#modelRoutingMode').inputValue(), 'squilla_router')
assert.equal(await tokenRhythmFeature.locator('[data-provider="tokenrhythm"]').getAttribute('aria-pressed'), 'true')
await page.locator('#onboardingLocale').selectOption('zh-Hans')
await page.locator('#apiKey').fill('synthetic-tokenrhythm-key')
await page.locator('[data-screen="1"].active .next-button').click()
await page.locator('[data-screen="2"].active').waitFor({ state: 'visible', timeout: 10_000 })
await page.waitForTimeout(300)
assert.equal(await page.locator('[data-screen="2"] h2').innerText(), '选择路由模式')
assert.equal(await page.locator('[data-model-routing-mode="squilla_router"]').isEnabled(), true)
assert.equal(await page.locator('[data-model-routing-mode="direct"]').isEnabled(), true)
assert.equal(await page.locator('[data-model-routing-mode="llm_ensemble"]').isEnabled(), true)
assert.equal(await page.locator('#modelRoutingMode').inputValue(), 'squilla_router')
assert.match(
await page.locator('[data-model-routing-mode="squilla_router"] small').innerText(),
/此提供商现有的 Squilla Router 层级默认值/,
)
assert.match(
await page.locator('[data-model-routing-mode="llm_ensemble"] small').innerText(),
/当前提供商的 static B5 Ensemble/,
)
if (screenshotPath) {
await mkdir(dirname(screenshotPath), { recursive: true })
await page.screenshot({ path: screenshotPath })
}
await page.locator('[data-screen="2"].active .next-button').click()
await page.locator('[data-screen="3"].active').waitFor({ state: 'visible', timeout: 10_000 })
assert.equal(await page.locator('[data-screen="3"] h2').innerText(), '候选模型池')
const tokenRhythmTierText = await page.locator('#tierBody').innerText()
for (const modelId of ['deepseek-v4-flash', 'deepseek-v4-pro', 'kimi-k2.7-code', 'glm-5.2', 'kimi-k2.6']) {
assert.match(tokenRhythmTierText, new RegExp(modelId.replaceAll('.', '\\.')))
}
await page.locator('[data-screen="3"].active .back-button').click()
await page.locator('[data-screen="2"].active').waitFor({ state: 'visible', timeout: 5_000 })
await page.locator('[data-model-routing-mode="direct"]').click()
assert.equal(await page.locator('#modelRoutingMode').inputValue(), 'direct')
assert.equal(await page.locator('#directModelRoute').inputValue(), 'deepseek-v4-pro')
await page.locator('#directModelRoute').fill('glm-5.2')
assert.equal(await page.locator('#model').inputValue(), 'glm-5.2')
await page.locator('[data-model-routing-mode="llm_ensemble"]').click()
assert.equal(await page.locator('#modelRoutingMode').inputValue(), 'llm_ensemble')
await page.locator('[data-screen="2"].active .next-button').click()
await page.locator('[data-screen="4"].active').waitFor({ state: 'visible', timeout: 10_000 })
await page.locator('[data-screen="4"].active .back-button').click()
await page.locator('[data-screen="2"].active').waitFor({ state: 'visible', timeout: 5_000 })
await page.locator('[data-screen="2"].active .back-button').click()
await page.locator('[data-screen="1"].active').waitFor({ state: 'visible', timeout: 5_000 })
await page.locator('#onboardingLocale').selectOption('en')
await page.locator('#providerGrid [data-provider="ollama"]').click()
assert.equal(await page.locator('#modelRoutingMode').inputValue(), 'direct')
assert.equal(await page.locator('#routerMode').inputValue(), 'disabled')
assert.equal(await page.locator('#model').inputValue(), '', 'direct-only providers without a default model should not inherit the previous provider model')
assert.equal(await page.locator('#endpointToggle').getAttribute('aria-expanded'), 'true', 'direct-only providers that need a model should open the endpoint panel')
await page.locator('[data-screen="1"].active .next-button').click()
await page.locator('[data-screen="2"].active').waitFor({ state: 'visible', timeout: 5_000 })
assert.equal(await page.locator('[data-model-routing-mode="squilla_router"]').isDisabled(), true)
assert.equal(await page.locator('[data-model-routing-mode="direct"]').isEnabled(), true)
assert.equal(await page.locator('[data-model-routing-mode="llm_ensemble"]').isDisabled(), true)
assert.equal(await page.locator('[data-step-label="3"]').isVisible(), false, 'route-excluded tier step should be hidden from the progress rail')
await page.locator('[data-screen="2"].active .next-button').click()
await page.locator('[data-screen="2"].active').waitFor({ state: 'visible', timeout: 5_000 })
assert.match(await page.locator('#error').innerText(), /Direct model is required/)
await page.locator('[data-screen="2"].active .back-button').click()
await page.locator('[data-screen="1"].active').waitFor({ state: 'visible', timeout: 5_000 })
await page.locator('#providerGrid [data-provider="openai"]').click()
assert.equal(await page.locator('#provider').inputValue(), 'openai')
assert.equal(await page.locator('#baseUrl').inputValue(), 'https://api.openai.com/v1')
assert.equal(await page.locator('#model').inputValue(), 'gpt-5.4-mini')
await page.locator('#providerGrid [data-provider="openai"].active').waitFor({ state: 'visible', timeout: 5_000 })
const openAiHint = await page.locator('#providerHint').innerText()
assert.match(openAiHint, /OpenAI-only tier profile/)
assert.doesNotMatch(openAiHint, /OPENAI_API_KEY/)
await page.locator('#apiKey').fill('test-openai-key')
await page.locator('[data-screen="1"].active .next-button').click()
await page.locator('[data-screen="2"].active').waitFor({ state: 'visible', timeout: 10_000 })
assert.equal(await page.locator('#modelRoutingMode').inputValue(), 'squilla_router')
assert.equal(await page.locator('[data-model-routing-mode="squilla_router"]').isEnabled(), true)
assert.equal(await page.locator('[data-model-routing-mode="direct"]').isEnabled(), true)
assert.equal(await page.locator('[data-model-routing-mode="llm_ensemble"]').isDisabled(), true)
await page.locator('[data-screen="2"].active .next-button').click()
await page.locator('[data-screen="3"].active').waitFor({ state: 'visible', timeout: 10_000 })
assert.match(await page.locator('[data-screen="3"] .eyebrow').innerText(), /step 04/i)
assert.equal(await page.locator('[data-screen="3"] h2').innerText(), 'Review tier models')
await page.locator('[data-screen="3"].active .back-button').click()
await page.locator('[data-screen="2"].active').waitFor({ state: 'visible', timeout: 5_000 })
await page.locator('[data-screen="2"].active .back-button').click()
await page.locator('[data-screen="1"].active').waitFor({ state: 'visible', timeout: 5_000 })
await tokenRhythmFeature.locator('[data-provider="tokenrhythm"]').click()
await page.locator('#apiKey').fill('synthetic-tokenrhythm-key')
await page.locator('[data-screen="1"].active .next-button').click()
await page.locator('[data-screen="2"].active').waitFor({ state: 'visible', timeout: 5_000 })
await page.locator('[data-model-routing-mode="llm_ensemble"]').click()
await page.locator('[data-screen="2"].active .next-button').click()
await page.locator('[data-screen="4"].active').waitFor({ state: 'visible', timeout: 5_000 })
await page.locator('#finish').click()
const saved = await waitFor(async () => {
const credential = JSON.parse(await readFile(join(userDataDir, 'desktop-credential.json'), 'utf8'))
if (credential.modelRoutingMode !== 'llm_ensemble') return null
const config = await readFile(join(userDataDir, 'opensquilla', 'config.toml'), 'utf8')
return { credential, config }
}, 'saved ensemble credential and config')
const { credential, config } = saved
assert.equal(credential.provider, 'tokenrhythm')
assert.equal(credential.modelRoutingMode, 'llm_ensemble')
assert.equal(credential.routerMode, 'recommended')
assert.equal(credential.routerTiers.c0.model, 'deepseek-v4-flash')
assert.equal(credential.routerTiers.c1.model, 'deepseek-v4-pro')
assert.equal(credential.routerTiers.c2.model, 'kimi-k2.7-code')
assert.equal(credential.routerTiers.c3.model, 'glm-5.2')
assert.equal(credential.routerTiers.image_model.model, 'kimi-k2.6')
assert.match(config, /\[squilla_router\]\nenabled = true/)
assert.doesNotMatch(config, /tier_profile = "tokenrhythm"/)
assert.match(config, /\[squilla_router\.tiers\.c0\]\nprovider = "tokenrhythm"\nmodel = "deepseek-v4-flash"/)
assert.match(config, /\[squilla_router\.tiers\.c3\]\nprovider = "tokenrhythm"\nmodel = "glm-5\.2"/)
assert.match(config, /\[llm_ensemble\]\nenabled = true\nselection_mode = "static_tokenrhythm_b5"/)
console.log(JSON.stringify({
ok: true,
provider: credential.provider,
modelRoutingMode: credential.modelRoutingMode,
routerMode: credential.routerMode,
model: credential.model,
screenshotPath: screenshotPath || null,
}, null, 2))
} finally {
await app.close().catch(() => {})
await rm(userDataRoot, { recursive: true, force: true }).catch(() => {})
}
@@ -0,0 +1,540 @@
import { strict as assert } from 'node:assert'
import { spawnSync } from 'node:child_process'
import { lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { fileURLToPath } from 'node:url'
import { _electron as electron } from 'playwright'
const scriptDir = dirname(fileURLToPath(import.meta.url))
const packageRoot = resolve(scriptDir, '..')
const repoRoot = resolve(packageRoot, '../..')
const SOURCE_IDENTITY = '# Synthetic imported identity\n'
const TARGET_IDENTITY = '# Synthetic previous Desktop identity\n'
const SOURCE_CHAT = 'synthetic imported chat survives whole-profile transfer'
// A replace import performs two independently bounded receipt-verifier CLI
// calls (60 seconds each) around the mutating import. Windows CI cold starts
// can legitimately approach both bounds, so the E2E timeout must cover the
// product's advertised "few minutes" operation without becoming unbounded.
const PROFILE_IMPORT_APPLY_TIMEOUT_MS = 180_000
async function waitFor(check, label, timeoutMs = 90_000) {
const startedAt = Date.now()
let lastError
while (Date.now() - startedAt < timeoutMs) {
try {
const value = await check()
if (value) return value
} catch (error) {
lastError = error
}
await delay(250)
}
throw new Error(`Timed out waiting for ${label}: ${lastError?.message || lastError || ''}`)
}
function runPython(source, args) {
const result = spawnSync('uv', ['run', 'python', '-c', source, ...args], {
cwd: repoRoot,
encoding: 'utf8',
env: { ...process.env, UV_CACHE_DIR: join(tmpdir(), 'opensquilla-profile-import-uv-cache') },
})
if (result.status !== 0) {
throw new Error(`Python fixture command failed: ${result.stderr || result.stdout}`)
}
return result.stdout.trim()
}
function seedProfile(home, identity, chat) {
runPython(`
import json, sqlite3, sys
from pathlib import Path
home = Path(sys.argv[1]).resolve()
identity = sys.argv[2]
chat = sys.argv[3]
workspace = home / "workspace"
state = home / "state"
workspace.mkdir(parents=True, exist_ok=True)
state.mkdir(parents=True, exist_ok=True)
for name, value in {
"IDENTITY.md": identity,
"USER.md": "# Synthetic user\\n",
"SOUL.md": "# Synthetic soul\\n",
"MEMORY.md": "# Synthetic memory\\n",
}.items():
(workspace / name).write_text(value, encoding="utf-8", newline="")
(home / "config.toml").write_text(
"workspace_dir = " + json.dumps(str(workspace)) + "\\n"
+ "state_dir = " + json.dumps(str(state)) + "\\n"
+ "[llm]\\nprovider = \\"ollama\\"\\nmodel = \\"synthetic-import-model\\"\\n"
+ "base_url = \\"http://127.0.0.1:11434/v1\\"\\napi_key_env = \\"\\"\\n",
encoding="utf-8",
newline="",
)
with sqlite3.connect(state / "sessions.db") as connection:
connection.execute("CREATE TABLE synthetic_import_chat (id TEXT PRIMARY KEY, body TEXT NOT NULL)")
connection.execute("INSERT INTO synthetic_import_chat VALUES (?, ?)", ("session-1", chat))
assert connection.execute("PRAGMA quick_check").fetchone() == ("ok",)
`, [home, identity, chat])
}
async function writeProviderProfileConfig(home, settings) {
const workspace = join(home, 'workspace')
const state = join(home, 'state')
const lines = [
`workspace_dir = ${JSON.stringify(workspace)}`,
`state_dir = ${JSON.stringify(state)}`,
`search_provider = ${JSON.stringify(settings.searchProvider || 'duckduckgo')}`,
`search_api_key_env = ${JSON.stringify(settings.searchApiKeyEnv || '')}`,
'',
'[llm]',
`provider = ${JSON.stringify(settings.provider)}`,
`model = ${JSON.stringify(settings.model)}`,
`base_url = ${JSON.stringify(settings.baseUrl)}`,
`api_key_env = ${JSON.stringify(settings.apiKeyEnv || '')}`,
'',
'[squilla_router]',
`enabled = ${settings.routerEnabled === true ? 'true' : 'false'}`,
'default_tier = "c2"',
'confidence_threshold = 0.77',
'',
'[squilla_router.tiers.c0]',
`provider = ${JSON.stringify(settings.provider)}`,
'model = "synthetic-source-tier-model"',
'',
'[llm_ensemble]',
'enabled = false',
'selection_mode = "static_openrouter_b5"',
'',
'[privacy]',
`disable_network_observability = ${settings.disableNetworkObservability ? 'true' : 'false'}`,
'',
'[control_ui]',
'enabled = true',
'base_path = "/control"',
'',
]
await writeFile(join(home, 'config.toml'), lines.join('\n'), 'utf8')
}
async function seedDesktopCredential(userData, settings) {
await mkdir(userData, { recursive: true })
const now = '2026-07-12T00:00:00.000Z'
const credential = {
provider: settings.provider,
model: settings.model,
baseUrl: settings.baseUrl,
apiKeyEnv: settings.apiKeyEnv || '',
encryptedApiKey: settings.apiKey
? Buffer.from(settings.apiKey, 'utf8').toString('base64')
: '',
encryption: 'plain',
configAuthority: 'generated',
importTransactionId: '',
createdAt: now,
updatedAt: now,
}
const raw = `${JSON.stringify(credential, null, 2)}\n`
await writeFile(join(userData, 'desktop-credential.json'), raw, { mode: 0o600 })
return raw
}
async function snapshotTree(root) {
const result = {}
async function visit(path, relative = '') {
const info = await lstat(path)
assert.equal(info.isSymbolicLink(), false, `fixture cannot contain symlinks: ${path}`)
if (info.isDirectory()) {
result[`${relative || '.'}/`] = { type: 'directory', mode: info.mode }
for (const name of (await readdir(path)).sort()) {
await visit(join(path, name), relative ? `${relative}/${name}` : name)
}
return
}
assert.equal(info.isFile(), true)
result[relative] = {
type: 'file',
mode: info.mode,
bytes: (await readFile(path)).toString('base64'),
}
}
await visit(root)
return result
}
function launchEnvironment(isolatedHome, port) {
const inherited = { ...process.env }
for (const name of Object.keys(inherited)) {
if (name === 'DISPLAY' || name === 'XAUTHORITY') continue
const upperName = name.toUpperCase()
if (
name.startsWith('OPENSQUILLA_')
|| ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY'].includes(upperName)
|| /(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH)/i.test(name)
|| /^(?:AWS|AZURE|GOOGLE|ANTHROPIC|OPENAI|OPENROUTER|MINIMAX|DEEPSEEK|GROQ|MISTRAL|COHERE|GEMINI|OLLAMA|XAI|MOONSHOT|DASHSCOPE|SILICONFLOW|ZHIPU|BAIDU|VOLCENGINE|TENCENT|ALIYUN|HF|HUGGINGFACE)_/i.test(name)
) delete inherited[name]
}
return {
...inherited,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
OPENSQUILLA_DESKTOP_REPO_ROOT: repoRoot,
OPENSQUILLA_DESKTOP_SECRET_STORAGE: 'plain',
OPENSQUILLA_USER_STATE_DIR: join(isolatedHome, 'user-state'),
OPENSQUILLA_TEST_PROFILE_LOCK_ROOT: '1',
OPENSQUILLA_DESKTOP_GATEWAY_PORT: String(port),
OPENSQUILLA_DESKTOP_DISABLE_AUTO_UPDATE: '1',
OPENSQUILLA_OPENROUTER_LIVE_PRICING: '0',
UV_CACHE_DIR: join(isolatedHome, '.uv-cache'),
HTTP_PROXY: 'http://127.0.0.1:1',
HTTPS_PROXY: 'http://127.0.0.1:1',
ALL_PROXY: 'http://127.0.0.1:1',
NO_PROXY: '127.0.0.1,localhost',
http_proxy: 'http://127.0.0.1:1',
https_proxy: 'http://127.0.0.1:1',
all_proxy: 'http://127.0.0.1:1',
no_proxy: '127.0.0.1,localhost',
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
}
}
async function launchDesktop(userData, isolatedHome, port) {
return await electron.launch({
args: ['--use-mock-keychain', `--user-data-dir=${userData}`, packageRoot],
env: launchEnvironment(isolatedHome, port),
})
}
async function onboardingPage(app) {
return await waitFor(async () => {
for (const page of app.windows()) {
if (page.isClosed()) continue
await page.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => {})
if (await page.locator('#setup-form').count().catch(() => 0)) return page
}
return null
}, 'Desktop onboarding')
}
async function recoveryPage(app) {
return await waitFor(async () => {
for (const page of app.windows()) {
if (page.isClosed()) continue
await page.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => {})
if (await page.locator('#recoveryPanel.visible').count().catch(() => 0)) return page
}
return null
}, 'recovery profile confirmation page')
}
async function controlPage(app) {
return await waitFor(async () => {
for (const page of app.windows()) {
if (page.isClosed()) continue
await page.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => {})
let pathname = ''
try { pathname = new URL(page.url()).pathname } catch { pathname = '' }
if (!['/control/chat', '/control/chat/new'].includes(pathname)) continue
if (await page.locator('.chat-textarea').count().catch(() => 0)) return page
}
return null
}, 'Desktop Control UI', 120_000)
}
const root = await realpath(await mkdtemp(join(tmpdir(), 'opensquilla-profile-import-e2e-')))
let app = null
try {
// A single detected CLI profile remains unselected, and skipping performs no import.
const skipHome = join(root, 'skip-home')
const skipSource = join(skipHome, '.opensquilla')
const skipDesktopSource = join(root, 'skip-desktop-source')
const skipUserData = join(root, 'skip-user-data')
seedProfile(skipSource, SOURCE_IDENTITY, SOURCE_CHAT)
seedProfile(skipDesktopSource, '# Synthetic alternate Desktop identity\n', 'alternate chat')
const skipSourceBefore = await snapshotTree(skipSource)
const skipDesktopSourceBefore = await snapshotTree(skipDesktopSource)
app = await launchDesktop(skipUserData, skipHome, 18921)
let page = await onboardingPage(app)
await page.locator('[data-screen="5"].active').waitFor({ state: 'visible' })
assert.equal(await page.locator('#migrationSource').inputValue(), '')
assert.equal(await page.locator('#migrationSource option').count(), 2)
assert.equal(await page.locator('#migrationPreview').isDisabled(), true)
await app.evaluate(({ dialog }, selectedPath) => {
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [selectedPath] })
}, skipDesktopSource)
await page.locator('#migrationSourceKind').selectOption('desktop-home')
await page.locator('#migrationBrowse').click()
await waitFor(async () => (
await page.locator('#migrationSource option').count() === 3
), 'second explicitly browsed candidate')
assert.equal(await page.locator('#migrationSource').inputValue(), skipDesktopSource)
await page.locator('#migrationSource').selectOption('')
assert.equal(await page.locator('#migrationPreview').isDisabled(), true)
await page.locator('#migrationSkip').click()
await page.locator('[data-screen="0"].active').waitFor({ state: 'visible' })
assert.deepEqual(await snapshotTree(skipSource), skipSourceBefore)
assert.deepEqual(await snapshotTree(skipDesktopSource), skipDesktopSourceBefore)
assert.notEqual(
await readFile(join(skipUserData, 'opensquilla', 'workspace', 'IDENTITY.md'), 'utf8').catch(() => ''),
SOURCE_IDENTITY,
)
await app.close()
app = null
// A non-empty Desktop target is backed up and replaced as one profile; source stays read-only.
const importHome = join(root, 'import-home')
const source = join(importHome, '.opensquilla')
const userData = join(root, 'import-user-data')
const target = join(userData, 'opensquilla')
seedProfile(source, SOURCE_IDENTITY, SOURCE_CHAT)
seedProfile(target, TARGET_IDENTITY, 'synthetic previous Desktop chat')
const sourceBefore = await snapshotTree(source)
app = await launchDesktop(userData, importHome, 18922)
page = await onboardingPage(app)
await page.locator('[data-screen="5"].active').waitFor({ state: 'visible' })
assert.equal(await page.locator('#migrationSource').inputValue(), '')
await page.locator('#migrationSource').selectOption(source)
await waitFor(async () => !(await page.locator('#migrationPreview').isDisabled()), 'explicit source selection')
await page.locator('#migrationPreview').click()
try {
await waitFor(async () => !(await page.locator('#migrationImport').isDisabled()), 'whole-replace preview')
} catch (error) {
const diagnostics = await page.evaluate(() => ({
error: document.getElementById('error')?.textContent || '',
summary: document.getElementById('migrationSummary')?.textContent || '',
source: document.getElementById('migrationSource')?.value || '',
}))
throw new Error(`${error.message}; diagnostics=${JSON.stringify(diagnostics)}`)
}
await app.evaluate(({ dialog }) => {
dialog.showMessageBox = async () => ({ response: 1, checkboxChecked: false })
})
await page.locator('#migrationImport').click()
try {
await page.locator('#migrationDoneNote').waitFor({
state: 'visible',
timeout: PROFILE_IMPORT_APPLY_TIMEOUT_MS,
})
} catch (error) {
const renderer = await page.evaluate(() => ({
error: document.getElementById('error')?.textContent || '',
statusVisible: !document.getElementById('migrationStatus')?.hidden,
summaryVisible: !document.getElementById('migrationSummary')?.hidden,
})).catch(() => ({ error: '', statusVisible: false, summaryVisible: false }))
const pendingPhase = await readFile(
join(userData, 'migration-provider-setup.json'),
'utf8',
).then((raw) => JSON.parse(raw)?.phase || '').catch(() => '')
const receiptCount = await readdir(join(target, 'migration', 'opensquilla'))
.then((entries) => entries.length)
.catch(() => 0)
const backupCount = await readdir(userData)
.then((entries) => entries.filter((name) => name.startsWith('opensquilla.backup.')).length)
.catch(() => 0)
const migrationResult = await readFile(
join(userData, 'migration-last-result.json'),
'utf8',
).then((raw) => {
const value = JSON.parse(raw)
return {
ok: value?.ok === true,
migrationApplied: value?.migrationApplied === true,
restartOk: value?.restartOk === true,
requiresProviderSetup: value?.requiresProviderSetup === true,
detail: typeof value?.detail === 'string' ? value.detail : '',
}
}).catch(() => null)
const diagnostics = {
renderer,
pendingPhase,
receiptCount,
backupCount,
migrationResult,
importedIdentityPresent: await readFile(
join(target, 'workspace', 'IDENTITY.md'),
'utf8',
).then((value) => value === SOURCE_IDENTITY).catch(() => false),
}
const reportDir = process.env.CI_REPORT_DIR
if (reportDir) {
await mkdir(reportDir, { recursive: true })
await writeFile(
join(reportDir, 'profile-import-timeout.json'),
`${JSON.stringify(diagnostics, null, 2)}\n`,
'utf8',
)
}
throw new Error(`${error.message}; diagnostics=${JSON.stringify(diagnostics)}`)
}
assert.match(await page.locator('#migrationDoneNote').innerText(), /Import complete/i)
assert.deepEqual(await snapshotTree(source), sourceBefore, 'source bytes and permissions changed')
assert.equal(await readFile(join(source, '.opensquilla-imported.json'), 'utf8').catch(() => null), null)
assert.equal(await readFile(join(target, 'workspace', 'IDENTITY.md'), 'utf8'), SOURCE_IDENTITY)
const importedChat = runPython(`
import sqlite3, sys
with sqlite3.connect('file:' + sys.argv[1] + '?mode=ro', uri=True) as connection:
print(connection.execute('SELECT body FROM synthetic_import_chat WHERE id = ?', ('session-1',)).fetchone()[0])
`, [join(target, 'state', 'sessions.db')])
assert.equal(importedChat, SOURCE_CHAT)
const backups = (await readdir(userData)).filter((name) => name.startsWith('opensquilla.backup.'))
assert.equal(backups.length, 1)
assert.equal(
await readFile(join(userData, backups[0], 'workspace', 'IDENTITY.md'), 'utf8'),
TARGET_IDENTITY,
)
await app.close()
app = null
// Settings import with a required key must release exclusive admission before
// onboarding, preserve source config bytes, and retain the previous credential.
const settingsHome = join(root, 'settings-home')
const settingsSource = join(settingsHome, '.opensquilla')
const settingsUserData = join(root, 'settings-user-data')
const settingsTarget = join(settingsUserData, 'opensquilla')
seedProfile(settingsSource, SOURCE_IDENTITY, SOURCE_CHAT)
seedProfile(settingsTarget, TARGET_IDENTITY, 'synthetic previous settings chat')
await writeProviderProfileConfig(settingsSource, {
provider: 'openai',
model: 'gpt-5.4-mini',
baseUrl: 'https://api.openai.com/v1',
apiKeyEnv: 'OPENAI_API_KEY',
searchProvider: 'brave',
searchApiKeyEnv: 'BRAVE_API_KEY',
routerEnabled: false,
disableNetworkObservability: true,
})
const importedEnvBytes = Buffer.from(
'OPENAI_API_KEY="synthetic-source-env-key"\r\nTRAILING_VALUE=keep\r\n\r\n',
)
await writeFile(join(settingsSource, '.env'), importedEnvBytes)
await writeProviderProfileConfig(settingsTarget, {
provider: 'openai',
model: 'synthetic-old-target-model',
baseUrl: 'https://api.openai.com/v1',
apiKeyEnv: 'OPENAI_API_KEY',
routerEnabled: true,
disableNetworkObservability: false,
})
const oldCredential = await seedDesktopCredential(settingsUserData, {
provider: 'openai',
model: 'synthetic-old-target-model',
baseUrl: 'https://api.openai.com/v1',
apiKeyEnv: 'OPENAI_API_KEY',
apiKey: 'synthetic-old-target-key',
})
app = await launchDesktop(settingsUserData, settingsHome, 18924)
const settingsControl = await controlPage(app)
const settingsPreview = await settingsControl.evaluate(async (sourcePath) => (
await window.opensquillaDesktop.migrationSummary({ source: sourcePath })
), settingsSource)
assert.equal(settingsPreview.ok, false, JSON.stringify(settingsPreview))
assert.equal(typeof settingsPreview.previewId, 'string')
assert.equal(
settingsPreview.report.items.filter((item) => item.status === 'error').at(0)?.kind,
'preflight/target',
)
await app.evaluate(({ dialog }) => {
dialog.showMessageBox = async () => ({ response: 1, checkboxChecked: false })
})
await settingsControl.evaluate(({ previewId }) => {
void window.opensquillaDesktop.migrationRun({ previewId, overwrite: true })
return true
}, { previewId: settingsPreview.previewId })
const requiredKeyOnboarding = await onboardingPage(app)
await requiredKeyOnboarding.locator('[data-screen="0"].active').waitFor({
state: 'visible',
timeout: 90_000,
})
await requiredKeyOnboarding.locator('[data-screen="0"].active .next-button').click()
await requiredKeyOnboarding.locator('[data-screen="1"].active').waitFor({
state: 'visible',
timeout: 90_000,
})
assert.equal(await requiredKeyOnboarding.locator('#provider').inputValue(), 'openai')
assert.equal(await requiredKeyOnboarding.locator('#model').inputValue(), 'gpt-5.4-mini')
const importedConfigBeforeCredential = await readFile(join(settingsTarget, 'config.toml'))
assert.match(importedConfigBeforeCredential.toString('utf8'), /search_provider = "brave"/)
assert.match(importedConfigBeforeCredential.toString('utf8'), /confidence_threshold = 0\.77/)
assert.match(
importedConfigBeforeCredential.toString('utf8'),
/disable_network_observability = true/,
)
await requiredKeyOnboarding.locator('#apiKey').fill('synthetic-new-imported-key')
await requiredKeyOnboarding.locator('[data-screen="1"].active .next-button').click()
await requiredKeyOnboarding.locator('[data-screen="4"].active').waitFor({ state: 'visible' })
await requiredKeyOnboarding.locator('#finish').click()
const adopted = await waitFor(async () => {
const pending = await readFile(
join(settingsUserData, 'migration-provider-setup.json'),
'utf8',
).catch(() => null)
if (pending !== null) return null
const raw = await readFile(join(settingsUserData, 'desktop-credential.json'), 'utf8')
const credential = JSON.parse(raw)
return credential.configAuthority === 'profile' ? credential : null
}, 'required-key imported credential adoption')
assert.match(adopted.importTransactionId, /^[0-9a-f-]{36}$/i)
assert.equal(adopted.model, 'gpt-5.4-mini')
assert.equal(
Buffer.from(adopted.encryptedApiKey, 'base64').toString('utf8'),
'synthetic-new-imported-key',
)
assert.deepEqual(
await readFile(join(settingsTarget, 'config.toml')),
importedConfigBeforeCredential,
'provider adoption rewrote imported config.toml',
)
assert.deepEqual(
await readFile(join(settingsTarget, '.env')),
importedEnvBytes,
'provider adoption rewrote imported .env bytes',
)
const credentialBackup = join(
settingsUserData,
`desktop-credential.import-backup.${adopted.importTransactionId}.json`,
)
assert.equal(await readFile(credentialBackup, 'utf8'), oldCredential)
if (process.platform !== 'win32') {
assert.equal((await lstat(credentialBackup)).mode & 0o777, 0o600)
}
await app.close()
app = null
// A selected recovery H can use the app, but it cannot import another profile.
const recoveryHome = join(root, 'recovery-home')
const recoveryUserData = join(root, 'recovery-user-data')
const recoveryId = '12345678-1234-4234-8234-123456789abc'
await mkdir(join(recoveryUserData, 'recovery-profiles', recoveryId, 'opensquilla'), { recursive: true })
await writeFile(join(recoveryUserData, 'desktop-profile-context.json'), JSON.stringify({
schema_version: 1,
active_profile_kind: 'recovery',
active_recovery_id: recoveryId,
attention_acknowledgement: null,
updated_at: new Date().toISOString(),
}, null, 2))
app = await launchDesktop(recoveryUserData, recoveryHome, 18923)
page = await recoveryPage(app)
const rejected = await page.evaluate(() => window.opensquillaDesktop.migrationSummary())
assert.equal(rejected.ok, false)
assert.match(rejected.raw, /primary profile/i)
console.log(JSON.stringify({
explicitSelectionAndSkip: true,
multipleCandidates: true,
wholeReplacement: true,
sourceUnchanged: true,
identityAndChatImported: true,
settingsRequiredKeyCompleted: true,
importedConfigPreserved: true,
previousCredentialBackedUp: true,
recoveryProfileRejected: true,
}, null, 2))
} finally {
if (app) await app.close().catch(() => {})
await rm(root, { recursive: true, force: true })
}
@@ -0,0 +1,524 @@
import { strict as assert } from 'node:assert'
import {
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
realpath,
rm,
writeFile,
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { fileURLToPath } from 'node:url'
import { _electron as electron } from 'playwright'
const scriptDir = dirname(fileURLToPath(import.meta.url))
const packageRoot = resolve(scriptDir, '..')
const repoRoot = resolve(packageRoot, '../..')
const RECOVERY_ID = '21234567-89ab-4cde-8fab-0123456789ab'
const observedRendererPages = new WeakSet()
const rendererDiagnostics = []
const LOCALES = {
en: {
title: 'Starting OpenSquilla',
bootAria: 'OpenSquilla startup',
recoveryTitle: 'Your primary profile needs recovery',
continueRecovery: 'Continue recovery profile',
createRecovery: 'Create recovery profile',
retryPrimary: 'Retry primary profile',
cleanupRecoveryTitle: 'A data cleanup stopped partway through',
abandonCleanup: 'Preserve remaining data and continue',
},
'zh-Hans': {
title: '正在启动 OpenSquilla',
bootAria: 'OpenSquilla 启动',
recoveryTitle: '主配置需要恢复',
continueRecovery: '继续恢复配置',
createRecovery: '创建恢复配置',
retryPrimary: '重试主配置',
cleanupRecoveryTitle: '数据清理在中途停止',
abandonCleanup: '保留剩余数据并继续',
},
ja: {
title: 'OpenSquilla を起動しています',
bootAria: 'OpenSquilla の起動',
recoveryTitle: 'プライマリプロファイルの復旧が必要です',
continueRecovery: '復旧プロファイルを続行',
createRecovery: '復旧プロファイルを作成',
retryPrimary: 'プライマリを再試行',
cleanupRecoveryTitle: 'データのクリーンアップが途中で停止しました',
abandonCleanup: '残りのデータを保持して続行',
},
fr: {
title: "Démarrage d'OpenSquilla",
bootAria: "Démarrage d'OpenSquilla",
recoveryTitle: 'Le profil principal doit être récupéré',
continueRecovery: 'Continuer ce profil',
createRecovery: 'Créer un profil de récupération',
retryPrimary: 'Réessayer le profil principal',
cleanupRecoveryTitle: 'Un nettoyage des données sest arrêté en cours de route',
abandonCleanup: 'Conserver les données restantes et continuer',
},
de: {
title: 'OpenSquilla wird gestartet',
bootAria: 'OpenSquilla-Start',
recoveryTitle: 'Das Hauptprofil muss wiederhergestellt werden',
continueRecovery: 'Profil fortsetzen',
createRecovery: 'Wiederherstellungsprofil erstellen',
retryPrimary: 'Hauptprofil erneut prüfen',
cleanupRecoveryTitle: 'Eine Datenbereinigung wurde unterbrochen',
abandonCleanup: 'Verbleibende Daten behalten und fortfahren',
},
es: {
title: 'Iniciando OpenSquilla',
bootAria: 'Inicio de OpenSquilla',
recoveryTitle: 'El perfil principal necesita recuperación',
continueRecovery: 'Continuar perfil de recuperación',
createRecovery: 'Crear perfil de recuperación',
retryPrimary: 'Reintentar perfil principal',
cleanupRecoveryTitle: 'Una limpieza de datos se detuvo a mitad de camino',
abandonCleanup: 'Conservar los datos restantes y continuar',
},
}
const BLOCKING_CASES = {
en: { fixture: 'missing-workspace', stableCode: 'effective_workspace_missing' },
'zh-Hans': { fixture: 'corrupt-config', stableCode: 'config_invalid' },
ja: { fixture: 'future-config', stableCode: 'config_schema_too_new' },
fr: {
fixture: 'future-context',
stableCode: 'desktop_profile_context_schema_too_new',
},
de: { fixture: 'unfinished-transaction', stableCode: 'transaction_incomplete' },
es: { fixture: 'unsafe-database', stableCode: 'state_database_invalid' },
}
async function waitFor(check, label, timeoutMs = 90_000) {
const startedAt = Date.now()
let lastError
while (Date.now() - startedAt < timeoutMs) {
try {
const value = await check()
if (value) return value
} catch (error) {
lastError = error
}
await delay(250)
}
throw new Error(`Timed out waiting for ${label}: ${lastError?.message || lastError || ''}`)
}
async function snapshotTree(root) {
const result = {}
async function visit(path, relative = '') {
const info = await lstat(path)
assert.equal(info.isSymbolicLink(), false, `fixture must not contain symlinks: ${path}`)
if (info.isDirectory()) {
result[`${relative || '.'}/`] = 'directory'
for (const name of (await readdir(path)).sort()) {
await visit(join(path, name), relative ? `${relative}/${name}` : name)
}
return
}
assert.equal(info.isFile(), true, `fixture must contain only files/directories: ${path}`)
result[relative] = (await readFile(path)).toString('base64')
}
await visit(root)
return result
}
function launchEnvironment(isolatedHome) {
const inherited = { ...process.env }
for (const name of Object.keys(inherited)) {
if (name === 'DISPLAY' || name === 'XAUTHORITY') continue
const upperName = name.toUpperCase()
if (
upperName.startsWith('OPENSQUILLA_')
|| ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY'].includes(upperName)
|| /(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH)/i.test(name)
|| /^(?:AWS|AZURE|GOOGLE|ANTHROPIC|OPENAI|OPENROUTER|MINIMAX|DEEPSEEK|GROQ|MISTRAL|COHERE|GEMINI|OLLAMA|XAI|MOONSHOT|DASHSCOPE|SILICONFLOW|ZHIPU|BAIDU|VOLCENGINE|TENCENT|ALIYUN|HF|HUGGINGFACE)_/i.test(name)
) {
delete inherited[name]
}
}
return {
...inherited,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
OPENSQUILLA_DESKTOP_REPO_ROOT: repoRoot,
OPENSQUILLA_DESKTOP_SECRET_STORAGE: 'plain',
OPENSQUILLA_USER_STATE_DIR: join(isolatedHome, 'user-state'),
OPENSQUILLA_TEST_PROFILE_LOCK_ROOT: '1',
OPENSQUILLA_DESKTOP_GATEWAY_PORT: '18897',
OPENSQUILLA_DESKTOP_DISABLE_AUTO_UPDATE: '1',
OPENSQUILLA_OPENROUTER_LIVE_PRICING: '0',
OPENSQUILLA_GATEWAY_WORKSPACE_DIR: '',
OPENSQUILLA_WORKSPACE_DIR: '',
OPENSQUILLA_GATEWAY_STATE_DIR: '',
HTTP_PROXY: 'http://127.0.0.1:1',
HTTPS_PROXY: 'http://127.0.0.1:1',
ALL_PROXY: 'http://127.0.0.1:1',
NO_PROXY: '127.0.0.1,localhost',
http_proxy: 'http://127.0.0.1:1',
https_proxy: 'http://127.0.0.1:1',
all_proxy: 'http://127.0.0.1:1',
no_proxy: '127.0.0.1,localhost',
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
}
}
async function createFixture(locale, blockingCase) {
const root = await realpath(await mkdtemp(join(tmpdir(), `opensquilla-recovery-a11y-${locale}-`)))
const userData = join(root, 'user-data')
const isolatedHome = join(root, 'home')
const primaryHome = join(userData, 'opensquilla')
const primaryWorkspace = join(primaryHome, 'workspace')
const primaryState = join(primaryHome, 'state')
const missingWorkspace = join(root, 'missing-external-workspace')
const recoveryHome = join(userData, 'recovery-profiles', RECOVERY_ID, 'opensquilla')
await mkdir(primaryWorkspace, { recursive: true })
await mkdir(primaryState, { recursive: true })
await mkdir(recoveryHome, { recursive: true })
await mkdir(isolatedHome, { recursive: true })
for (const [name, text] of [
['USER.md', 'synthetic accessibility user\n'],
['SOUL.md', 'synthetic accessibility soul\n'],
['IDENTITY.md', 'synthetic accessibility identity\n'],
['MEMORY.md', 'synthetic accessibility memory\n'],
]) {
await writeFile(join(primaryWorkspace, name), text, 'utf8')
}
await writeFile(join(primaryState, 'primary-must-not-change.txt'), 'unchanged\n', 'utf8')
const validConfig = [
`state_dir = ${JSON.stringify(primaryState)}`,
'',
].join('\n')
let config = validConfig
if (blockingCase.fixture === 'missing-workspace') {
config = [
`state_dir = ${JSON.stringify(primaryState)}`,
`workspace_dir = ${JSON.stringify(missingWorkspace)}`,
'',
].join('\n')
} else if (blockingCase.fixture === 'corrupt-config') {
config = 'workspace_dir = "unterminated\n'
} else if (blockingCase.fixture === 'future-config') {
config = 'config_version = 999\n'
}
await writeFile(join(primaryHome, 'config.toml'), config, 'utf8')
if (blockingCase.fixture === 'unsafe-database') {
await writeFile(join(primaryState, 'sessions.db'), 'not a sqlite database', 'utf8')
}
let journalPath = null
let journalBytes = null
if (blockingCase.fixture === 'unfinished-transaction') {
journalPath = join(userData, '.opensquilla.profile-replace.json')
journalBytes = '{"schema_version":1,"phase":"prepared"}\n'
await writeFile(journalPath, journalBytes, 'utf8')
} else if (blockingCase.fixture === 'cleanup-transaction') {
journalPath = join(userData, '.opensquilla.profile-cleanup.json')
journalBytes = 'synthetic interrupted cleanup authority\n'
await writeFile(journalPath, journalBytes, 'utf8')
}
await writeFile(join(userData, 'desktop-locale'), locale, 'utf8')
await writeFile(
join(userData, 'desktop-profile-context.json'),
`${JSON.stringify({
schema_version: blockingCase.fixture === 'future-context' ? 999 : 1,
active_profile_kind: 'primary',
active_recovery_id: null,
attention_acknowledgement: null,
updated_at: '2026-07-11T00:00:00.000Z',
}, null, 2)}\n`,
'utf8',
)
return {
root,
userData,
isolatedHome,
primaryHome,
recoveryHome,
journalPath,
journalBytes,
primaryBefore: await snapshotTree(primaryHome),
}
}
async function launchFixture(fixture) {
return await electron.launch({
args: ['--use-mock-keychain', `--user-data-dir=${fixture.userData}`, packageRoot],
env: launchEnvironment(fixture.isolatedHome),
})
}
async function recoveryPage(app) {
return await waitFor(async () => {
for (const page of app.windows()) {
if (page.isClosed()) continue
await page.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => {})
if (await page.locator('#recoveryPanel.visible').count().catch(() => 0)) return page
}
return null
}, 'localized recovery page')
}
function observeRenderer(page) {
if (observedRendererPages.has(page)) return
observedRendererPages.add(page)
page.on('console', (message) => {
rendererDiagnostics.push({
type: `console:${message.type()}`,
text: message.text().slice(0, 1_000),
})
})
page.on('pageerror', (error) => {
rendererDiagnostics.push({
type: 'pageerror',
text: String(error?.message || error).slice(0, 1_000),
})
})
}
async function onboardingPage(app) {
try {
return await waitFor(async () => {
for (const page of app.windows()) {
if (page.isClosed()) continue
observeRenderer(page)
await page.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => {})
if (await page.locator('#setup-form').count().catch(() => 0)) return page
}
return null
}, 'selected recovery profile onboarding')
} catch (error) {
const windows = await Promise.all(app.windows().map(async (page) => ({
url: page.url(),
title: await page.title().catch(() => ''),
body: await page.locator('body').innerText().catch(() => '').then((value) => (
value.slice(0, 1_500)
)),
})))
throw new Error(
`${error.message}; windows=${JSON.stringify(windows)}; `
+ `renderer=${JSON.stringify(rendererDiagnostics.slice(-30))}`,
)
}
}
async function tabTo(page, targetId, maximumTabs = 30) {
for (let index = 0; index <= maximumTabs; index += 1) {
const activeId = await page.evaluate(() => document.activeElement?.id || '')
if (activeId === targetId) return
await page.keyboard.press('Tab')
}
throw new Error(`Keyboard focus did not reach #${targetId}`)
}
function durationSeconds(value) {
if (value.endsWith('ms')) return Number.parseFloat(value) / 1_000
if (value.endsWith('s')) return Number.parseFloat(value)
return Number.NaN
}
async function assertReducedMotion(page) {
await page.emulateMedia({ reducedMotion: 'reduce' })
const result = await page.evaluate(() => {
const bodyWasErrored = document.body.classList.contains('errored')
const blockedStyle = getComputedStyle(
document.querySelector('.status-line'),
'::before',
)
const blockedAnimationName = blockedStyle.animationName
document.body.classList.remove('errored')
const reducedStyle = getComputedStyle(
document.querySelector('.status-line'),
'::before',
)
const snapshot = {
mediaMatches: matchMedia('(prefers-reduced-motion: reduce)').matches,
blockedAnimationName,
animationName: reducedStyle.animationName,
animationDuration: reducedStyle.animationDuration,
animationIterationCount: reducedStyle.animationIterationCount,
scrollBehavior: getComputedStyle(document.documentElement).scrollBehavior,
}
if (bodyWasErrored) document.body.classList.add('errored')
return snapshot
})
assert.equal(result.mediaMatches, true)
assert.equal(result.blockedAnimationName, 'none')
assert.equal(result.animationName, 'progress')
assert(durationSeconds(result.animationDuration) <= 0.00001, result.animationDuration)
assert.equal(result.animationIterationCount, '1')
assert.equal(result.scrollBehavior, 'auto')
}
async function assertLocalizedRecovery(page, locale, expected) {
await waitFor(
async () => await page.locator('html').getAttribute('lang') === locale,
`${locale} locale application`,
)
assert.equal(await page.title(), expected.title)
assert.equal(await page.locator('main.boot').getAttribute('aria-label'), expected.bootAria)
assert.equal(await page.locator('#recoveryTitle').innerText(), expected.recoveryTitle)
assert.equal(await page.locator('#continueRecovery').innerText(), expected.continueRecovery)
assert.equal(await page.locator('#createRecovery').innerText(), expected.createRecovery)
assert.equal(await page.locator('#retryPrimary').innerText(), expected.retryPrimary)
assert.equal(await page.locator('#recoveryPanel').getAttribute('role'), 'region')
assert.equal(await page.locator('#recoveryPanel').getAttribute('aria-labelledby'), 'recoveryTitle')
assert.equal(
await page.getByRole('region', { name: expected.recoveryTitle }).count(),
1,
)
assert.equal(await page.locator('#recoveryStatus').getAttribute('role'), 'status')
assert.equal(await page.locator('#recoveryStatus').getAttribute('aria-live'), 'polite')
assert.equal(await page.evaluate(() => document.activeElement?.id), 'recoveryTitle')
}
const completedLocales = []
const completedBlockingCodes = []
const completedCleanupLocales = []
for (const [locale, expected] of Object.entries(LOCALES)) {
const blockingCase = BLOCKING_CASES[locale]
assert(blockingCase, `missing blocking fixture for ${locale}`)
const fixture = await createFixture(locale, blockingCase)
let app
try {
app = await launchFixture(fixture)
const page = await recoveryPage(app)
assert.deepEqual(
await readdir(fixture.recoveryHome),
[],
'read-only recovery inspection must not seed a third blank workspace',
)
assert.equal(await page.locator('#recoveryCode').innerText(), blockingCase.stableCode)
await assertLocalizedRecovery(page, locale, expected)
const originalRecoveryState = await page.evaluate(() => (
window.opensquillaDesktop.getRecoveryState()
))
await page.evaluate((state) => {
window.renderRecoveryState({
...state,
blocked: true,
inspection: {
...state.inspection,
outcome: 'recovery_required',
stable_code: 'cleanup_transaction_incomplete',
allowed_actions: [
'abandon-cleanup',
'continue-recovery-profile',
'create-recovery-profile',
'retry-primary-profile',
'show-backups',
'copy-diagnostics',
],
},
}, false)
}, originalRecoveryState)
assert.equal(await page.locator('#recoveryTitle').innerText(), expected.cleanupRecoveryTitle)
assert.equal(await page.locator('#abandonCleanup').innerText(), expected.abandonCleanup)
assert.equal(await page.locator('#cleanupAbandonGroup').getAttribute('hidden'), null)
completedCleanupLocales.push(locale)
await page.evaluate((state) => window.renderRecoveryState(state, true), originalRecoveryState)
if (locale === 'en') await assertReducedMotion(page)
const existingProfiles = await page.locator('#recoveryProfiles option').evaluateAll((options) => (
options.map((option) => ({ value: option.value, label: option.textContent || '' }))
))
assert(existingProfiles.some((option) => (
option.value === RECOVERY_ID && option.label.includes(fixture.recoveryHome)
)))
const gatewayBeforeChoice = await page.evaluate(() => (
window.opensquillaDesktop.getGatewayStatus()
))
assert.equal(gatewayBeforeChoice.status, 'stopped')
assert.equal(gatewayBeforeChoice.owned, false)
// The entire fallback selection is keyboard-only. Starting from the
// programmatically focused recovery heading, Tab reaches the existing
// profile selector, then Enter activates Continue without a pointer click.
await tabTo(page, 'recoveryProfiles')
assert.equal(await page.locator('#recoveryProfiles').inputValue(), RECOVERY_ID)
await page.keyboard.press('Home')
assert.equal(await page.locator('#recoveryProfiles').inputValue(), RECOVERY_ID)
await page.keyboard.press('Tab')
assert.equal(await page.evaluate(() => document.activeElement?.id), 'continueRecovery')
await page.keyboard.press('Enter')
const onboarding = await onboardingPage(app)
const gatewayAfterChoice = await onboarding.evaluate(() => (
window.opensquillaDesktop.getGatewayStatus()
))
assert.equal(gatewayAfterChoice.status, 'stopped')
assert.equal(gatewayAfterChoice.owned, false)
const context = JSON.parse(
await readFile(join(fixture.userData, 'desktop-profile-context.json'), 'utf8'),
)
assert.equal(context.active_profile_kind, 'recovery')
assert.equal(context.active_recovery_id, RECOVERY_ID)
assert.deepEqual(await snapshotTree(fixture.primaryHome), fixture.primaryBefore)
if (fixture.journalPath) {
assert.equal(await readFile(fixture.journalPath, 'utf8'), fixture.journalBytes)
}
completedLocales.push(locale)
completedBlockingCodes.push(blockingCase.stableCode)
} finally {
await app?.close().catch(() => {})
await rm(fixture.root, { recursive: true, force: true }).catch(() => {})
}
}
assert.deepEqual(completedLocales, Object.keys(LOCALES))
assert.deepEqual(completedCleanupLocales, Object.keys(LOCALES))
assert.deepEqual(
completedBlockingCodes,
Object.values(BLOCKING_CASES).map((item) => item.stableCode),
)
// A real compiled Electron launch also covers the cleanup-specific recovery
// state. The destructive action itself is not activated here (the native
// confirmation is intentionally outside renderer automation); keyboard focus,
// localized explanation, ARIA region, and the untouched primary bytes are.
const cleanupFixture = await createFixture('en', {
fixture: 'cleanup-transaction',
stableCode: 'cleanup_transaction_incomplete',
})
let cleanupApp
try {
cleanupApp = await launchFixture(cleanupFixture)
const cleanupPage = await recoveryPage(cleanupApp)
assert.equal(await cleanupPage.locator('#recoveryCode').innerText(), 'cleanup_transaction_incomplete')
assert.equal(await cleanupPage.locator('#recoveryTitle').innerText(), LOCALES.en.cleanupRecoveryTitle)
assert.equal(await cleanupPage.locator('#cleanupAbandonGroup').getAttribute('hidden'), null)
assert.equal(await cleanupPage.locator('#abandonCleanup').innerText(), LOCALES.en.abandonCleanup)
assert.equal(await cleanupPage.locator('#recoveryPanel').getAttribute('role'), 'region')
assert.equal(await cleanupPage.locator('#recoveryStatus').getAttribute('aria-live'), 'polite')
await tabTo(cleanupPage, 'abandonCleanup')
assert.equal(await cleanupPage.evaluate(() => document.activeElement?.id), 'abandonCleanup')
assert.deepEqual(await snapshotTree(cleanupFixture.primaryHome), cleanupFixture.primaryBefore)
assert.equal(
await readFile(cleanupFixture.journalPath, 'utf8'),
cleanupFixture.journalBytes,
)
} finally {
await cleanupApp?.close().catch(() => {})
await rm(cleanupFixture.root, { recursive: true, force: true }).catch(() => {})
}
console.log(JSON.stringify({
ok: true,
locales: completedLocales,
blockingCodes: completedBlockingCodes,
keyboardContinueVerified: true,
primaryBytesUnchanged: true,
reducedMotionVerified: true,
cleanupAbandonKeyboardVerified: true,
}))
@@ -0,0 +1,667 @@
import { strict as assert } from 'node:assert'
import { spawnSync } from 'node:child_process'
import { createServer } from 'node:http'
import {
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
realpath,
rm,
writeFile,
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { fileURLToPath } from 'node:url'
import { _electron as electron } from 'playwright'
const scriptDir = dirname(fileURLToPath(import.meta.url))
const packageRoot = resolve(scriptDir, '..')
const repoRoot = resolve(packageRoot, '../..')
const screenshotPath = String(process.env.OPENSQUILLA_DESKTOP_RECOVERY_SCREENSHOT || '').trim()
const PRIMARY_SENTINEL = 'synthetic-primary-credential-must-not-be-copied'
const RECOVERY_SYNTHETIC_KEY = 'synthetic-loopback-only-recovery-key'
const FIRST_PROMPT = 'RECOVERY_E2E_FIRST_PROMPT'
const SECOND_PROMPT = 'RECOVERY_E2E_SECOND_PROMPT'
const REPLY = 'RECOVERY_E2E_REPLY'
const observedRendererPages = new WeakSet()
const rendererDiagnostics = []
async function waitFor(check, label, timeoutMs = 90_000) {
const startedAt = Date.now()
let lastError
while (Date.now() - startedAt < timeoutMs) {
try {
const value = await check()
if (value) return value
} catch (error) {
lastError = error
}
await delay(250)
}
throw new Error(`Timed out waiting for ${label}: ${lastError?.message || lastError || ''}`)
}
function runPython(source, args) {
const result = spawnSync('uv', ['run', 'python', '-c', source, ...args], {
cwd: repoRoot,
encoding: 'utf8',
})
if (result.status !== 0) {
throw new Error(`Python fixture command failed: ${result.stderr || result.stdout}`)
}
return result.stdout.trim()
}
async function snapshotTree(root) {
const result = {}
async function visit(path, relative = '') {
const info = await lstat(path)
assert.equal(info.isSymbolicLink(), false, `fixture must not contain symlinks: ${path}`)
if (info.isDirectory()) {
result[`${relative || '.'}/`] = 'directory'
for (const name of (await readdir(path)).sort()) {
await visit(join(path, name), relative ? `${relative}/${name}` : name)
}
return
}
assert.equal(info.isFile(), true, `fixture must contain only files/directories: ${path}`)
result[relative] = (await readFile(path)).toString('base64')
}
await visit(root)
return result
}
async function snapshotPrimary(profile, credentialPath) {
return {
profile: await snapshotTree(profile),
credential: (await readFile(credentialPath)).toString('base64'),
}
}
async function recoveryPage(app) {
return await waitFor(async () => {
for (const page of app.windows()) {
if (page.isClosed()) continue
await page.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => {})
if (await page.locator('#recoveryPanel.visible').count().catch(() => 0)) return page
}
return null
}, 'unsafe-primary recovery page')
}
async function onboardingPage(app) {
return await waitFor(async () => {
for (const page of app.windows()) {
if (page.isClosed()) continue
await page.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => {})
if (await page.locator('#setup-form').count().catch(() => 0)) return page
}
return null
}, 'recovery-profile onboarding window')
}
async function controlPage(app) {
const observe = (page) => {
if (observedRendererPages.has(page)) return
observedRendererPages.add(page)
page.on('console', (message) => {
rendererDiagnostics.push({ type: `console:${message.type()}`, text: message.text().slice(0, 1_000) })
})
page.on('pageerror', (error) => {
rendererDiagnostics.push({ type: 'pageerror', text: String(error?.message || error).slice(0, 1_000) })
})
}
try {
const page = await waitFor(async () => {
for (const candidate of app.windows()) {
if (candidate.isClosed()) continue
observe(candidate)
await candidate.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => {})
let pathname = ''
try { pathname = new URL(candidate.url()).pathname } catch { pathname = '' }
if (pathname !== '/control/chat' && pathname !== '/control/chat/new') continue
if (await candidate.locator('.chat-textarea').count().catch(() => 0)) return candidate
}
return null
}, 'recovery-profile Control UI', 120_000)
await waitFor(() => {
try { return new URL(page.url()).pathname === '/control/chat/new' } catch { return false }
}, 'new-chat draft route', 30_000)
return page
} catch (error) {
const windows = await Promise.all(app.windows().map(async (page) => ({
url: page.url(),
title: await page.title().catch(() => ''),
body: await page.locator('body').innerText().catch(() => '').then((value) => value.slice(0, 1_500)),
})))
throw new Error(
`${error.message}; windows=${JSON.stringify(windows)}; renderer=${JSON.stringify(rendererDiagnostics.slice(-30))}`,
)
}
}
async function sendChat(page, prompt) {
const textarea = page.locator('.chat-textarea')
await textarea.waitFor({ state: 'visible', timeout: 30_000 })
try {
await waitFor(async () => {
// The Control UI can finish one last reactive render after its textarea
// first becomes visible. Refill before sending if that render replaced
// the input; never press Enter until the exact synthetic prompt remains.
if (await textarea.inputValue().catch(() => '') !== prompt) {
await textarea.fill(prompt)
}
return await page.locator('.chat-send-btn.is-ready').count().catch(() => 0)
}, 'ready recovery chat composer', 10_000)
} catch (error) {
const state = await page.evaluate(() => ({
href: window.location.href,
sessionKey: document.querySelector('.chat-label')?.getAttribute('title') || '',
textareaValue: document.querySelector('.chat-textarea')?.value || '',
sendButtonClass: document.querySelector('.chat-send-btn')?.className || '',
bodyText: document.body.innerText.slice(0, 1_000),
})).catch(() => ({ unavailable: true }))
throw new Error(`${error.message}; composer=${JSON.stringify(state)}`)
}
await textarea.press('Enter')
await page.locator('.msg-ai').filter({ hasText: REPLY }).last().waitFor({
state: 'visible',
timeout: 60_000,
})
await waitFor(async () => (
await page.locator('.chat-thread').getAttribute('aria-busy') === 'false'
), 'completed recovery chat turn', 60_000)
}
function launchEnvironment(isolatedHome, providerPort, sourceEnvironment = process.env) {
const inherited = { ...sourceEnvironment }
for (const name of Object.keys(inherited)) {
if (name === 'DISPLAY' || name === 'XAUTHORITY') continue
const upperName = name.toUpperCase()
if (
name.startsWith('OPENSQUILLA_')
|| ['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY'].includes(upperName)
|| /(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH)/i.test(name)
|| /^(?:AWS|AZURE|GOOGLE|ANTHROPIC|OPENAI|OPENROUTER|MINIMAX|DEEPSEEK|GROQ|MISTRAL|COHERE|GEMINI|OLLAMA|XAI|MOONSHOT|DASHSCOPE|SILICONFLOW|ZHIPU|BAIDU|VOLCENGINE|TENCENT|ALIYUN|HF|HUGGINGFACE)_/i.test(name)
) {
delete inherited[name]
}
}
return {
...inherited,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
OPENSQUILLA_DESKTOP_REPO_ROOT: repoRoot,
OPENSQUILLA_DESKTOP_SECRET_STORAGE: 'plain',
OPENSQUILLA_USER_STATE_DIR: join(isolatedHome, 'user-state'),
OPENSQUILLA_TEST_PROFILE_LOCK_ROOT: '1',
OPENSQUILLA_DESKTOP_GATEWAY_PORT: '18898',
OPENSQUILLA_DESKTOP_DISABLE_AUTO_UPDATE: '1',
OPENSQUILLA_OPENROUTER_LIVE_PRICING: '0',
OPENSQUILLA_GATEWAY_WORKSPACE_DIR: '',
OPENSQUILLA_WORKSPACE_DIR: '',
OPENSQUILLA_GATEWAY_STATE_DIR: '',
OPENSQUILLA_E2E_PROVIDER_PORT: String(providerPort),
PYTHONFAULTHANDLER: '1',
HTTP_PROXY: 'http://127.0.0.1:1',
HTTPS_PROXY: 'http://127.0.0.1:1',
ALL_PROXY: 'http://127.0.0.1:1',
NO_PROXY: '127.0.0.1,localhost',
http_proxy: 'http://127.0.0.1:1',
https_proxy: 'http://127.0.0.1:1',
all_proxy: 'http://127.0.0.1:1',
no_proxy: '127.0.0.1,localhost',
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
}
}
async function launchDesktop(userData, isolatedHome, providerPort) {
return await electron.launch({
args: ['--use-mock-keychain', `--user-data-dir=${userData}`, packageRoot],
env: launchEnvironment(isolatedHome, providerPort),
})
}
async function startFakeProvider() {
const requests = []
const server = createServer(async (request, response) => {
const chunks = []
for await (const chunk of request) chunks.push(chunk)
const raw = Buffer.concat(chunks).toString('utf8')
let payload = {}
try { payload = raw ? JSON.parse(raw) : {} } catch { payload = {} }
requests.push({ method: request.method, url: request.url, payload })
if (request.method === 'GET' && request.url?.endsWith('/models')) {
response.writeHead(200, { 'content-type': 'application/json' })
response.end(JSON.stringify({ object: 'list', data: [{ id: 'synthetic-recovery-model' }] }))
return
}
if (request.method === 'GET' && request.url?.endsWith('/api/tags')) {
response.writeHead(200, { 'content-type': 'application/json' })
response.end(JSON.stringify({ models: [{ name: 'synthetic-recovery-model' }] }))
return
}
if (request.method !== 'POST' || !request.url?.endsWith('/chat/completions')) {
response.writeHead(404, { 'content-type': 'application/json' })
response.end(JSON.stringify({ error: { message: 'synthetic endpoint not found' } }))
return
}
if (payload.stream === false) {
response.writeHead(200, { 'content-type': 'application/json' })
response.end(JSON.stringify({
id: 'chatcmpl-recovery-title',
object: 'chat.completion',
model: 'synthetic-recovery-model',
choices: [{ index: 0, message: { role: 'assistant', content: 'Recovery chat' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 },
}))
return
}
response.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'close',
})
response.write(`data: ${JSON.stringify({
id: 'chatcmpl-recovery-e2e',
object: 'chat.completion.chunk',
model: 'synthetic-recovery-model',
choices: [{ index: 0, delta: { role: 'assistant', content: REPLY }, finish_reason: null }],
})}\n\n`)
response.write(`data: ${JSON.stringify({
id: 'chatcmpl-recovery-e2e',
object: 'chat.completion.chunk',
model: 'synthetic-recovery-model',
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
usage: { prompt_tokens: 12, completion_tokens: 3, total_tokens: 15 },
})}\n\n`)
response.end('data: [DONE]\n\n')
})
await new Promise((resolveListen, rejectListen) => {
server.once('error', rejectListen)
server.listen(0, '127.0.0.1', resolveListen)
})
const address = server.address()
assert(address && typeof address === 'object')
return {
port: address.port,
requests,
close: () => new Promise((resolveClose, rejectClose) => {
server.close((error) => error ? rejectClose(error) : resolveClose())
}),
}
}
const root = await realpath(await mkdtemp(join(tmpdir(), 'opensquilla-electron-recovery-test-')))
const userData = join(root, 'user-data')
const isolatedHome = join(root, 'home')
const primaryHome = join(userData, 'opensquilla')
const primaryWorkspace = join(primaryHome, 'workspace')
const primaryState = join(primaryHome, 'state')
const primaryDatabase = join(primaryState, 'sessions.db')
const primaryCredential = join(userData, 'desktop-credential.json')
const missingWorkspace = join(root, 'missing-external-workspace')
await mkdir(primaryWorkspace, { recursive: true })
await mkdir(primaryState, { recursive: true })
await mkdir(isolatedHome, { recursive: true })
for (const [name, text] of [
['USER.md', 'synthetic primary user\n'],
['SOUL.md', 'synthetic primary soul\n'],
['IDENTITY.md', 'synthetic primary identity\n'],
['MEMORY.md', 'synthetic primary memory\n'],
]) {
await writeFile(join(primaryWorkspace, name), text, 'utf8')
}
await writeFile(
join(primaryHome, 'config.toml'),
[
`state_dir = ${JSON.stringify(primaryState)}`,
`workspace_dir = ${JSON.stringify(missingWorkspace)}`,
'',
'[llm]',
'provider = "ollama"',
'model = "primary-must-not-run"',
'base_url = "http://127.0.0.1:9/v1"',
'',
].join('\n'),
'utf8',
)
await writeFile(
primaryCredential,
JSON.stringify({
provider: 'ollama',
model: 'primary-must-not-run',
baseUrl: 'http://127.0.0.1:9/v1',
encryptedApiKey: PRIMARY_SENTINEL,
modelRoutingMode: 'direct',
routerMode: 'disabled',
searchProvider: 'duckduckgo',
encryption: 'plain',
createdAt: '2026-07-11T00:00:00.000Z',
updatedAt: '2026-07-11T00:00:00.000Z',
}, null, 2),
'utf8',
)
runPython(
'import sqlite3,sys; c=sqlite3.connect(sys.argv[1]); '
+ 'c.execute("CREATE TABLE synthetic_primary_sessions (id TEXT PRIMARY KEY, body TEXT)"); '
+ 'c.execute("INSERT INTO synthetic_primary_sessions VALUES (?, ?)", '
+ '("primary-session", "primary transcript must remain unchanged")); c.commit(); c.close()',
[primaryDatabase],
)
const primaryBefore = await snapshotPrimary(primaryHome, primaryCredential)
const fakeProvider = await startFakeProvider()
const scrubbedEnvironmentProbe = launchEnvironment(isolatedHome, fakeProvider.port, {
...process.env,
OPENAI_API_KEY: 'synthetic-real-provider-key-must-not-leak',
AWS_PROFILE: 'synthetic-real-provider-profile-must-not-leak',
OPENSQUILLA_STATE_DIR: '/synthetic/external/state/must-not-leak',
})
assert.equal(scrubbedEnvironmentProbe.OPENAI_API_KEY, undefined)
assert.equal(scrubbedEnvironmentProbe.AWS_PROFILE, undefined)
assert.equal(scrubbedEnvironmentProbe.OPENSQUILLA_STATE_DIR, undefined)
assert.equal(scrubbedEnvironmentProbe.HTTP_PROXY, 'http://127.0.0.1:1')
assert.equal(scrubbedEnvironmentProbe.NO_PROXY, '127.0.0.1,localhost')
let app
try {
app = await launchDesktop(userData, isolatedHome, fakeProvider.port)
const recovery = await recoveryPage(app)
assert.equal(await recovery.locator('#recoveryCode').innerText(), 'effective_workspace_missing')
assert.equal(await recovery.locator('#copyCredential').isChecked(), false)
if (screenshotPath) {
await mkdir(dirname(screenshotPath), { recursive: true })
await recovery.screenshot({ path: screenshotPath })
}
await recovery.locator('#createRecovery').click()
const setup = await onboardingPage(app)
await setup.locator('[data-screen="0"].active .next-button').click()
await setup.locator('[data-screen="1"].active').waitFor({ state: 'visible', timeout: 10_000 })
await setup.locator('#providerMoreToggle').click()
await setup.locator('#providerGrid [data-provider="minimax_openai"]').click()
await setup.locator('#baseUrl').fill(`http://127.0.0.1:${fakeProvider.port}/v1`)
await setup.locator('#model').fill('synthetic-recovery-model')
await setup.locator('#apiKey').fill(RECOVERY_SYNTHETIC_KEY)
await setup.locator('[data-screen="1"].active .next-button').click()
await setup.locator('[data-screen="4"].active').waitFor({ state: 'visible', timeout: 10_000 })
await setup.locator('#finish').click()
const firstControl = await controlPage(app)
const recoveryIds = (await readdir(join(userData, 'recovery-profiles'))).sort()
assert.equal(recoveryIds.length, 1)
const recoveryId = recoveryIds[0]
assert.match(recoveryId, /^[0-9a-f-]{36}$/i)
const recoveryRoot = join(userData, 'recovery-profiles', recoveryId)
const recoveryHome = join(recoveryRoot, 'opensquilla')
const recoveryWorkspace = join(recoveryHome, 'workspace')
const recoveryState = join(recoveryHome, 'state')
const recoveryCredential = await readFile(join(recoveryRoot, 'desktop-credential.json'), 'utf8')
assert.doesNotMatch(recoveryCredential, new RegExp(PRIMARY_SENTINEL))
assert.equal(JSON.parse(recoveryCredential).provider, 'minimax_openai')
for (const name of ['USER.md', 'SOUL.md', 'IDENTITY.md', 'MEMORY.md']) {
assert.equal((await lstat(join(recoveryWorkspace, name))).isFile(), true)
}
assert.deepEqual(await snapshotPrimary(primaryHome, primaryCredential), primaryBefore)
await sendChat(firstControl, FIRST_PROMPT)
await waitFor(
() => fakeProvider.requests.some((item) => JSON.stringify(item.payload).includes(FIRST_PROMPT)),
'first prompt at local fake provider',
)
assert.deepEqual(await snapshotPrimary(primaryHome, primaryCredential), primaryBefore)
await app.close()
app = null
const recoveryDatabase = join(recoveryState, 'sessions.db')
const firstTranscript = JSON.parse(runPython(
'import json,sqlite3,sys; c=sqlite3.connect(f"file:{sys.argv[1]}?mode=ro", uri=True); '
+ 'rows=c.execute("SELECT role,content FROM transcript_entries ORDER BY id").fetchall(); '
+ 'c.close(); print(json.dumps(rows))',
[recoveryDatabase],
))
assert(firstTranscript.some(([role, content]) => role === 'user' && String(content).includes(FIRST_PROMPT)))
assert(firstTranscript.some(([role, content]) => role === 'assistant' && String(content).includes(REPLY)))
assert.deepEqual(await snapshotPrimary(primaryHome, primaryCredential), primaryBefore)
const persistedBeforeRestart = JSON.parse(
await readFile(join(userData, 'desktop-profile-context.json'), 'utf8'),
)
assert.equal(persistedBeforeRestart.active_profile_kind, 'recovery')
assert.equal(persistedBeforeRestart.active_recovery_id, recoveryId)
app = await launchDesktop(userData, isolatedHome, fakeProvider.port)
const restartedRecovery = await recoveryPage(app)
assert.equal(
await restartedRecovery.locator('#recoveryCode').innerText(),
'desktop_recovery_profile_confirmation_required',
)
assert.equal(
await restartedRecovery.locator('#recoveryTitle').innerText(),
'Confirm recovery profile',
)
assert.equal(
await restartedRecovery.locator('#recoveryIntro').innerText(),
'OpenSquilla is waiting for you to confirm the selected isolated recovery profile before it starts. This does not mean your primary profile is unsafe.',
)
const persistedWhileBlocked = JSON.parse(
await readFile(join(userData, 'desktop-profile-context.json'), 'utf8'),
)
assert.equal(persistedWhileBlocked.active_profile_kind, 'recovery')
assert.equal(persistedWhileBlocked.active_recovery_id, recoveryId)
const existingOptions = await restartedRecovery.locator('#recoveryProfiles option').evaluateAll((items) => (
items.map((item) => ({ value: item.value, label: item.textContent || '' }))
))
assert(existingOptions.some((item) => item.value === recoveryId && item.label.includes(recoveryHome)))
await restartedRecovery.locator('#recoveryProfiles').selectOption(recoveryId)
await restartedRecovery.locator('#continueRecovery').click()
const secondControl = await controlPage(app)
const activeState = await secondControl.evaluate(() => window.opensquillaDesktop.getRecoveryState())
assert.equal(activeState.activeProfile.kind, 'recovery')
assert.equal(activeState.activeProfile.recoveryId, recoveryId)
assert.equal(activeState.activeProfile.home, recoveryHome)
const persistedConversation = secondControl.locator('.sidebar-history-item', {
hasText: 'Recovery chat',
})
await persistedConversation.waitFor({ state: 'visible', timeout: 30_000 })
await persistedConversation.click()
await waitFor(() => secondControl.url().includes('?session='), 'persisted recovery chat route')
await secondControl.locator('.msg-user').filter({ hasText: FIRST_PROMPT }).last().waitFor({
state: 'visible',
timeout: 30_000,
})
await secondControl.locator('.msg-ai').filter({ hasText: REPLY }).last().waitFor({
state: 'visible',
timeout: 30_000,
})
await sendChat(secondControl, SECOND_PROMPT)
await waitFor(
() => fakeProvider.requests.some((item) => JSON.stringify(item.payload).includes(SECOND_PROMPT)),
'second prompt at local fake provider',
)
// Reset is a credential/onboarding action, not a profile wipe. Trigger it
// from the live Control UI and prove that config, identity Markdown, and the
// recovery chat database remain in place after the gateway has drained.
const recoveryConfigBeforeReset = await readFile(join(recoveryHome, 'config.toml'))
await secondControl.evaluate(() => {
void window.opensquillaDesktop.resetDesktopSettings()
return true
})
await onboardingPage(app)
assert.deepEqual(
await readFile(join(recoveryHome, 'config.toml')),
recoveryConfigBeforeReset,
'Reset setup must preserve config.toml byte-for-byte',
)
await assert.rejects(
lstat(join(recoveryRoot, 'desktop-credential.json')),
(error) => error && error.code === 'ENOENT',
)
for (const name of ['USER.md', 'SOUL.md', 'IDENTITY.md', 'MEMORY.md']) {
assert.equal((await lstat(join(recoveryWorkspace, name))).isFile(), true)
}
await app.close()
app = null
const finalTranscript = JSON.parse(runPython(
'import json,sqlite3,sys; c=sqlite3.connect(f"file:{sys.argv[1]}?mode=ro", uri=True); '
+ 'rows=c.execute("SELECT role,content FROM transcript_entries ORDER BY id").fetchall(); '
+ 'c.close(); print(json.dumps(rows))',
[recoveryDatabase],
))
for (const prompt of [FIRST_PROMPT, SECOND_PROMPT]) {
assert(finalTranscript.some(([role, content]) => role === 'user' && String(content).includes(prompt)))
}
assert(finalTranscript.filter(([role, content]) => role === 'assistant' && String(content).includes(REPLY)).length >= 2)
assert.deepEqual(await snapshotPrimary(primaryHome, primaryCredential), primaryBefore)
console.log(JSON.stringify({
ok: true,
recoveryId,
chatsCompleted: 2,
explicitContinueVerified: true,
resetPreservedProfileData: true,
primaryBytesUnchanged: true,
localProviderRequests: fakeProvider.requests.length,
}, null, 2))
} catch (error) {
const requestSummary = fakeProvider.requests.map((item) => ({
method: item.method,
url: item.url,
stream: item.payload?.stream,
}))
const recoveryIds = await readdir(join(userData, 'recovery-profiles')).catch(() => [])
const desktopLog = await readFile(join(userData, 'logs', 'desktop.log'), 'utf8').catch(() => '')
const gatewayPid = desktopLog
.trim()
.split('\n')
.reverse()
.map((line) => {
try { return JSON.parse(line) } catch { return null }
})
.find((entry) => entry?.event === 'gateway_spawned' && Number.isSafeInteger(entry.pid))
?.pid
let gatewayProcessTree = []
const gatewaySamples = []
let faulthandlerSignal = { attempted: false }
let sessionsDbLsof = { attempted: false }
if (process.platform === 'darwin' && gatewayPid && process.env.CI_REPORT_DIR) {
const processResult = spawnSync('/bin/ps', ['-axo', 'pid=,ppid=,command='], {
encoding: 'utf8',
timeout: 10_000,
})
const processes = String(processResult.stdout || '')
.split('\n')
.map((line) => line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/))
.filter(Boolean)
.map((match) => ({ pid: Number(match[1]), ppid: Number(match[2]), command: match[3] }))
const descendantPids = new Set([gatewayPid])
let changed = true
while (changed) {
changed = false
for (const candidate of processes) {
if (!descendantPids.has(candidate.ppid) || descendantPids.has(candidate.pid)) continue
descendantPids.add(candidate.pid)
changed = true
}
}
gatewayProcessTree = processes.filter((candidate) => descendantPids.has(candidate.pid))
for (const candidate of gatewayProcessTree) {
const samplePath = join(
process.env.CI_REPORT_DIR,
`gateway-process-${candidate.pid}.sample.txt`,
)
const result = spawnSync(
'/usr/bin/sample',
[String(candidate.pid), '3', '-file', samplePath],
{ encoding: 'utf8', timeout: 10_000 },
)
gatewaySamples.push({
pid: candidate.pid,
status: result.status,
signal: result.signal,
error: result.error?.message || '',
stderr: String(result.stderr || '').slice(-1_000),
})
}
if (recoveryIds.length === 1) {
const sessionsDb = join(
userData,
'recovery-profiles',
recoveryIds[0],
'opensquilla',
'state',
'sessions.db',
)
const result = spawnSync('/usr/sbin/lsof', ['-n', '-P', '--', sessionsDb], {
encoding: 'utf8',
timeout: 10_000,
})
const output = `${result.stdout || ''}${result.stderr || ''}`
await writeFile(join(process.env.CI_REPORT_DIR, 'sessions-db-lsof.txt'), output, 'utf8')
sessionsDbLsof = { attempted: true, status: result.status, bytes: output.length }
}
const pythonChild = gatewayProcessTree.find((candidate) => (
candidate.pid !== gatewayPid && /(?:^|[/\\])python(?:3(?:\.\d+)?)?(?:\s|$)/i.test(candidate.command)
))
if (pythonChild) {
try {
process.kill(pythonChild.pid, 'SIGABRT')
faulthandlerSignal = { attempted: true, pid: pythonChild.pid, sent: true }
await delay(1_000)
} catch (signalError) {
faulthandlerSignal = {
attempted: true,
pid: pythonChild.pid,
sent: false,
error: signalError?.message || String(signalError),
}
}
}
}
const gatewayLog = recoveryIds.length === 1
? await readFile(
join(userData, 'recovery-profiles', recoveryIds[0], 'logs', 'gateway.log'),
'utf8',
).catch(() => '')
: ''
const debugLog = recoveryIds.length === 1
? await readFile(
join(userData, 'recovery-profiles', recoveryIds[0], 'opensquilla', 'logs', 'debug.log'),
'utf8',
).catch(() => '')
: ''
const recoveryStateEntries = recoveryIds.length === 1
? await readdir(
join(userData, 'recovery-profiles', recoveryIds[0], 'opensquilla', 'state'),
{ withFileTypes: true },
).then((entries) => entries.map((entry) => ({
name: entry.name,
kind: entry.isFile() ? 'file' : entry.isDirectory() ? 'directory' : 'other',
}))).catch(() => [])
: []
console.error(JSON.stringify({
requestSummary,
desktopLogTail: desktopLog.slice(-4000),
gatewayProcessTree,
gatewaySamples,
faulthandlerSignal,
sessionsDbLsof,
gatewayLogTail: gatewayLog.slice(-4000),
debugLogTail: debugLog.slice(-8000),
recoveryStateEntries,
}, null, 2))
throw error
} finally {
await app?.close().catch(() => {})
await fakeProvider.close().catch(() => {})
await rm(root, { recursive: true, force: true }).catch(() => {})
}
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict'
import {
macCodeSignatureIsAdHoc,
secretStorageBackendForPolicy,
shouldUseChromiumMockKeychainForPolicy,
} from '../dist/secret-storage-policy.js'
assert.equal(macCodeSignatureIsAdHoc('Signature=adhoc'), true)
assert.equal(macCodeSignatureIsAdHoc('CodeDirectory flags=0x10002(adhoc,runtime)'), true)
assert.equal(macCodeSignatureIsAdHoc('Authority=Developer ID Application: OpenSquilla'), false)
assert.equal(secretStorageBackendForPolicy({
envMode: undefined,
platform: 'darwin',
appPackaged: true,
codesignDiagnostic: 'Signature=adhoc',
}), 'plain')
assert.equal(secretStorageBackendForPolicy({
envMode: undefined,
platform: 'darwin',
appPackaged: true,
codesignDiagnostic: 'Authority=Developer ID Application: OpenSquilla',
}), 'safeStorage')
assert.equal(secretStorageBackendForPolicy({
envMode: 'safeStorage',
platform: 'darwin',
appPackaged: true,
codesignDiagnostic: 'Signature=adhoc',
}), 'safeStorage')
assert.equal(secretStorageBackendForPolicy({
envMode: 'plain',
platform: 'darwin',
appPackaged: true,
codesignDiagnostic: 'Authority=Developer ID Application: OpenSquilla',
}), 'plain')
assert.equal(secretStorageBackendForPolicy({
envMode: undefined,
platform: 'win32',
appPackaged: true,
codesignDiagnostic: '',
}), 'safeStorage')
assert.equal(shouldUseChromiumMockKeychainForPolicy({
envMode: undefined,
platform: 'darwin',
appPackaged: true,
codesignDiagnostic: 'Signature=adhoc',
}), true)
assert.equal(shouldUseChromiumMockKeychainForPolicy({
envMode: 'safeStorage',
platform: 'darwin',
appPackaged: true,
codesignDiagnostic: 'Signature=adhoc',
}), false)
assert.equal(shouldUseChromiumMockKeychainForPolicy({
envMode: undefined,
platform: 'darwin',
appPackaged: true,
codesignDiagnostic: 'Authority=Developer ID Application: OpenSquilla',
}), false)
assert.equal(shouldUseChromiumMockKeychainForPolicy({
envMode: undefined,
platform: 'win32',
appPackaged: true,
codesignDiagnostic: '',
}), false)
console.log('Secret storage policy tests passed.')
@@ -0,0 +1,103 @@
import { strict as assert } from 'node:assert'
import { mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { fileURLToPath } from 'node:url'
import { _electron as electron } from 'playwright'
const scriptDir = dirname(fileURLToPath(import.meta.url))
const packageRoot = resolve(scriptDir, '..')
const repoRoot = resolve(packageRoot, '../..')
const recoveryId = '11234567-89ab-4cde-8fab-0123456789ab'
async function waitFor(check, label, timeoutMs = 60_000) {
const startedAt = Date.now()
let lastError
while (Date.now() - startedAt < timeoutMs) {
try {
const value = await check()
if (value) return value
} catch (error) {
lastError = error
}
await delay(250)
}
throw new Error(`Timed out waiting for ${label}: ${lastError?.message || lastError || ''}`)
}
const root = await realpath(await mkdtemp(join(tmpdir(), 'opensquilla-unsafe-profile-test-')))
const userData = join(root, 'user-data')
const isolatedHome = join(root, 'home')
const outside = join(root, 'outside')
const recoveryRoot = join(userData, 'recovery-profiles')
const selectedRoot = join(recoveryRoot, recoveryId)
await mkdir(recoveryRoot, { recursive: true })
await mkdir(isolatedHome, { recursive: true })
await mkdir(outside, { recursive: true })
const unsafeUpdateState = join(outside, 'opensquilla', 'state', 'desktop-update.json')
const unsafeUpdateBytes = JSON.stringify({
snoozedVersion: '0.0.1',
snoozedUntil: '2099-01-01T00:00:00.000Z',
}, null, 2)
await mkdir(dirname(unsafeUpdateState), { recursive: true })
await writeFile(unsafeUpdateState, unsafeUpdateBytes, 'utf8')
await symlink(outside, selectedRoot, process.platform === 'win32' ? 'junction' : 'dir')
await writeFile(
join(userData, 'desktop-profile-context.json'),
JSON.stringify({
schema_version: 1,
active_profile_kind: 'recovery',
active_recovery_id: recoveryId,
attention_acknowledgement: null,
updated_at: '2026-07-11T00:00:00.000Z',
}, null, 2),
'utf8',
)
let app
try {
app = await electron.launch({
args: ['--use-mock-keychain', `--user-data-dir=${userData}`, packageRoot],
env: {
...process.env,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
OPENSQUILLA_DESKTOP_REPO_ROOT: repoRoot,
OPENSQUILLA_DESKTOP_SECRET_STORAGE: 'plain',
OPENSQUILLA_USER_STATE_DIR: join(isolatedHome, 'user-state'),
OPENSQUILLA_TEST_PROFILE_LOCK_ROOT: '1',
OPENSQUILLA_DESKTOP_GATEWAY_PORT: '18896',
OPENSQUILLA_DESKTOP_DISABLE_AUTO_UPDATE: '1',
OPENSQUILLA_DESKTOP_MOCK_UPDATE_VERSION: '9.9.9',
LANG: 'en_US.UTF-8',
LC_ALL: 'en_US.UTF-8',
},
})
const page = await waitFor(async () => {
for (const candidate of app.windows()) {
if (candidate.isClosed()) continue
await candidate.waitForLoadState('domcontentloaded', { timeout: 5_000 }).catch(() => {})
if (await candidate.locator('#recoveryPanel.visible').count().catch(() => 0)) return candidate
}
return null
}, 'unsafe recovery selection page')
assert.equal(
await page.locator('#recoveryCode').innerText(),
'desktop_selected_recovery_profile_unsafe',
)
await delay(1_000)
assert.deepEqual(await readdir(outside), ['opensquilla'])
assert.equal(
await readFile(unsafeUpdateState, 'utf8'),
unsafeUpdateBytes,
'updater persistence must not read or rewrite an unsafe selected recovery profile',
)
console.log(JSON.stringify({ ok: true, stableCode: 'desktop_selected_recovery_profile_unsafe' }))
} finally {
await app?.close().catch(() => {})
await rm(root, { recursive: true, force: true }).catch(() => {})
}
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict'
import {
parseOpenSquillaReleaseTag,
selectMacPrereleaseCandidate,
} from '../dist/update-feed-resolver.js'
// This exercises the resolver shipped after Preview 2. It proves that clients
// containing this resolver can select later releases; it does not prove that
// the already-published Preview 1/2 binaries can discover Preview 3.
// --- tag parsing: PEP440 rc, semver rc, stable, and rejects ---
assert.deepEqual(parseOpenSquillaReleaseTag('v0.5.0rc2'), { base: '0.5.0', rc: 2 })
assert.deepEqual(parseOpenSquillaReleaseTag('0.5.0-rc2'), { base: '0.5.0', rc: 2 })
assert.deepEqual(parseOpenSquillaReleaseTag('v0.5.0-rc.3'), { base: '0.5.0', rc: 3 })
assert.deepEqual(parseOpenSquillaReleaseTag('v0.5.0'), { base: '0.5.0', rc: null })
assert.equal(parseOpenSquillaReleaseTag('website-2026-01'), null)
assert.equal(parseOpenSquillaReleaseTag('v0.5'), null)
const withMacFeed = (tag) => ({ tag_name: tag, assets: [{ name: 'latest-mac.yml' }] })
const noMacFeed = (tag) => ({ tag_name: tag, assets: [{ name: 'OpenSquilla-mac.zip' }] })
// 1. A resolver-enabled client on 0.5.0-rc1 sees v0.5.0rc2 (PEP440 tag).
{
const c = selectMacPrereleaseCandidate({ base: '0.5.0', rc: 1 }, [
withMacFeed('v0.5.0rc2'),
withMacFeed('v0.5.0rc1'),
])
assert.ok(c, 'rc1 should find rc2')
assert.equal(c.tag, 'v0.5.0rc2')
assert.equal(c.version, '0.5.0-rc2')
assert.equal(c.feedUrl, 'https://github.com/opensquilla/opensquilla/releases/download/v0.5.0rc2')
}
// 2. A resolver-enabled client on 0.5.0-rc2 sees v0.5.0rc3.
{
const c = selectMacPrereleaseCandidate(
{ base: '0.5.0', rc: 2 },
[withMacFeed('v0.5.0rc3'), withMacFeed('v0.5.0rc2')],
)
assert.ok(c)
assert.equal(c.tag, 'v0.5.0rc3')
assert.equal(c.version, '0.5.0-rc3')
}
// 3. 0.5.0-rc2 sees the final stable v0.5.0 (stable outranks a later rc).
{
const c = selectMacPrereleaseCandidate({ base: '0.5.0', rc: 2 }, [
withMacFeed('v0.5.0'),
withMacFeed('v0.5.0rc3'),
withMacFeed('v0.5.0rc2'),
])
assert.ok(c, 'rc2 should find a candidate')
assert.equal(c.tag, 'v0.5.0')
assert.equal(c.version, '0.5.0')
}
// 2b. Two-digit rc ordering is numeric, not string: rc9 sees rc10 (not the
// reverse). electron-updater's own semver gate sorts rc10 below rc9, which is
// why the resolver path also sets allowDowngrade — see main.ts.
{
const c = selectMacPrereleaseCandidate({ base: '0.5.0', rc: 9 }, [
withMacFeed('v0.5.0rc10'),
withMacFeed('v0.5.0rc9'),
])
assert.ok(c, 'rc9 should find rc10')
assert.equal(c.tag, 'v0.5.0rc10')
assert.equal(c.version, '0.5.0-rc10')
}
// rc10 does not regress to rc9.
{
const c = selectMacPrereleaseCandidate({ base: '0.5.0', rc: 10 }, [
withMacFeed('v0.5.0rc10'),
withMacFeed('v0.5.0rc9'),
])
assert.equal(c, null, 'rc10 must not pick the lower rc9')
}
// 4. A prerelease does NOT jump to a different base's preview (0.5.0-rc2 ignores v0.6.0rc1).
{
const c = selectMacPrereleaseCandidate({ base: '0.5.0', rc: 2 }, [
withMacFeed('v0.6.0rc1'),
withMacFeed('v0.5.0rc2'),
])
assert.equal(c, null, 'rc2 must not cross to a different base')
}
// 4a. A newer same-base release without latest-mac.yml is skipped.
{
const c = selectMacPrereleaseCandidate({ base: '0.5.0', rc: 1 }, [noMacFeed('v0.5.0rc2')])
assert.equal(c, null, 'candidate without latest-mac.yml is skipped')
}
// 4b. When the highest release lacks the feed, fall back to the highest that has it.
{
const c = selectMacPrereleaseCandidate({ base: '0.5.0', rc: 1 }, [
noMacFeed('v0.5.0rc3'),
withMacFeed('v0.5.0rc2'),
])
assert.ok(c, 'should fall back to rc2 which has the feed')
assert.equal(c.tag, 'v0.5.0rc2')
}
// 5. No newer same-base release → no candidate (current rc is the latest).
{
const c = selectMacPrereleaseCandidate({ base: '0.5.0', rc: 2 }, [withMacFeed('v0.5.0rc2')])
assert.equal(c, null, 'only the current rc exists → up to date')
}
// 6. Draft releases are ignored.
{
const c = selectMacPrereleaseCandidate({ base: '0.5.0', rc: 1 }, [
{ tag_name: 'v0.5.0rc2', draft: true, assets: [{ name: 'latest-mac.yml' }] },
])
assert.equal(c, null, 'draft releases are not upgrade candidates')
}
console.log('Update resolver tests passed.')
@@ -0,0 +1,47 @@
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptDir = dirname(fileURLToPath(import.meta.url))
const packageRoot = resolve(scriptDir, '..')
const packageJsonPath = join(packageRoot, 'package.json')
const macIconPath = join(packageRoot, 'assets', 'icon.icns')
const windowsIconPath = join(packageRoot, 'assets', 'icon.ico')
const failures = []
function fail(message) {
failures.push(message)
}
function expectEqual(actual, expected, label) {
if (actual !== expected) {
fail(`${label} must be ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
}
}
const pkg = JSON.parse(await readFile(packageJsonPath, 'utf8'))
const build = pkg.build ?? {}
if (!existsSync(macIconPath)) {
fail(`macOS icon is missing at ${macIconPath}`)
}
if (!existsSync(windowsIconPath)) {
fail(`Windows icon is missing at ${windowsIconPath}`)
}
expectEqual(build.mac?.icon, 'assets/icon.icns', 'build.mac.icon')
expectEqual(build.dmg?.icon, 'assets/icon.icns', 'build.dmg.icon')
expectEqual(build.win?.icon, 'assets/icon.ico', 'build.win.icon')
expectEqual(build.nsis?.installerIcon, 'assets/icon.ico', 'build.nsis.installerIcon')
expectEqual(build.nsis?.uninstallerIcon, 'assets/icon.ico', 'build.nsis.uninstallerIcon')
if (failures.length > 0) {
console.error('OpenSquilla desktop icon verification failed:')
for (const failure of failures) console.error(`- ${failure}`)
process.exit(1)
}
console.log('OpenSquilla desktop icon verification passed.')
+400
View File
@@ -0,0 +1,400 @@
import { spawnSync } from 'node:child_process'
import { existsSync, statSync } from 'node:fs'
import { readdir, readFile, stat } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
const scriptDir = dirname(fileURLToPath(import.meta.url))
const packageRoot = resolve(scriptDir, '..')
const repoRoot = resolve(packageRoot, '..', '..')
const runtimeGatewayDir = join(packageRoot, 'runtime', 'gateway')
const sourceMainPath = join(packageRoot, 'src', 'main.ts')
const compiledMainPath = join(packageRoot, 'dist', 'main.js')
const packageJsonPath = join(packageRoot, 'package.json')
const desktopOutputDir = join(repoRoot, 'dist', 'desktop-electron')
const failures = []
function fail(message) {
failures.push(message)
}
function pathIsFileSync(path) {
try {
return statSync(path).isFile()
} catch {
return false
}
}
function gatewayBinary(root, platform) {
const binaryName = platform === 'win32' ? 'opensquilla-gateway.exe' : 'opensquilla-gateway'
const candidates = [join(root, 'opensquilla-gateway', binaryName), join(root, binaryName)]
const binary = candidates.find(pathIsFileSync)
return { binary, candidates }
}
function verificationEnv() {
const keys = [
'PATH',
'Path',
'HOME',
'USERPROFILE',
'TMPDIR',
'TEMP',
'TMP',
'SystemRoot',
'ComSpec',
'PATHEXT',
'LANG',
'LC_ALL',
]
const env = {
PYTHONUNBUFFERED: '1',
PYTHONUTF8: '1',
PYTHONIOENCODING: 'utf-8:replace',
}
for (const key of keys) {
if (process.env[key] !== undefined) env[key] = process.env[key]
}
return env
}
function outputTail(output) {
const tail = [output.stdout, output.stderr]
.filter(Boolean)
.join('\n')
.trim()
.split(/\r?\n/)
.slice(-12)
.join('\n')
.trim()
return tail ? `\nOutput tail:\n${tail}` : ''
}
function requireGatewayBinary(root, label, platform) {
const { binary, candidates } = gatewayBinary(root, platform)
if (!binary) {
fail(`${label} gateway binary is missing; checked ${candidates.join(', ')}`)
return null
}
return binary
}
function verifyGatewayCommand(binary, label, args, options = {}) {
const result = spawnSync(binary, args, {
cwd: dirname(binary),
encoding: 'utf8',
env: verificationEnv(),
input: options.input,
timeout: options.timeout ?? 30000,
windowsHide: true,
})
const commandLabel = `${label} gateway command ${args.join(' ')}`
if (result.error) {
fail(`${commandLabel} could not start: ${result.error.message}${outputTail(result)}`)
return
}
if (result.status !== 0) {
const exitReason = result.signal ? `signal ${result.signal}` : `exit ${result.status}`
fail(`${commandLabel} failed with ${exitReason}${outputTail(result)}`)
}
}
function verifyMacLightgbmRuntime(files, label) {
const lightgbmLibs = files.filter((path) => path.endsWith(join('lightgbm', 'lib', 'lib_lightgbm.dylib')))
if (lightgbmLibs.length === 0) {
fail(`${label} runtime is missing lightgbm/lib/lib_lightgbm.dylib`)
return
}
for (const lightgbmLib of lightgbmLibs) {
const bundledLibomp = join(dirname(lightgbmLib), 'libomp.dylib')
if (!files.includes(bundledLibomp)) {
fail(`${label} runtime is missing bundled libomp.dylib next to ${lightgbmLib}`)
}
if (process.platform !== 'darwin') continue
const result = spawnSync('otool', ['-L', lightgbmLib], {
encoding: 'utf8',
windowsHide: true,
})
if (result.error) {
fail(`${label} could not inspect ${lightgbmLib} with otool: ${result.error.message}`)
} else if (result.status !== 0) {
fail(`${label} otool -L failed for ${lightgbmLib}${outputTail(result)}`)
} else if (!result.stdout.includes('@loader_path/libomp.dylib')) {
fail(`${label} ${lightgbmLib} does not load libomp.dylib via @loader_path`)
}
}
}
async function listFiles(root) {
const files = []
async function walk(dir) {
let entries
try {
entries = await readdir(dir, { withFileTypes: true })
} catch (error) {
fail(`${root} could not be read: ${error instanceof Error ? error.message : String(error)}`)
return
}
for (const entry of entries) {
const path = join(dir, entry.name)
if (entry.isDirectory()) {
await walk(path)
} else if (entry.isFile() && entry.name !== '.DS_Store') {
files.push(path)
}
}
}
await walk(root)
return files
}
async function verifyRuntime(root, label, { platform, executeCommands }) {
if (!existsSync(root)) {
fail(`${label} runtime is missing at ${root}`)
return
}
const info = await stat(root).catch(() => null)
if (!info?.isDirectory()) {
fail(`${label} runtime is not a directory: ${root}`)
return
}
const files = await listFiles(root)
if (files.length === 0) {
fail(`${label} runtime is empty: ${root}`)
return
}
const compatFile = files.find((path) => path.endsWith(join('opensquilla', 'compat', 'aiosqlite.py')))
if (!compatFile) {
fail(`${label} runtime is missing opensquilla/compat/aiosqlite.py`)
} else {
const source = await readFile(compatFile, 'utf8')
if (!source.includes('async def create_function(') || !source.includes('self._conn.create_function')) {
fail(`${label} runtime aiosqlite.py does not contain _AsyncConnection.create_function`)
}
}
const binary = requireGatewayBinary(root, label, platform)
if (executeCommands) {
if (!binary) return
verifyGatewayCommand(binary, label, ['--help'])
verifyGatewayCommand(binary, label, ['code-task', '--help'])
verifyGatewayCommand(binary, label, ['code-task', 'stage-task-file'], { input: 'desktop package smoke\n' })
verifyGatewayCommand(binary, label, ['code-task', 'smoke-imports'], { timeout: 120000 })
verifyGatewayCommand(binary, label, ['code-task', 'smoke-router'], { timeout: 120000 })
}
if (platform === 'darwin') {
verifyMacLightgbmRuntime(files, label)
}
}
function verifyMainProcess(source, label) {
for (const expected of [
'gatewayStartPromise',
'openOrResumeDesktopApp',
'ensureGatewayStarted',
'isCurrentWindowAtControlUi',
]) {
if (!source.includes(expected)) fail(`${label} main process is missing ${expected}`)
}
const helperIndex = source.indexOf('async function openOrResumeDesktopApp')
const createIndex = source.indexOf('await createMainWindow()', helperIndex)
const ensureIndex = source.indexOf('ensureGatewayStarted()', helperIndex)
if (helperIndex === -1 || createIndex === -1 || ensureIndex === -1 || createIndex > ensureIndex) {
fail(`${label} main process does not create the desktop window before gateway startup`)
}
if (!/app\.on\(['"]activate['"][\s\S]{0,240}openOrResumeDesktopApp/.test(source)) {
fail(`${label} main process activate handler does not route through openOrResumeDesktopApp`)
}
if (!/second-instance[\s\S]{0,240}openOrResumeDesktopApp/.test(source)) {
fail(`${label} main process second-instance handler does not route through openOrResumeDesktopApp`)
}
const loadCurrentIndex = source.indexOf('async function loadControlUiIntoCurrentWindow')
const controlGuardIndex = source.indexOf('isCurrentWindowAtControlUi(window, gatewayUrl)', loadCurrentIndex)
const controlLoadIndex = source.indexOf('loadControlUi(window, gatewayUrl)', loadCurrentIndex)
if (
loadCurrentIndex === -1
|| controlGuardIndex === -1
|| controlLoadIndex === -1
|| controlGuardIndex > controlLoadIndex
) {
fail(`${label} main process reloads the Control UI before checking whether it is already loaded`)
}
const onboardingIndex = source.indexOf('async function runOnboarding')
const onboardingWindowIndex = source.indexOf('onboardingWindow = new BrowserWindow', onboardingIndex)
const parentIndex = source.indexOf('const parentWindow = currentMainWindow()', onboardingIndex)
const parentOptionIndex = source.indexOf('parent: parentWindow ?? undefined', onboardingWindowIndex)
const modalOptionIndex = source.indexOf('modal: Boolean(parentWindow)', onboardingWindowIndex)
if (
onboardingIndex === -1
|| onboardingWindowIndex === -1
|| parentIndex === -1
|| parentOptionIndex === -1
|| modalOptionIndex === -1
|| parentIndex > onboardingWindowIndex
) {
fail(`${label} main process does not make first-run onboarding an owned modal child window`)
}
const focusIndex = source.indexOf('function focusMainWindow')
const focusSource = focusIndex === -1 ? '' : source.slice(focusIndex, focusIndex + 800)
const onboardingFocusMatch = /if\s*\(\s*focusOnboardingWindow\(\)\s*\)\s*(?:\{\s*)?return true\s*;?/.exec(focusSource)
const mainFocusMatch =
/if\s*\(\s*!mainWindow\s*\|\|\s*mainWindow\.isDestroyed\(\)\s*\)\s*(?:\{\s*)?return false\s*;?/.exec(
focusSource,
)
if (
focusIndex === -1
|| !onboardingFocusMatch
|| !mainFocusMatch
|| onboardingFocusMatch.index > mainFocusMatch.index
) {
fail(`${label} main process does not prefer the onboarding window when focusing`)
}
}
async function verifySourceMain() {
if (!existsSync(sourceMainPath)) {
fail(`source Electron main process is missing at ${sourceMainPath}`)
return
}
verifyMainProcess(await readFile(sourceMainPath, 'utf8'), 'source')
}
async function verifyInstallerDataPolicy() {
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'))
if (packageJson.build?.nsis?.deleteAppDataOnUninstall !== false) {
fail('NSIS uninstall must preserve Desktop profile data (deleteAppDataOnUninstall=false)')
}
}
async function verifyCompiledMain() {
if (!existsSync(compiledMainPath)) {
fail(`compiled Electron main process is missing at ${compiledMainPath}; run npm run build first`)
return
}
verifyMainProcess(await readFile(compiledMainPath, 'utf8'), 'compiled')
}
async function findGeneratedBundles(root) {
const bundles = []
const seenResourcesDirs = new Set()
if (!existsSync(root)) return bundles
function addBundle(bundle) {
if (seenResourcesDirs.has(bundle.resourcesDir)) return
seenResourcesDirs.add(bundle.resourcesDir)
bundles.push(bundle)
}
async function walk(dir, depth) {
if (depth > 5) return
const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
for (const entry of entries) {
if (!entry.isDirectory()) continue
const path = join(dir, entry.name)
if (entry.name.endsWith('.app')) {
addBundle({
label: `app bundle ${path}`,
resourcesDir: join(path, 'Contents', 'Resources'),
platform: 'darwin',
})
} else if (entry.name === 'win-unpacked' || entry.name === 'linux-unpacked') {
addBundle({
label: `generated bundle ${path}`,
resourcesDir: join(path, 'resources'),
platform: entry.name === 'win-unpacked' ? 'win32' : 'linux',
})
} else {
await walk(path, depth + 1)
}
}
}
await walk(root, 0)
return bundles
}
async function readAsarText(asarPath, innerPath) {
const asar = await import('@electron/asar')
return asar.extractFile(asarPath, innerPath).toString('utf8')
}
async function verifyAsarPackageVersion(asarPath, label) {
let packageJson
try {
packageJson = JSON.parse(await readAsarText(asarPath, 'package.json'))
} catch (error) {
fail(`${label} app.asar package.json could not be inspected: ${error instanceof Error ? error.message : String(error)}`)
return
}
const version = typeof packageJson.version === 'string' ? packageJson.version : ''
const semverPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
if (!semverPattern.test(version) || /\d(?:a|b|rc)\d+$/.test(version)) {
fail(
`${label} app.asar package.json version is not npm semver: ${JSON.stringify(version)}; ` +
'prereleases must use 0.5.0-rc2 style, not 0.5.0rc2'
)
}
}
async function verifyGeneratedBundle({ label, resourcesDir, platform }) {
await verifyRuntime(join(resourcesDir, 'runtime', 'gateway'), label, {
platform,
executeCommands: platform === process.platform,
})
const asarPath = join(resourcesDir, 'app.asar')
if (!existsSync(asarPath)) {
fail(`${label} is missing app.asar`)
return
}
try {
await verifyAsarPackageVersion(asarPath, label)
verifyMainProcess(await readAsarText(asarPath, 'dist/main.js'), label)
} catch (error) {
fail(`${label} app.asar could not be inspected: ${error instanceof Error ? error.message : String(error)}`)
}
}
await verifyRuntime(runtimeGatewayDir, 'source', { platform: process.platform, executeCommands: true })
await verifyInstallerDataPolicy()
await verifySourceMain()
await verifyCompiledMain()
const generatedBundles = await findGeneratedBundles(desktopOutputDir)
for (const bundle of generatedBundles) {
await verifyGeneratedBundle(bundle)
}
if (failures.length > 0) {
console.error('OpenSquilla desktop package verification failed:')
for (const failure of failures) console.error(`- ${failure}`)
process.exit(1)
}
console.log('OpenSquilla desktop package verification passed.')
+933
View File
@@ -0,0 +1,933 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Starting OpenSquilla</title>
<style>
:root {
color-scheme: light dark;
--bg: #f8f7f3;
--surface: #fffefa;
--ink: #1d1f1d;
--muted: #686e66;
--dim: #8b9088;
--line: rgba(34, 31, 27, 0.12);
--accent: #F26A1B;
--danger: #a23a22;
}
/* The app theme defaults to "system", so match the OS here — a dark-OS user
gets a dark splash that hands off to the dark app with no luminance flash.
These mirror the app's canonical "Instrument" dark tokens. */
@media (prefers-color-scheme: dark) {
:root {
--bg: #0E0F11;
--surface: #161719;
--ink: #e8e8ea;
--muted: #9a9aa0;
--dim: #6b6b72;
--line: rgba(255, 255, 255, 0.12);
--accent: #F26A1B;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
overflow: hidden;
background: var(--bg);
color: var(--ink);
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 40px;
}
.boot {
width: min(520px, calc(100vw - 48px));
display: grid;
gap: 34px;
padding: 4px;
}
.brand {
display: grid;
gap: 12px;
}
.mark-row {
align-items: center;
display: flex;
gap: 11px;
}
.mark {
width: 22px;
height: 22px;
border: 2px solid var(--ink);
position: relative;
}
.mark::after {
content: "";
position: absolute;
bottom: -2px;
right: -7px;
width: 9px;
height: 2px;
background: var(--accent);
}
h1 {
margin: 0;
font-size: 28px;
font-weight: 760;
letter-spacing: 0;
line-height: 1;
}
.subtitle {
color: var(--muted);
font-size: 14px;
line-height: 1.5;
margin: 0;
max-width: 42ch;
}
.status {
display: grid;
gap: 13px;
padding-top: 2px;
}
.status-line {
height: 2px;
overflow: hidden;
background: rgba(29, 31, 29, 0.1);
}
.status-line::before {
animation: progress 1.6s ease-in-out infinite;
background: var(--accent);
content: "";
display: block;
height: 100%;
width: 38%;
}
/* On a startup failure the splash is paused, not working — freeze the
indeterminate sweep so it doesn't contradict the "Startup paused" state. */
body.errored .status-line::before {
animation: none;
opacity: 0.2;
}
.status-copy {
align-items: baseline;
display: flex;
justify-content: space-between;
gap: 18px;
}
.phase {
color: var(--ink);
font-size: 14px;
font-weight: 680;
min-width: 0;
}
.timer {
color: var(--dim);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.steps {
display: grid;
gap: 8px;
grid-template-columns: repeat(3, 1fr);
}
.step {
border-top: 1px solid var(--line);
color: var(--dim);
font-size: 11px;
font-weight: 720;
letter-spacing: 0.06em;
padding-top: 10px;
text-transform: uppercase;
transition: color 160ms ease, border-color 160ms ease;
}
.step.active {
border-color: var(--accent);
color: var(--ink);
}
.step.done {
border-color: rgba(29, 31, 29, 0.32);
color: var(--muted);
}
.error {
display: none;
gap: 13px;
padding: 16px;
border: 1px solid rgba(162, 58, 34, 0.28);
background: #fff8f5;
color: var(--danger);
}
.error.visible { display: grid; }
.recovery {
display: none;
gap: 18px;
padding: 20px;
border: 1px solid rgba(242, 106, 27, 0.28);
background: var(--surface);
}
.recovery.visible { display: grid; }
body.recovering .status,
body.recovering .error { display: none; }
.recovery h2 {
margin: 0;
font-size: 18px;
line-height: 1.2;
}
.recovery p {
color: var(--muted);
font-size: 13px;
line-height: 1.5;
margin: 0;
}
.recovery-code {
color: var(--dim);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
overflow-wrap: anywhere;
}
.recovery-group {
display: grid;
gap: 8px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
.recovery-group[hidden] {
display: none;
}
.recovery-group label,
.recovery-group legend {
color: var(--ink);
font-size: 12px;
font-weight: 720;
}
.recovery-group fieldset {
border: 0;
display: grid;
gap: 8px;
margin: 0;
padding: 0;
}
.recovery-row {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.recovery-row select {
min-height: 34px;
min-width: min(300px, 100%);
flex: 1;
border: 1px solid var(--line);
background: var(--surface);
color: var(--ink);
font: inherit;
font-size: 12px;
padding: 0 9px;
}
.check-row {
align-items: flex-start;
color: var(--muted);
display: flex;
font-size: 12px;
font-weight: 500;
gap: 8px;
line-height: 1.4;
}
.check-row input { margin-top: 2px; }
.recovery-status {
min-height: 1.5em;
color: var(--danger);
font-size: 12px;
}
.recovery button:focus-visible,
.recovery select:focus-visible,
.recovery input:focus-visible,
.recovery [tabindex="-1"]:focus-visible {
outline: 3px solid color-mix(in srgb, var(--accent) 35%, transparent);
outline-offset: 2px;
}
.recovery button:disabled { cursor: wait; opacity: 0.55; }
.error strong {
font-size: 13px;
}
.error p {
color: #75584f;
font-size: 13px;
line-height: 1.45;
margin: 0;
white-space: pre-line;
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
button {
min-height: 34px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--ink);
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 720;
padding: 0 12px;
}
button.primary {
border-color: var(--accent);
background: var(--accent);
color: #fff;
}
@keyframes progress {
0% { transform: translateX(-110%); opacity: 0.35; }
24% { opacity: 1; }
100% { transform: translateX(270%); opacity: 0.45; }
}
@media (max-width: 560px) {
.shell { padding: 28px; }
.status-copy {
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
.steps {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
}
</style>
</head>
<body>
<div class="shell">
<main class="boot" aria-label="OpenSquilla startup" data-i18n-aria="bootAria">
<section class="brand">
<div class="mark-row">
<span class="mark" aria-hidden="true"></span>
<h1>OpenSquilla</h1>
</div>
<p class="subtitle" data-i18n="subtitle">Starting the desktop control surface.</p>
</section>
<section class="status" aria-live="polite">
<div class="status-line" aria-hidden="true"></div>
<div class="status-copy">
<span class="phase" id="phase" data-i18n="phaseDefault">Preparing desktop profile</span>
<span class="timer" id="timer">00.0s</span>
</div>
<div class="steps" aria-hidden="true">
<span class="step active" data-step="profile" data-i18n="stepProfile">Profile</span>
<span class="step" data-step="gateway" data-i18n="stepGateway">Gateway</span>
<span class="step" data-step="control" data-i18n="stepControl">Control</span>
</div>
</section>
<section class="error" id="errorPanel" aria-live="polite">
<strong data-i18n="errorTitle">Startup needs attention</strong>
<p id="errorMessage" data-i18n="errorDefault">The gateway did not finish starting.</p>
<div class="actions">
<button id="revealLog" type="button" data-i18n="revealLog">Reveal log</button>
<button id="resetSetup" type="button" data-i18n="resetSetup">Reset setup</button>
<button id="quit" type="button" data-i18n="quit">Quit</button>
<button id="retry" class="primary" type="button" data-i18n="retry">Retry</button>
</div>
</section>
<section class="recovery" id="recoveryPanel" role="region" aria-labelledby="recoveryTitle">
<div>
<h2 id="recoveryTitle" tabindex="-1" data-i18n="recoveryTitle">Your primary profile needs recovery</h2>
<p id="recoveryIntro" data-i18n="recoveryIntro">OpenSquilla paused the primary gateway because it could not prove that writing is safe. Your data has not been merged or deleted.</p>
</div>
<div class="recovery-code" id="recoveryCode"></div>
<div class="recovery-group" id="workspaceGroup">
<label for="workspaceCandidates" data-i18n="workspaceLabel">Choose a verified workspace</label>
<div class="recovery-row">
<select id="workspaceCandidates"></select>
<button id="chooseWorkspace" type="button" data-i18n="chooseWorkspace">Use selected workspace</button>
<button id="browseWorkspace" type="button" data-i18n="browseWorkspace">Browse…</button>
</div>
</div>
<div class="recovery-group" id="existingRecoveryGroup">
<label for="recoveryProfiles" data-i18n="existingRecoveryLabel">Existing recovery profile</label>
<div class="recovery-row">
<select id="recoveryProfiles"></select>
<button id="continueRecovery" type="button" data-i18n="continueRecovery">Continue recovery profile</button>
</div>
</div>
<div class="recovery-group">
<fieldset>
<legend data-i18n="newRecoveryLabel">New recovery profile</legend>
<label class="check-row" for="copyCredential">
<input id="copyCredential" type="checkbox">
<span data-i18n="copyCredential">Copy the decryptable provider credential from the primary profile</span>
</label>
</fieldset>
<div class="recovery-row">
<button id="createRecovery" class="primary" type="button" data-i18n="createRecovery">Create recovery profile</button>
<button id="retryPrimary" type="button" data-i18n="retryPrimary">Retry primary profile</button>
<button id="returnPrimary" type="button" data-i18n="returnPrimary">Return to primary profile</button>
</div>
</div>
<div class="recovery-group" id="transactionRecoveryGroup" hidden>
<div class="recovery-row">
<button id="recoverTransaction" class="primary" type="button" data-i18n="recoverTransaction">Safely recover interrupted operation</button>
</div>
</div>
<div class="recovery-group" id="cleanupAbandonGroup" hidden>
<p data-i18n="abandonCleanupHelp">A cleanup stopped partway through. Preserve every remaining file and leave the interrupted transaction before choosing what to use next.</p>
<div class="recovery-row">
<button id="abandonCleanup" class="primary" type="button" data-i18n="abandonCleanup">Preserve remaining data and continue</button>
</div>
</div>
<p class="recovery-status" id="recoveryStatus" role="status" aria-live="polite"></p>
<div class="actions">
<button id="revealProfile" type="button" data-i18n="revealProfile">Show profile location</button>
<button id="revealBackups" type="button" data-i18n="revealBackups">Show backup location</button>
<button id="copyDiagnostics" type="button" data-i18n="copyDiagnostics">Copy diagnostics</button>
<button id="recoveryQuit" type="button" data-i18n="quit">Quit</button>
</div>
</section>
</main>
</div>
<script>
// Static-string catalog for the splash screen. The main process owns the
// live phase labels (already localized when sent over IPC), so this only
// covers the pre-IPC chrome: subtitle, default phase, step chips, error
// panel, and buttons. The first sub-second paint shows the English markup;
// we swap to the OS locale as soon as getOsLocale() resolves.
const MESSAGES = {
en: {
title: 'Starting OpenSquilla', bootAria: 'OpenSquilla startup',
subtitle: 'Starting the desktop control surface.',
phaseDefault: 'Preparing desktop profile',
stepProfile: 'Profile', stepGateway: 'Gateway', stepControl: 'Control',
errorTitle: 'Startup needs attention',
errorDefault: 'The gateway did not finish starting.',
revealLog: 'Reveal log', resetSetup: 'Reset setup', quit: 'Quit', retry: 'Retry',
startupPaused: 'Startup paused',
resetConfirm: 'Reset desktop setup? This clears the saved provider credential and reopens onboarding. Your workspace path, identity, memory, and chat history are kept.',
resetPhase: 'Resetting desktop setup',
resetProgress: 'Clearing saved setup and restarting onboarding.',
resetFailed: 'Reset failed',
recoveryTitle: 'Your primary profile needs recovery',
recoveryIntro: 'OpenSquilla paused the primary gateway because it could not prove that writing is safe. Your data has not been merged or deleted.',
recoveryConfirmationTitle: 'Confirm recovery profile',
recoveryConfirmationIntro: 'OpenSquilla is waiting for you to confirm the selected isolated recovery profile before it starts. This does not mean your primary profile is unsafe.',
recoveryProfileUnsafeTitle: 'Recovery profile needs attention',
recoveryProfileUnsafeIntro: 'OpenSquilla could not prove that the selected recovery profile is safe to write. The primary profile has not been selected or changed.',
cleanupRecoveryTitle: 'A data cleanup stopped partway through',
cleanupRecoveryIntro: 'Some confirmed items may already have been deleted. OpenSquilla stopped before touching anything it could not verify and preserved every remaining file.',
workspaceLabel: 'Choose a verified workspace', chooseWorkspace: 'Use selected workspace', browseWorkspace: 'Browse…',
existingRecoveryLabel: 'Existing recovery profile', continueRecovery: 'Continue recovery profile', noRecoveryProfiles: 'No recovery profiles yet',
newRecoveryLabel: 'New recovery profile', copyCredential: 'Copy the decryptable provider credential from the primary profile', createRecovery: 'Create recovery profile',
retryPrimary: 'Retry primary profile', returnPrimary: 'Return to primary profile', recoverTransaction: 'Safely recover interrupted operation', abandonCleanup: 'Preserve remaining data and continue', abandonCleanupHelp: 'A cleanup stopped partway through. Preserve every remaining file and leave the interrupted transaction before choosing what to use next.', revealProfile: 'Show profile location', revealBackups: 'Show backup location',
copyDiagnostics: 'Copy diagnostics', diagnosticsCopied: 'Sanitized diagnostics copied.', recoveryWorking: 'Working…', noWorkspaceCandidates: 'No verified candidates; browse for a workspace.',
},
'zh-Hans': {
title: '正在启动 OpenSquilla', bootAria: 'OpenSquilla 启动',
subtitle: '正在启动桌面控制界面。',
phaseDefault: '正在准备桌面配置',
stepProfile: '配置', stepGateway: '网关', stepControl: '控制',
errorTitle: '启动需要处理',
errorDefault: '网关未能完成启动。',
revealLog: '显示日志', resetSetup: '重新配置', quit: '退出', retry: '重试',
startupPaused: '启动已暂停',
resetConfirm: '要重置桌面设置吗?这会清除已保存的提供商凭据并重新进入引导。工作区路径、身份、记忆和聊天记录都会保留。',
resetPhase: '正在重置桌面设置',
resetProgress: '正在清除已保存的设置并重新启动引导。',
resetFailed: '重置失败',
recoveryTitle: '主配置需要恢复',
recoveryIntro: 'OpenSquilla 无法证明写入安全,因此已暂停主网关。你的数据没有被合并或删除。',
recoveryConfirmationTitle: '确认继续恢复配置',
recoveryConfirmationIntro: 'OpenSquilla 正在等待你确认启动已选中的独立恢复配置。这不表示主配置不安全。',
recoveryProfileUnsafeTitle: '恢复配置需要处理',
recoveryProfileUnsafeIntro: 'OpenSquilla 无法证明已选恢复配置可以安全写入。主配置未被选中或修改。',
cleanupRecoveryTitle: '数据清理在中途停止',
cleanupRecoveryIntro: '部分已确认条目可能已经删除。OpenSquilla 在遇到无法验证的内容前已停止,并保留了所有剩余文件。',
workspaceLabel: '选择已验证的工作区', chooseWorkspace: '使用所选工作区', browseWorkspace: '浏览…',
existingRecoveryLabel: '现有恢复配置', continueRecovery: '继续恢复配置', noRecoveryProfiles: '尚无恢复配置',
newRecoveryLabel: '新建恢复配置', copyCredential: '从主配置复制可解密的提供商凭据', createRecovery: '创建恢复配置',
retryPrimary: '重试主配置', returnPrimary: '返回主配置', recoverTransaction: '安全恢复中断的操作', abandonCleanup: '保留剩余数据并继续', abandonCleanupHelp: '清理在中途停止。请先保留所有剩余文件并结束中断的事务,再选择接下来要使用的配置。', revealProfile: '显示配置位置', revealBackups: '显示备份位置',
copyDiagnostics: '复制诊断信息', diagnosticsCopied: '已复制脱敏诊断信息。', recoveryWorking: '正在处理…', noWorkspaceCandidates: '没有已验证候选;请浏览选择工作区。',
},
ja: {
title: 'OpenSquilla を起動しています', bootAria: 'OpenSquilla の起動',
subtitle: 'デスクトップのコントロール画面を起動しています。',
phaseDefault: 'デスクトッププロファイルを準備しています',
stepProfile: 'プロファイル', stepGateway: 'ゲートウェイ', stepControl: 'コントロール',
errorTitle: '起動の確認が必要です',
errorDefault: 'ゲートウェイの起動が完了しませんでした。',
revealLog: 'ログを表示', resetSetup: '設定をリセット', quit: '終了', retry: '再試行',
startupPaused: '起動を一時停止しました',
resetConfirm: 'デスクトップ設定をリセットしますか?保存済みのプロバイダー認証情報を消去してオンボーディングを再開します。ワークスペースのパス、ID、メモリ、チャット履歴は保持されます。',
resetPhase: 'デスクトップ設定をリセットしています',
resetProgress: '保存済み設定を消去し、オンボーディングを再開しています。',
resetFailed: 'リセットに失敗しました',
recoveryTitle: 'プライマリプロファイルの復旧が必要です',
recoveryIntro: '安全な書き込みを確認できないため、プライマリゲートウェイを一時停止しました。データの結合や削除は行っていません。',
recoveryConfirmationTitle: '復旧プロファイルを確認',
recoveryConfirmationIntro: '選択済みの独立した復旧プロファイルを起動する前に確認が必要です。プライマリプロファイルが危険という意味ではありません。',
recoveryProfileUnsafeTitle: '復旧プロファイルの確認が必要です',
recoveryProfileUnsafeIntro: '選択済みの復旧プロファイルに安全に書き込めることを確認できません。プライマリプロファイルは選択も変更もされていません。',
cleanupRecoveryTitle: 'データのクリーンアップが途中で停止しました',
cleanupRecoveryIntro: '確認済みの項目の一部は削除済みの可能性があります。検証できない項目に触れる前に停止し、残りのファイルをすべて保持しました。',
workspaceLabel: '検証済みワークスペースを選択', chooseWorkspace: '選択したワークスペースを使用', browseWorkspace: '参照…',
existingRecoveryLabel: '既存の復旧プロファイル', continueRecovery: '復旧プロファイルを続行', noRecoveryProfiles: '復旧プロファイルはまだありません',
newRecoveryLabel: '新しい復旧プロファイル', copyCredential: 'プライマリから復号可能なプロバイダー認証情報をコピー', createRecovery: '復旧プロファイルを作成',
retryPrimary: 'プライマリを再試行', returnPrimary: 'プライマリに戻る', recoverTransaction: '中断した操作を安全に復旧', abandonCleanup: '残りのデータを保持して続行', abandonCleanupHelp: 'クリーンアップが途中で停止しました。残っているすべてのファイルを保持し、中断したトランザクションを終了してから次に使うプロファイルを選択してください。', revealProfile: 'プロファイルの場所を表示', revealBackups: 'バックアップの場所を表示',
copyDiagnostics: '診断情報をコピー', diagnosticsCopied: 'サニタイズ済み診断情報をコピーしました。', recoveryWorking: '処理中…', noWorkspaceCandidates: '検証済み候補がありません。ワークスペースを参照してください。',
},
fr: {
title: 'Démarrage d\'OpenSquilla', bootAria: 'Démarrage d\'OpenSquilla',
subtitle: 'Démarrage de l\'interface de contrôle du bureau.',
phaseDefault: 'Préparation du profil de bureau',
stepProfile: 'Profil', stepGateway: 'Passerelle', stepControl: 'Contrôle',
errorTitle: 'Le démarrage nécessite votre attention',
errorDefault: 'La passerelle n\'a pas terminé son démarrage.',
revealLog: 'Afficher le journal', resetSetup: 'Réinitialiser', quit: 'Quitter', retry: 'Réessayer',
startupPaused: 'Démarrage en pause',
resetConfirm: 'Réinitialiser la configuration du bureau ? Cela efface les identifiants du fournisseur et relance lonboarding. Le chemin de lespace de travail, lidentité, la mémoire et lhistorique des discussions sont conservés.',
resetPhase: 'Réinitialisation de la configuration du bureau',
resetProgress: 'Suppression de la configuration enregistrée et relance de lonboarding.',
resetFailed: 'Échec de la réinitialisation',
recoveryTitle: 'Le profil principal doit être récupéré',
recoveryIntro: 'OpenSquilla a suspendu la passerelle principale car une écriture sûre ne pouvait pas être garantie. Vos données nont été ni fusionnées ni supprimées.',
recoveryConfirmationTitle: 'Confirmer le profil de récupération',
recoveryConfirmationIntro: 'OpenSquilla attend votre confirmation avant de démarrer le profil de récupération isolé sélectionné. Cela ne signifie pas que le profil principal est dangereux.',
recoveryProfileUnsafeTitle: 'Le profil de récupération nécessite votre attention',
recoveryProfileUnsafeIntro: 'OpenSquilla ne peut pas garantir une écriture sûre dans le profil de récupération sélectionné. Le profil principal na été ni sélectionné ni modifié.',
cleanupRecoveryTitle: 'Un nettoyage des données sest arrêté en cours de route',
cleanupRecoveryIntro: 'Certains éléments confirmés peuvent déjà avoir été supprimés. OpenSquilla sest arrêté avant tout élément non vérifiable et a conservé tous les fichiers restants.',
workspaceLabel: 'Choisir un espace de travail vérifié', chooseWorkspace: 'Utiliser lespace sélectionné', browseWorkspace: 'Parcourir…',
existingRecoveryLabel: 'Profil de récupération existant', continueRecovery: 'Continuer ce profil', noRecoveryProfiles: 'Aucun profil de récupération',
newRecoveryLabel: 'Nouveau profil de récupération', copyCredential: 'Copier lidentifiant fournisseur déchiffrable du profil principal', createRecovery: 'Créer un profil de récupération',
retryPrimary: 'Réessayer le profil principal', returnPrimary: 'Revenir au profil principal', recoverTransaction: 'Récupérer lopération interrompue en toute sécurité', abandonCleanup: 'Conserver les données restantes et continuer', abandonCleanupHelp: 'Un nettoyage sest arrêté en cours de route. Conservez tous les fichiers restants et quittez la transaction interrompue avant de choisir le profil à utiliser.', revealProfile: 'Afficher lemplacement du profil', revealBackups: 'Afficher les sauvegardes',
copyDiagnostics: 'Copier le diagnostic', diagnosticsCopied: 'Diagnostic expurgé copié.', recoveryWorking: 'Traitement…', noWorkspaceCandidates: 'Aucun candidat vérifié ; parcourez les dossiers.',
},
de: {
title: 'OpenSquilla wird gestartet', bootAria: 'OpenSquilla-Start',
subtitle: 'Die Desktop-Steueroberfläche wird gestartet.',
phaseDefault: 'Desktop-Profil wird vorbereitet',
stepProfile: 'Profil', stepGateway: 'Gateway', stepControl: 'Control',
errorTitle: 'Der Start erfordert Ihre Aufmerksamkeit',
errorDefault: 'Das Gateway hat den Start nicht abgeschlossen.',
revealLog: 'Protokoll anzeigen', resetSetup: 'Zurücksetzen', quit: 'Beenden', retry: 'Wiederholen',
startupPaused: 'Start angehalten',
resetConfirm: 'Desktop-Einrichtung zurücksetzen? Die gespeicherten Anbieter-Zugangsdaten werden gelöscht und das Onboarding wird neu geöffnet. Arbeitsbereichspfad, Identität, Speicher und Chatverlauf bleiben erhalten.',
resetPhase: 'Desktop-Einrichtung wird zurückgesetzt',
resetProgress: 'Gespeicherte Einrichtung wird gelöscht und Onboarding neu gestartet.',
resetFailed: 'Zurücksetzen fehlgeschlagen',
recoveryTitle: 'Das Hauptprofil muss wiederhergestellt werden',
recoveryIntro: 'OpenSquilla hat das Haupt-Gateway angehalten, weil sicheres Schreiben nicht bestätigt werden konnte. Daten wurden weder zusammengeführt noch gelöscht.',
recoveryConfirmationTitle: 'Wiederherstellungsprofil bestätigen',
recoveryConfirmationIntro: 'OpenSquilla wartet auf Ihre Bestätigung, bevor das ausgewählte isolierte Wiederherstellungsprofil gestartet wird. Dies bedeutet nicht, dass das Hauptprofil unsicher ist.',
recoveryProfileUnsafeTitle: 'Wiederherstellungsprofil erfordert Aufmerksamkeit',
recoveryProfileUnsafeIntro: 'OpenSquilla konnte sicheres Schreiben im ausgewählten Wiederherstellungsprofil nicht bestätigen. Das Hauptprofil wurde weder ausgewählt noch geändert.',
cleanupRecoveryTitle: 'Eine Datenbereinigung wurde unterbrochen',
cleanupRecoveryIntro: 'Einige bestätigte Elemente wurden möglicherweise bereits gelöscht. OpenSquilla stoppte vor nicht überprüfbaren Elementen und behielt alle verbleibenden Dateien.',
workspaceLabel: 'Geprüften Arbeitsbereich wählen', chooseWorkspace: 'Auswahl verwenden', browseWorkspace: 'Durchsuchen…',
existingRecoveryLabel: 'Vorhandenes Wiederherstellungsprofil', continueRecovery: 'Profil fortsetzen', noRecoveryProfiles: 'Noch keine Wiederherstellungsprofile',
newRecoveryLabel: 'Neues Wiederherstellungsprofil', copyCredential: 'Entschlüsselbare Anbieter-Anmeldedaten aus dem Hauptprofil kopieren', createRecovery: 'Wiederherstellungsprofil erstellen',
retryPrimary: 'Hauptprofil erneut prüfen', returnPrimary: 'Zum Hauptprofil zurückkehren', recoverTransaction: 'Unterbrochenen Vorgang sicher wiederherstellen', abandonCleanup: 'Verbleibende Daten behalten und fortfahren', abandonCleanupHelp: 'Eine Bereinigung wurde unterbrochen. Behalten Sie alle verbliebenen Dateien und verlassen Sie die unterbrochene Transaktion, bevor Sie das nächste Profil auswählen.', revealProfile: 'Profilordner anzeigen', revealBackups: 'Sicherungsordner anzeigen',
copyDiagnostics: 'Diagnose kopieren', diagnosticsCopied: 'Bereinigte Diagnose kopiert.', recoveryWorking: 'Wird ausgeführt…', noWorkspaceCandidates: 'Keine geprüften Kandidaten; bitte Arbeitsbereich auswählen.',
},
es: {
title: 'Iniciando OpenSquilla', bootAria: 'Inicio de OpenSquilla',
subtitle: 'Iniciando la superficie de control del escritorio.',
phaseDefault: 'Preparando el perfil de escritorio',
stepProfile: 'Perfil', stepGateway: 'Pasarela', stepControl: 'Control',
errorTitle: 'El inicio necesita atención',
errorDefault: 'La pasarela no terminó de iniciarse.',
revealLog: 'Mostrar registro', resetSetup: 'Restablecer', quit: 'Salir', retry: 'Reintentar',
startupPaused: 'Inicio en pausa',
resetConfirm: '¿Restablecer la configuración de escritorio? Esto borra la credencial del proveedor y vuelve a abrir la configuración inicial. Se conservan la ruta del espacio de trabajo, la identidad, la memoria y el historial de chat.',
resetPhase: 'Restableciendo configuración de escritorio',
resetProgress: 'Borrando la configuración guardada y reiniciando la incorporación.',
resetFailed: 'Error al restablecer',
recoveryTitle: 'El perfil principal necesita recuperación',
recoveryIntro: 'OpenSquilla pausó la pasarela principal porque no pudo demostrar que fuera seguro escribir. No se combinaron ni eliminaron datos.',
recoveryConfirmationTitle: 'Confirmar perfil de recuperación',
recoveryConfirmationIntro: 'OpenSquilla espera tu confirmación antes de iniciar el perfil de recuperación aislado seleccionado. Esto no significa que el perfil principal sea inseguro.',
recoveryProfileUnsafeTitle: 'El perfil de recuperación necesita atención',
recoveryProfileUnsafeIntro: 'OpenSquilla no pudo demostrar que fuera seguro escribir en el perfil de recuperación seleccionado. El perfil principal no se seleccionó ni modificó.',
cleanupRecoveryTitle: 'Una limpieza de datos se detuvo a mitad de camino',
cleanupRecoveryIntro: 'Es posible que algunos elementos confirmados ya se hayan eliminado. OpenSquilla se detuvo antes de tocar algo no verificable y conservó todos los archivos restantes.',
workspaceLabel: 'Elegir un espacio verificado', chooseWorkspace: 'Usar el espacio seleccionado', browseWorkspace: 'Examinar…',
existingRecoveryLabel: 'Perfil de recuperación existente', continueRecovery: 'Continuar perfil de recuperación', noRecoveryProfiles: 'Aún no hay perfiles de recuperación',
newRecoveryLabel: 'Nuevo perfil de recuperación', copyCredential: 'Copiar la credencial descifrable del proveedor desde el perfil principal', createRecovery: 'Crear perfil de recuperación',
retryPrimary: 'Reintentar perfil principal', returnPrimary: 'Volver al perfil principal', recoverTransaction: 'Recuperar de forma segura la operación interrumpida', abandonCleanup: 'Conservar los datos restantes y continuar', abandonCleanupHelp: 'Una limpieza se detuvo a mitad de camino. Conserva todos los archivos restantes y sal de la transacción interrumpida antes de elegir qué perfil usar.', revealProfile: 'Mostrar ubicación del perfil', revealBackups: 'Mostrar copias de seguridad',
copyDiagnostics: 'Copiar diagnóstico', diagnosticsCopied: 'Diagnóstico depurado copiado.', recoveryWorking: 'Procesando…', noWorkspaceCandidates: 'No hay candidatos verificados; busca un espacio de trabajo.',
},
};
let msg = MESSAGES.en;
const started = performance.now();
const phase = document.getElementById('phase');
const timer = document.getElementById('timer');
const errorPanel = document.getElementById('errorPanel');
const errorMessage = document.getElementById('errorMessage');
const recoveryPanel = document.getElementById('recoveryPanel');
const recoveryTitle = document.getElementById('recoveryTitle');
const recoveryIntro = document.getElementById('recoveryIntro');
const recoveryCode = document.getElementById('recoveryCode');
const recoveryStatus = document.getElementById('recoveryStatus');
const workspaceCandidates = document.getElementById('workspaceCandidates');
const recoveryProfiles = document.getElementById('recoveryProfiles');
let currentRecoveryState = null;
const steps = {
profile: document.querySelector('[data-step="profile"]'),
gateway: document.querySelector('[data-step="gateway"]'),
control: document.querySelector('[data-step="control"]'),
};
// Map the stable phaseId from main to one of the three visible step chips.
const stepMap = {
profile: 'profile',
'gateway-start': 'gateway',
'gateway-health': 'gateway',
control: 'control',
ready: 'control',
};
function applyLocale(locale) {
msg = MESSAGES[locale] || MESSAGES.en;
document.documentElement.lang = MESSAGES[locale] ? locale : 'en';
document.title = msg.title;
document.querySelectorAll('[data-i18n]').forEach((el) => {
const key = el.getAttribute('data-i18n');
if (msg[key]) el.textContent = msg[key];
});
document.querySelectorAll('[data-i18n-aria]').forEach((el) => {
const key = el.getAttribute('data-i18n-aria');
if (msg[key]) el.setAttribute('aria-label', msg[key]);
});
if (currentRecoveryState) renderRecoveryState(currentRecoveryState, false);
}
function setStepState(activeKey, ready) {
let passedActive = false;
for (const [key, step] of Object.entries(steps)) {
step.classList.remove('active', 'done');
if (key === activeKey) {
step.classList.add(ready ? 'done' : 'active');
passedActive = true;
} else if (!passedActive) {
step.classList.add('done');
}
}
}
function applyStatus(payload) {
const phaseId = payload && payload.phaseId ? String(payload.phaseId) : 'profile';
// The label is already localized by the main process; fall back to the
// local default phase string only when no label was provided.
phase.textContent = payload && payload.label ? String(payload.label) : msg.phaseDefault;
errorPanel.classList.remove('visible');
recoveryPanel.classList.remove('visible');
document.body.classList.remove('recovering');
// Leaving the error state: resume the sweep and the elapsed timer.
document.body.classList.remove('errored');
if (!timerInterval) timerInterval = setInterval(updateTimer, 100);
setStepState(stepMap[phaseId] || 'profile', phaseId === 'ready');
}
function applyError(payload) {
const message = payload && payload.message ? String(payload.message) : msg.errorDefault;
phase.textContent = msg.startupPaused;
errorMessage.textContent = message;
errorPanel.classList.add('visible');
recoveryPanel.classList.remove('visible');
document.body.classList.remove('recovering');
// Stop signalling progress: freeze the elapsed timer, pause the sweep (via
// the .errored class), and drop the active step highlight so a failed phase
// is not shown as still running.
document.body.classList.add('errored');
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
for (const step of Object.values(steps)) {
if (step) step.classList.remove('active');
}
}
function renderRecoveryState(state, moveFocus = true) {
currentRecoveryState = state || null;
const inspection = state && state.inspection;
const blocked = Boolean(state && state.blocked && inspection);
if (!blocked) {
recoveryPanel.classList.remove('visible');
document.body.classList.remove('recovering');
return;
}
const wasVisible = recoveryPanel.classList.contains('visible');
document.body.classList.add('recovering', 'errored');
errorPanel.classList.remove('visible');
recoveryPanel.classList.add('visible');
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
recoveryCode.textContent = String(inspection.stable_code || 'recovery_required');
const confirmationRequired = (
inspection.stable_code === 'desktop_recovery_profile_confirmation_required'
);
const cleanupInterrupted = inspection.stable_code === 'cleanup_transaction_incomplete';
const recoveryProfileUnsafe = (
!confirmationRequired && !cleanupInterrupted && state.activeProfile?.kind === 'recovery'
);
recoveryTitle.textContent = cleanupInterrupted
? msg.cleanupRecoveryTitle
: confirmationRequired
? msg.recoveryConfirmationTitle
: (recoveryProfileUnsafe ? msg.recoveryProfileUnsafeTitle : msg.recoveryTitle);
recoveryIntro.textContent = cleanupInterrupted
? msg.cleanupRecoveryIntro
: confirmationRequired
? msg.recoveryConfirmationIntro
: (recoveryProfileUnsafe ? msg.recoveryProfileUnsafeIntro : msg.recoveryIntro);
const candidates = Array.isArray(inspection.candidates)
? inspection.candidates.filter((item) => (
item
&& ['canonical', 'legacy', 'external'].includes(String(item.kind || ''))
&& item.exists
&& item.valid
))
: [];
const allowedActions = new Set(Array.isArray(inspection.allowed_actions)
? inspection.allowed_actions.map((item) => String(item))
: []);
const mayChooseWorkspace = allowedActions.has('choose-workspace');
workspaceCandidates.replaceChildren();
if (candidates.length) {
for (const candidate of candidates) {
const option = document.createElement('option');
option.value = String(candidate.path || '');
option.textContent = `${String(candidate.kind || 'workspace')}${String(candidate.path || '')}`;
workspaceCandidates.appendChild(option);
}
} else {
const option = document.createElement('option');
option.value = '';
option.textContent = msg.noWorkspaceCandidates;
workspaceCandidates.appendChild(option);
}
const profiles = Array.isArray(state.recoveryProfiles) ? state.recoveryProfiles : [];
recoveryProfiles.replaceChildren();
if (profiles.length) {
for (const profile of profiles) {
const option = document.createElement('option');
option.value = String(profile.id || '');
option.textContent = String(profile.home || profile.id || '');
recoveryProfiles.appendChild(option);
}
} else {
const option = document.createElement('option');
option.value = '';
option.textContent = msg.noRecoveryProfiles;
recoveryProfiles.appendChild(option);
}
document.getElementById('workspaceGroup').hidden = !mayChooseWorkspace;
document.getElementById('existingRecoveryGroup').hidden = (
profiles.length === 0 || !allowedActions.has('continue-recovery-profile')
);
document.getElementById('returnPrimary').hidden = state.activeProfile?.kind !== 'recovery';
document.getElementById('createRecovery').hidden = !allowedActions.has('create-recovery-profile');
document.getElementById('retryPrimary').hidden = !allowedActions.has('retry-primary-profile');
document.getElementById('transactionRecoveryGroup').hidden = !allowedActions.has('recover-transaction');
const mayAbandonCleanup = allowedActions.has('abandon-cleanup')
&& typeof api?.abandonCleanupTransaction === 'function';
document.getElementById('cleanupAbandonGroup').hidden = !mayAbandonCleanup;
document.getElementById('revealBackups').hidden = !allowedActions.has('show-backups');
document.getElementById('copyDiagnostics').hidden = !allowedActions.has('copy-diagnostics');
recoveryStatus.textContent = state.error ? String(state.error) : (state.busy ? msg.recoveryWorking : '');
for (const button of recoveryPanel.querySelectorAll('button')) button.disabled = Boolean(state.busy);
document.getElementById('chooseWorkspace').disabled = (
Boolean(state.busy) || !mayChooseWorkspace || candidates.length === 0
);
document.getElementById('browseWorkspace').disabled = Boolean(state.busy) || !mayChooseWorkspace;
document.getElementById('continueRecovery').disabled = (
Boolean(state.busy)
|| !allowedActions.has('continue-recovery-profile')
|| profiles.length === 0
);
document.getElementById('recoverTransaction').disabled = (
Boolean(state.busy) || !allowedActions.has('recover-transaction')
);
document.getElementById('abandonCleanup').disabled = (
Boolean(state.busy) || !mayAbandonCleanup
);
if (moveFocus && !wasVisible) recoveryTitle.focus();
}
async function runRecoveryAction(action) {
recoveryStatus.textContent = msg.recoveryWorking;
try {
const result = await action();
if (result && result.state) renderRecoveryState(result.state, false);
if (result && result.ok === false) {
recoveryStatus.textContent = String(result.error || msg.errorDefault);
}
return result;
} catch (error) {
recoveryStatus.textContent = error && error.message ? error.message : String(error);
return null;
}
}
async function resetSetup() {
if (!api || typeof api.resetDesktopSettings !== 'function') return;
const ok = window.confirm(msg.resetConfirm);
if (!ok) return;
phase.textContent = msg.resetPhase;
errorMessage.textContent = msg.resetProgress;
try {
const result = await api.resetDesktopSettings();
if (result && result.ok === false) {
throw new Error(String(result.detail || result.error || msg.errorDefault));
}
await api.retryStartup();
} catch (err) {
const message = err && err.message ? err.message : String(err);
phase.textContent = msg.startupPaused;
errorMessage.textContent = `${msg.resetFailed}: ${message}`;
errorPanel.classList.add('visible');
}
}
function updateTimer() {
timer.textContent = `${((performance.now() - started) / 1000).toFixed(1).padStart(4, '0')}s`;
}
let timerInterval = setInterval(updateTimer, 100);
const api = window.opensquillaDesktop;
if (api) {
if (typeof api.getOsLocale === 'function') {
api.getOsLocale().then((locale) => applyLocale(String(locale || 'en'))).catch(() => {});
}
api.onBootStatus(applyStatus);
api.onBootError(applyError);
if (typeof api.onRecoveryState === 'function') {
api.onRecoveryState((state) => renderRecoveryState(state));
}
api.getBootState().then((state) => {
if (state && state.recovery && state.recovery.blocked) renderRecoveryState(state.recovery);
else if (state && state.error) applyError(state.error);
else applyStatus(state && state.status ? state.status : null);
}).catch(() => applyStatus(null));
document.getElementById('retry').addEventListener('click', () => api.retryStartup());
document.getElementById('revealLog').addEventListener('click', () => api.revealGatewayLog());
document.getElementById('quit').addEventListener('click', () => api.quitApp());
const resetButton = document.getElementById('resetSetup');
if (typeof api.resetDesktopSettings === 'function') {
resetButton.addEventListener('click', () => resetSetup());
} else {
resetButton.style.display = 'none';
}
document.getElementById('chooseWorkspace').addEventListener('click', () => runRecoveryAction(() => (
api.chooseRecoveryWorkspace({ workspace: workspaceCandidates.value })
)));
document.getElementById('browseWorkspace').addEventListener('click', () => runRecoveryAction(() => (
api.chooseRecoveryWorkspace({})
)));
document.getElementById('continueRecovery').addEventListener('click', () => runRecoveryAction(() => (
api.launchSafeProfile({ mode: 'continue', recoveryId: recoveryProfiles.value })
)));
document.getElementById('createRecovery').addEventListener('click', () => runRecoveryAction(() => (
api.launchSafeProfile({
mode: 'create',
copyPrimaryCredential: document.getElementById('copyCredential').checked,
})
)));
document.getElementById('retryPrimary').addEventListener('click', () => runRecoveryAction(() => (
api.retryPrimaryProfile()
)));
document.getElementById('recoverTransaction').addEventListener('click', () => runRecoveryAction(() => (
api.recoverProfileTransaction()
)));
document.getElementById('abandonCleanup').addEventListener('click', () => runRecoveryAction(() => (
api.abandonCleanupTransaction()
)));
document.getElementById('returnPrimary').addEventListener('click', () => runRecoveryAction(() => (
api.returnPrimaryProfile()
)));
document.getElementById('revealProfile').addEventListener('click', () => (
api.revealRecoveryPath({ target: 'primary' })
));
document.getElementById('revealBackups').addEventListener('click', () => (
api.revealRecoveryPath({ target: 'backups' })
));
document.getElementById('copyDiagnostics').addEventListener('click', async () => {
if (await api.copyRecoveryDiagnostics()) recoveryStatus.textContent = msg.diagnosticsCopied;
});
document.getElementById('recoveryQuit').addEventListener('click', () => api.quitApp());
}
</script>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
export interface CliInvocation {
mode: 'bundled' | 'dev'
// Paste-ready replacement for the leading `opensquilla` CLI token in
// copyable commands surfaced by the Control UI.
prefix: string
}
export interface CliInvocationInput {
platform: NodeJS.Platform
mode: 'bundled' | 'dev'
binaryPath?: string
repoRoot?: string
stateDir: string
configPath: string
}
function quotePosix(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`
}
function quotePowerShell(value: string): string {
// PowerShell treats the Unicode smart quotes U+2018-U+201B as single-quote
// delimiters too; doubling is the only escape for every quote-class char.
return `'${value.replace(/['‘’‚‛]/g, (ch) => ch + ch)}'`
}
// The desktop gateway resolves its config and state roots from environment
// variables rather than CLI flags, so a paste-ready invocation must carry both
// alongside the packaged binary path — a bare binary call would silently
// operate on the default ~/.opensquilla world instead of the app's.
export function buildCliInvocation(input: CliInvocationInput): CliInvocation {
const windows = input.platform === 'win32'
const quote = windows ? quotePowerShell : quotePosix
const runner = input.mode === 'bundled'
? (windows ? `& ${quote(input.binaryPath ?? '')}` : quote(input.binaryPath ?? ''))
: `uv run --directory ${quote(input.repoRoot ?? '')} opensquilla`
const env = windows
? `$env:OPENSQUILLA_STATE_DIR = ${quote(input.stateDir)}; `
+ `$env:OPENSQUILLA_GATEWAY_CONFIG_PATH = ${quote(input.configPath)}; `
: `OPENSQUILLA_STATE_DIR=${quote(input.stateDir)} `
+ `OPENSQUILLA_GATEWAY_CONFIG_PATH=${quote(input.configPath)} `
return { mode: input.mode, prefix: `${env}${runner}` }
}
+226
View File
@@ -0,0 +1,226 @@
import { randomUUID } from 'node:crypto'
import { isAbsolute, relative, resolve, sep } from 'node:path'
export const DESKTOP_CLEANUP_MODES = [
'reset-current-settings',
'delete-current-profile',
'delete-all-user-data',
] as const
export type DesktopCleanupMode = typeof DESKTOP_CLEANUP_MODES[number]
export type DesktopCleanupProfileKind = 'primary' | 'recovery'
export type DesktopCleanupOutcome = 'ready' | 'blocked' | 'complete' | 'partial'
export interface DesktopCleanupItem {
kind: string
path: string
exists: boolean
identity: string | null
}
export interface DesktopCleanupReport {
schema_version: 1
outcome: DesktopCleanupOutcome
stable_code: string
mode: DesktopCleanupMode
items: DesktopCleanupItem[]
transaction_id: string
revision: number
scope_fingerprint: string
}
export interface DesktopCleanupSelection {
mode: DesktopCleanupMode
profileKind: DesktopCleanupProfileKind
recoveryId: string | null
profileKey: string
}
export interface TrustedDesktopCleanupPreview {
id: string
report: DesktopCleanupReport
selection: DesktopCleanupSelection
createdAt: number
}
const CLEANUP_MODE_SET = new Set<string>(DESKTOP_CLEANUP_MODES)
const CLEANUP_OUTCOME_SET = new Set<string>(['ready', 'blocked', 'complete', 'partial'])
function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
export function parseDesktopCleanupMode(value: unknown): DesktopCleanupMode | null {
return typeof value === 'string' && CLEANUP_MODE_SET.has(value)
? value as DesktopCleanupMode
: null
}
export function parseDesktopCleanupProtocol(value: unknown): DesktopCleanupReport {
const payload = record(value)
if (!payload || payload.schema_version !== 1) {
throw new Error('Cleanup command returned an unsupported protocol schema.')
}
const outcome = typeof payload.outcome === 'string' && CLEANUP_OUTCOME_SET.has(payload.outcome)
? payload.outcome as DesktopCleanupOutcome
: null
const mode = parseDesktopCleanupMode(payload.mode)
if (!outcome || !mode) throw new Error('Cleanup command returned an invalid outcome or mode.')
if (typeof payload.stable_code !== 'string' || !payload.stable_code) {
throw new Error('Cleanup command omitted its stable code.')
}
if (typeof payload.transaction_id !== 'string' || !payload.transaction_id) {
throw new Error('Cleanup command omitted its transaction id.')
}
if (!Number.isSafeInteger(payload.revision) || Number(payload.revision) < 0) {
throw new Error('Cleanup command returned an invalid revision.')
}
if (
typeof payload.scope_fingerprint !== 'string'
|| !/^[0-9a-f]{64}$/.test(payload.scope_fingerprint)
) {
throw new Error('Cleanup command returned an invalid scope fingerprint.')
}
if (!Array.isArray(payload.items)) {
throw new Error('Cleanup command returned an invalid inventory.')
}
const items = payload.items.map((value): DesktopCleanupItem => {
const item = record(value)
if (
!item
|| typeof item.kind !== 'string'
|| !item.kind
|| typeof item.path !== 'string'
|| !item.path
|| typeof item.exists !== 'boolean'
|| (item.identity !== null && typeof item.identity !== 'string')
) {
throw new Error('Cleanup command returned an invalid inventory item.')
}
return {
kind: item.kind,
path: item.path,
exists: item.exists,
identity: item.identity as string | null,
}
})
return {
schema_version: 1,
outcome,
stable_code: payload.stable_code,
mode,
items,
transaction_id: payload.transaction_id,
revision: Number(payload.revision),
scope_fingerprint: payload.scope_fingerprint,
}
}
export function cleanupSelectorArgs(
selection: DesktopCleanupSelection,
): string[] {
return [
'--mode', selection.mode,
'--profile-kind', selection.profileKind,
...(selection.profileKind === 'recovery' && selection.recoveryId
? ['--recovery-id', selection.recoveryId]
: []),
]
}
export function desktopCleanupScopeIsContained(
report: DesktopCleanupReport,
userData: string,
): boolean {
const root = resolve(userData)
return report.items.every((item) => {
const fromRoot = relative(root, resolve(item.path))
return fromRoot === '' || (
fromRoot !== '..'
&& !fromRoot.startsWith(`..${sep}`)
&& !isAbsolute(fromRoot)
)
})
}
export function sameDesktopCleanupScope(
displayed: DesktopCleanupReport,
refreshed: DesktopCleanupReport,
userData: string,
): boolean {
const signature = (report: DesktopCleanupReport) => report.items
.map((item) => `${item.kind}\u0000${resolve(item.path)}`)
.sort()
return displayed.mode === refreshed.mode
&& desktopCleanupScopeIsContained(displayed, userData)
&& desktopCleanupScopeIsContained(refreshed, userData)
&& JSON.stringify(signature(displayed)) === JSON.stringify(signature(refreshed))
}
/**
* Holds the one main-process-authoritative cleanup inventory that a renderer
* may confirm. The renderer receives only its opaque id; mode/profile/path
* selectors are never accepted back across IPC.
*/
export class DesktopCleanupPreviewStore {
private preview: TrustedDesktopCleanupPreview | null = null
constructor(private readonly ttlMs = 10 * 60 * 1000) {}
create(
report: DesktopCleanupReport,
selection: DesktopCleanupSelection,
createdAt = Date.now(),
): TrustedDesktopCleanupPreview {
if (report.outcome !== 'ready' || report.mode !== selection.mode) {
throw new Error('Only a ready, selector-bound cleanup inventory can be approved.')
}
const preview = {
id: randomUUID(),
report,
selection: { ...selection },
createdAt,
}
this.preview = preview
return preview
}
consume(
previewId: unknown,
activeProfileKey: string,
now = Date.now(),
): TrustedDesktopCleanupPreview | null {
const preview = this.preview
this.preview = null
if (
!preview
|| typeof previewId !== 'string'
|| preview.id !== previewId
|| now - preview.createdAt > this.ttlMs
|| now < preview.createdAt
|| preview.selection.profileKey !== activeProfileKey
) return null
return preview
}
discard(
previewId: unknown,
activeProfileKey: string,
): boolean {
const preview = this.preview
if (
!preview
|| typeof previewId !== 'string'
|| preview.id !== previewId
|| preview.selection.profileKey !== activeProfileKey
) return false
this.preview = null
return true
}
clear(): void {
this.preview = null
}
}
@@ -0,0 +1,35 @@
import { AsyncLocalStorage } from 'node:async_hooks'
/**
* Process-local keyed lock for Desktop-owned context files.
*
* The Electron main process owns the application single-instance lock before
* it activates profile writes. Every cooperative context writer must also use
* this lock, so a read/check/publish transaction cannot be interleaved by a
* second Desktop code path inside the owning process.
*/
export class DesktopContextLock {
private readonly ownership = new AsyncLocalStorage<ReadonlySet<string>>()
private readonly tails = new Map<string, Promise<void>>()
async runExclusive<T>(key: string, operation: () => T | Promise<T>): Promise<T> {
if (!key) throw new Error('Desktop context lock requires a non-empty key.')
if (this.ownership.getStore()?.has(key)) {
throw new Error('Desktop context lock cannot be re-entered for the same key.')
}
const previous = this.tails.get(key) ?? Promise.resolve()
const queued = previous.catch(() => undefined).then(async () => {
const held = new Set(this.ownership.getStore() ?? [])
held.add(key)
return this.ownership.run(held, operation)
})
const tail = queued.then(() => undefined, () => undefined)
this.tails.set(key, tail)
try {
return await queued
} finally {
if (this.tails.get(key) === tail) this.tails.delete(key)
}
}
}
+50
View File
@@ -0,0 +1,50 @@
// Pure OS-language → bundled-locale resolution, split out of main.ts so it can
// be unit-tested without pulling in Electron (same pattern as
// update-feed-resolver.ts). main.ts feeds it
// [...app.getPreferredSystemLanguages(), app.getLocale()] in preference order.
export type DesktopLocale = 'en' | 'zh-Hans' | 'ja' | 'fr' | 'de' | 'es'
export const DESKTOP_LOCALES: DesktopLocale[] = ['en', 'zh-Hans', 'ja', 'fr', 'de', 'es']
/**
* Map the user's ordered BCP-47 language tags to the first bundled locale.
* First match wins, so a top-preference tag can never lose to a
* lower-preference one (e.g. en-HK above fr-HK must yield 'en', not 'fr').
*/
export function resolveLocaleFromTags(tags: readonly unknown[]): DesktopLocale {
for (const raw of tags) {
if (typeof raw !== 'string') continue
let locale: Intl.Locale
try {
// Electron returns canonical BCP-47 tags. Accept underscores too for
// compatibility with legacy locale strings, but let Intl.Locale reject
// malformed tags instead of matching them by substring.
locale = new Intl.Locale(raw.trim().replaceAll('_', '-'))
} catch {
continue
}
const language = locale.language.toLowerCase()
// English is a bundled locale and must match here: without this branch a
// top-preference en-* tag falls through and a LOWER-preference language
// (e.g. fr-HK behind en-HK on a Hong Kong system) wins the loop.
if (language === 'en') return 'en'
if (language === 'zh') {
// Only Simplified Chinese is bundled. An explicit script subtag wins
// over region: zh-Hans-HK/TW/MO is Simplified wherever the reader
// lives. Only then route Traditional variants — explicit zh-Hant, or
// bare region tags that default to Traditional (zh-TW / zh-HK /
// zh-MO) — to the English fallback rather than forcing Simplified
// text a Traditional reader may not want.
const script = locale.script?.toLowerCase()
const region = locale.region?.toLowerCase()
if (script === 'hans') return 'zh-Hans'
if (script === 'hant' || region === 'tw' || region === 'hk' || region === 'mo') continue
return 'zh-Hans'
}
for (const code of ['ja', 'fr', 'de', 'es'] as const) {
if (language === code) return code
}
}
return 'en'
}
@@ -0,0 +1,478 @@
import {
constants,
lstatSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
type Stats,
} from 'node:fs'
import { open, rename, rm } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { dirname, join, resolve } from 'node:path'
import { DesktopContextLock } from './desktop-context-lock.js'
export type DesktopProfileKind = 'primary' | 'recovery'
export interface DesktopProfilePaths {
kind: DesktopProfileKind
recoveryId: string | null
home: string
credentialPath: string
logsDir: string
}
/**
* Only the selected profile kind and opaque recovery UUID are persisted. Paths
* are derived from Electron's current userData root on every launch so an app
* data relocation cannot make the context escape into a stale directory.
*/
export interface DesktopProfileContextFile {
schema_version: 1
active_profile_kind: DesktopProfileKind
active_recovery_id: string | null
attention_acknowledgement: DesktopAttentionAcknowledgement | null
updated_at: string
}
export interface DesktopAttentionAcknowledgement {
stable_code: string
candidates: Array<{
path: string
identity: string | null
modified_at_ns: number | null
}>
}
export interface DesktopProfileContext {
active: DesktopProfilePaths
primary: DesktopProfilePaths
persisted: DesktopProfileContextFile
/**
* A persisted selection that cannot be trusted is kept visible but never
* activated. The main process turns this code into recovery_required before
* it inspects, creates, or starts anything under `active.home`.
*/
issue: DesktopProfileContextIssue | null
}
export type DesktopProfileContextIssue =
| 'desktop_profile_context_corrupt'
| 'desktop_profile_context_schema_too_new'
| 'desktop_profile_context_schema_unsupported'
| 'desktop_profile_context_unreadable'
| 'desktop_selected_recovery_profile_missing'
| 'desktop_selected_recovery_profile_unsafe'
const RECOVERY_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
export function desktopProfileContextPath(userData: string): string {
return join(resolve(userData), 'desktop-profile-context.json')
}
export function primaryProfilePaths(userData: string): DesktopProfilePaths {
const root = resolve(userData)
return {
kind: 'primary',
recoveryId: null,
home: join(root, 'opensquilla'),
credentialPath: join(root, 'desktop-credential.json'),
logsDir: join(root, 'logs'),
}
}
export function isRecoveryProfileId(value: unknown): value is string {
return typeof value === 'string' && RECOVERY_ID_RE.test(value)
}
export function recoveryProfilePaths(userData: string, recoveryId: string): DesktopProfilePaths {
if (!isRecoveryProfileId(recoveryId)) throw new Error('Invalid recovery profile id.')
const root = join(resolve(userData), 'recovery-profiles', recoveryId)
return {
kind: 'recovery',
recoveryId,
home: join(root, 'opensquilla'),
credentialPath: join(root, 'desktop-credential.json'),
logsDir: join(root, 'logs'),
}
}
export function defaultDesktopProfileContext(userData: string): DesktopProfileContext {
const primary = primaryProfilePaths(userData)
return {
active: primary,
primary,
persisted: {
schema_version: 1,
active_profile_kind: 'primary',
active_recovery_id: null,
attention_acknowledgement: null,
updated_at: new Date(0).toISOString(),
},
issue: null,
}
}
export function contextForProfile(
userData: string,
kind: DesktopProfileKind,
recoveryId: string | null = null,
updatedAt = new Date().toISOString(),
attentionAcknowledgement: DesktopAttentionAcknowledgement | null = null,
): DesktopProfileContext {
const primary = primaryProfilePaths(userData)
const active = kind === 'recovery'
? recoveryProfilePaths(userData, recoveryId || '')
: primary
return {
active,
primary,
persisted: {
schema_version: 1,
active_profile_kind: kind,
active_recovery_id: kind === 'recovery' ? active.recoveryId : null,
attention_acknowledgement: attentionAcknowledgement,
updated_at: updatedAt,
},
issue: null,
}
}
export function loadDesktopProfileContext(userData: string): DesktopProfileContext {
const fallback = defaultDesktopProfileContext(userData)
const contextPath = desktopProfileContextPath(userData)
let raw = ''
try {
const info = lstatSync(contextPath)
if (!info.isFile() || info.isSymbolicLink()) {
return contextWithIssue(fallback, 'desktop_profile_context_unreadable')
}
raw = readFileSync(contextPath, 'utf8')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return fallback
return contextWithIssue(fallback, 'desktop_profile_context_unreadable')
}
try {
const parsed = JSON.parse(raw) as Partial<DesktopProfileContextFile>
if (typeof parsed.schema_version !== 'number') {
return contextWithIssue(fallback, 'desktop_profile_context_corrupt')
}
if (parsed.schema_version > 1) {
return contextWithIssue(fallback, 'desktop_profile_context_schema_too_new')
}
if (parsed.schema_version !== 1) {
return contextWithIssue(fallback, 'desktop_profile_context_schema_unsupported')
}
if (typeof parsed.updated_at !== 'string') {
return contextWithIssue(fallback, 'desktop_profile_context_corrupt')
}
if (parsed.active_profile_kind === 'primary') {
if (parsed.active_recovery_id !== null) {
return contextWithIssue(fallback, 'desktop_profile_context_corrupt')
}
return contextForProfile(
userData,
'primary',
null,
String(parsed.updated_at || ''),
parseAttentionAcknowledgement(parsed.attention_acknowledgement),
)
}
if (
parsed.active_profile_kind === 'recovery'
&& isRecoveryProfileId(parsed.active_recovery_id)
) {
const candidate = recoveryProfilePaths(userData, parsed.active_recovery_id)
const candidateStatus = recoveryProfileStatus(userData, candidate)
if (candidateStatus === 'valid') {
return contextForProfile(
userData,
'recovery',
parsed.active_recovery_id,
String(parsed.updated_at || ''),
parseAttentionAcknowledgement(parsed.attention_acknowledgement),
)
}
// Preserve the selected UUID in memory so the recovery UI and sanitized
// diagnostics can explain what became unavailable. Startup is blocked by
// `issue`; nothing creates this missing home or silently selects primary.
return contextWithIssue(
contextForProfile(
userData,
'recovery',
parsed.active_recovery_id,
parsed.updated_at,
parseAttentionAcknowledgement(parsed.attention_acknowledgement),
),
candidateStatus === 'missing'
? 'desktop_selected_recovery_profile_missing'
: 'desktop_selected_recovery_profile_unsafe',
)
}
} catch {
return contextWithIssue(fallback, 'desktop_profile_context_corrupt')
}
return contextWithIssue(fallback, 'desktop_profile_context_corrupt')
}
function contextWithIssue(
context: DesktopProfileContext,
issue: DesktopProfileContextIssue,
): DesktopProfileContext {
return { ...context, issue }
}
function parseAttentionAcknowledgement(value: unknown): DesktopAttentionAcknowledgement | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const record = value as Partial<DesktopAttentionAcknowledgement>
if (typeof record.stable_code !== 'string' || !Array.isArray(record.candidates)) return null
const candidates: DesktopAttentionAcknowledgement['candidates'] = []
for (const item of record.candidates) {
if (!item || typeof item !== 'object' || Array.isArray(item)) return null
const candidate = item as Record<string, unknown>
if (
typeof candidate.path !== 'string'
|| (candidate.identity !== null && typeof candidate.identity !== 'string')
|| (candidate.modified_at_ns !== null && typeof candidate.modified_at_ns !== 'number')
) return null
candidates.push({
path: candidate.path,
identity: candidate.identity as string | null,
modified_at_ns: candidate.modified_at_ns as number | null,
})
}
return { stable_code: record.stable_code, candidates }
}
function realDirectoryStatus(path: string): 'valid' | 'missing' | 'unsafe' {
try {
const info = lstatSync(path)
return info.isDirectory() && !info.isSymbolicLink() ? 'valid' : 'unsafe'
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'missing' : 'unsafe'
}
}
function recoveryProfileStatus(
userData: string,
profile: DesktopProfilePaths,
): 'valid' | 'missing' | 'unsafe' {
const recoveryRoot = join(resolve(userData), 'recovery-profiles')
const profileRoot = dirname(profile.home)
for (const path of [recoveryRoot, profileRoot, profile.home]) {
const status = realDirectoryStatus(path)
if (status !== 'valid') return status
}
try {
const resolvedRecoveryRoot = realpathSync(recoveryRoot)
const resolvedProfileRoot = realpathSync(profileRoot)
const resolvedHome = realpathSync(profile.home)
if (dirname(resolvedProfileRoot) !== resolvedRecoveryRoot) return 'unsafe'
if (dirname(resolvedHome) !== resolvedProfileRoot) return 'unsafe'
return 'valid'
} catch {
return 'unsafe'
}
}
/**
* Enumerate every Desktop-owned profile for cleanup/backup UIs. Recovery roots
* are accepted only when the UUID and no-follow directory shape both match;
* symlinks and junction-like aliases are never traversed.
*/
export function allProfileContexts(userData: string): DesktopProfilePaths[] {
const profiles = [primaryProfilePaths(userData)]
const recoveryRoot = join(resolve(userData), 'recovery-profiles')
let entries: string[] = []
try {
const rootInfo = lstatSync(recoveryRoot)
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) return profiles
entries = readdirSync(recoveryRoot)
} catch {
return profiles
}
for (const entry of entries.sort()) {
if (!isRecoveryProfileId(entry)) continue
const profile = recoveryProfilePaths(userData, entry)
if (recoveryProfileStatus(userData, profile) !== 'valid') continue
profiles.push(profile)
}
return profiles
}
export function serializeDesktopProfileContext(context: DesktopProfileContext): string {
return `${JSON.stringify(context.persisted, null, 2)}\n`
}
interface DesktopProfileContextDiskSnapshot {
exists: boolean
identity: string | null
bytes: Buffer | null
}
const desktopProfileContextLock = new DesktopContextLock()
function diskIdentity(info: Stats): string {
return [
info.dev,
info.ino,
info.mode,
info.nlink,
info.size,
info.mtimeMs,
info.ctimeMs,
].join(':')
}
function captureDesktopProfileContextDiskSnapshot(
userData: string,
): DesktopProfileContextDiskSnapshot {
const path = desktopProfileContextPath(userData)
let before: Stats
try {
before = lstatSync(path)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return { exists: false, identity: null, bytes: null }
}
throw error
}
let bytes: Buffer | null = null
if (before.isFile() && !before.isSymbolicLink()) {
try {
bytes = readFileSync(path)
} catch {
// An explicit user choice may repair an unreadable context. The no-follow
// identity still participates in CAS even when its bytes cannot be read.
}
}
let after: Stats
try {
after = lstatSync(path)
} catch {
throw new Error('Desktop profile context changed while it was being updated.')
}
if (diskIdentity(before) !== diskIdentity(after)) {
throw new Error('Desktop profile context changed while it was being updated.')
}
return { exists: true, identity: diskIdentity(after), bytes }
}
function sameDiskSnapshot(
left: DesktopProfileContextDiskSnapshot,
right: DesktopProfileContextDiskSnapshot,
): boolean {
if (left.exists !== right.exists || left.identity !== right.identity) return false
if (left.bytes === null || right.bytes === null) return left.bytes === right.bytes
return left.bytes.equals(right.bytes)
}
/**
* Durably replace the Desktop-owned context after an explicit profile choice.
* The temp file is created owner-only and fsynced before rename; the containing
* directory is fsynced where the platform supports directory handles. A failed
* load never calls this function, so corrupt/future context remains untouched
* until the user explicitly chooses a profile.
*/
async function persistDesktopProfileContextFileWithSnapshot(
userData: string,
context: DesktopProfileContext,
expectedSnapshot: DesktopProfileContextDiskSnapshot | null = null,
): Promise<void> {
const root = resolve(userData)
mkdirSync(root, { recursive: true, mode: 0o700 })
const rootInfo = lstatSync(root)
if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) {
throw new Error('Desktop user data is not a safe local directory.')
}
const destination = desktopProfileContextPath(root)
const temporary = `${destination}.${randomUUID()}.tmp`
try {
const handle = await open(
temporary,
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
0o600,
)
try {
await handle.writeFile(serializeDesktopProfileContext(context), 'utf8')
await handle.sync()
} finally {
await handle.close()
}
if (
expectedSnapshot
&& !sameDiskSnapshot(
expectedSnapshot,
captureDesktopProfileContextDiskSnapshot(root),
)
) {
throw new Error('Desktop profile context changed while it was being updated.')
}
await rename(temporary, destination)
await syncDirectoryWhereSupported(root)
} catch (error) {
await rm(temporary, { force: true }).catch(() => null)
throw error
}
}
export async function persistDesktopProfileContextFile(
userData: string,
context: DesktopProfileContext,
): Promise<void> {
const root = resolve(userData)
await desktopProfileContextLock.runExclusive(
desktopProfileContextPath(root),
() => persistDesktopProfileContextFileWithSnapshot(root, context),
)
}
/**
* Serialize every cooperative profile-context write, including direct
* persistence after an explicit choice, as a fresh read-modify-write. Electron
* owns the application single-instance lock before these writers are activated,
* while this shared context lock closes the final CAS-check-to-rename window
* between in-process writers. The CAS snapshot also refuses to overwrite an
* uncoordinated writer that changed the context while the updater was deciding.
*/
export async function updateDesktopProfileContextFile(
userData: string,
updater: (
current: DesktopProfileContext,
) => DesktopProfileContext | Promise<DesktopProfileContext>,
): Promise<DesktopProfileContext> {
const root = resolve(userData)
const key = desktopProfileContextPath(root)
return desktopProfileContextLock.runExclusive(key, async () => {
const expectedSnapshot = captureDesktopProfileContextDiskSnapshot(root)
const current = loadDesktopProfileContext(root)
const next = await updater(current)
await persistDesktopProfileContextFileWithSnapshot(root, next, expectedSnapshot)
return next
})
}
async function syncDirectoryWhereSupported(path: string): Promise<void> {
let handle: Awaited<ReturnType<typeof open>> | null = null
try {
handle = await open(path, constants.O_RDONLY)
await handle.sync()
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
// Windows and a few network filesystems do not expose fsync-capable
// directory handles. The file itself was still fsynced before rename.
if (!['EACCES', 'EINVAL', 'EISDIR', 'ENOTSUP', 'EPERM'].includes(code || '')) {
throw error
}
} finally {
await handle?.close().catch(() => null)
}
}
export function profileKindEnvironment(kind: DesktopProfileKind): 'desktop-primary' | 'desktop-recovery' {
return kind === 'primary' ? 'desktop-primary' : 'desktop-recovery'
}
@@ -0,0 +1,98 @@
interface DesktopWriterWaiter {
maximumActive: number
resolve: () => void
}
export interface ExclusiveDesktopWriterOperation {
admissionToken: symbol
finish: () => void
}
/**
* Coordinates Desktop-owned profile writers with lifecycle operations.
*
* A normal writer calls `begin` and always invokes the returned idempotent
* finish callback. Update, quit, or another lifecycle boundary calls `close`
* before waiting for active writers to drain. An operation that must close
* admission and reserve its own writer slot atomically uses
* `tryBeginExclusive`.
*
* This class deliberately has no cleanup or deletion policy. It only owns
* admission and drain state, so recovery, update, and quit can share the same
* contract without inheriting destructive-operation semantics.
*/
export class DesktopWriterAdmission {
private readonly closeOwners = new Set<symbol>()
private active = 0
private readonly waiters = new Set<DesktopWriterWaiter>()
get closed(): boolean {
return this.closeOwners.size > 0
}
get activeCount(): number {
return this.active
}
close(label: string): symbol {
const token = Symbol(`desktop writer admission: ${label}`)
this.closeOwners.add(token)
return token
}
reopen(token: symbol): boolean {
return this.closeOwners.delete(token)
}
hasOwner(token: symbol): boolean {
return this.closeOwners.has(token)
}
hasOtherOwner(token: symbol): boolean {
for (const owner of this.closeOwners) {
if (owner !== token) return true
}
return false
}
begin(label: string): () => void {
if (this.closed) {
throw new Error(`Desktop writer admission is closed; ${label} was not started.`)
}
return this.reserveWriter()
}
tryBeginExclusive(label: string): ExclusiveDesktopWriterOperation | null {
if (this.closed) return null
const admissionToken = this.close(label)
return {
admissionToken,
finish: this.reserveWriter(),
}
}
waitForAtMost(maximumActive: number): Promise<void> {
if (!Number.isSafeInteger(maximumActive) || maximumActive < 0) {
throw new Error('Desktop writer drain threshold must be a non-negative integer.')
}
if (this.active <= maximumActive) return Promise.resolve()
return new Promise((resolve) => {
this.waiters.add({ maximumActive, resolve })
})
}
private reserveWriter(): () => void {
this.active += 1
let finished = false
return () => {
if (finished) return
finished = true
this.active = Math.max(0, this.active - 1)
for (const waiter of [...this.waiters]) {
if (this.active > waiter.maximumActive) continue
this.waiters.delete(waiter)
waiter.resolve()
}
}
}
}
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('opensquillaDesktop', {
getOsLocale: () => ipcRenderer.invoke('desktop:os-locale'),
isAutoUpdateEnabled: () => ipcRenderer.invoke('desktop:update:supported'),
getUpdateState: () => ipcRenderer.invoke('desktop:update:state'),
checkForUpdates: () => ipcRenderer.invoke('desktop:update:check'),
downloadUpdate: () => ipcRenderer.invoke('desktop:update:download'),
relaunchToUpdate: () => ipcRenderer.invoke('desktop:update:relaunch'),
dismissUpdate: () => ipcRenderer.invoke('desktop:update:dismiss'),
getGatewayStatus: () => ipcRenderer.invoke('gateway:status'),
getCliInvocation: () => ipcRenderer.invoke('gateway:cli-invocation'),
revealGatewayLog: () => ipcRenderer.invoke('gateway:reveal-log'),
getDesktopSettings: () => ipcRenderer.invoke('desktop:settings:get'),
saveDesktopSettings: (payload: unknown) => ipcRenderer.invoke('desktop:settings:save', payload),
resetDesktopSettings: () => ipcRenderer.invoke('desktop:settings:reset'),
setNativeTheme: (payload: unknown) => ipcRenderer.invoke('desktop:theme:set', payload),
openArtifact: (payload: unknown) => ipcRenderer.invoke('desktop:artifact:open', payload),
getOnboardingDefaults: () => ipcRenderer.invoke('desktop:onboarding:defaults'),
saveOnboarding: (payload: unknown) => ipcRenderer.invoke('desktop:onboarding:save', payload),
cancelOnboarding: () => ipcRenderer.invoke('desktop:onboarding:cancel'),
getBootState: () => ipcRenderer.invoke('desktop:boot:state'),
retryStartup: () => ipcRenderer.invoke('desktop:boot:retry'),
quitApp: () => ipcRenderer.invoke('desktop:boot:quit'),
getRecoveryState: () => ipcRenderer.invoke('desktop:recovery:state'),
chooseRecoveryWorkspace: (payload: unknown) => ipcRenderer.invoke('desktop:recovery:choose-workspace', payload),
recoverProfileTransaction: () => ipcRenderer.invoke('desktop:recovery:recover-transaction'),
launchSafeProfile: (payload: unknown) => ipcRenderer.invoke('desktop:recovery:launch-safe', payload),
retryPrimaryProfile: () => ipcRenderer.invoke('desktop:recovery:retry-primary'),
returnPrimaryProfile: () => ipcRenderer.invoke('desktop:recovery:return-primary'),
revealRecoveryPath: (payload: unknown) => ipcRenderer.invoke('desktop:recovery:reveal-path', payload),
copyRecoveryDiagnostics: () => ipcRenderer.invoke('desktop:recovery:copy-diagnostics'),
abandonCleanupTransaction: () => ipcRenderer.invoke('desktop:recovery:abandon-cleanup'),
inspectDesktopCleanup: (payload: unknown) => ipcRenderer.invoke('desktop:cleanup:inspect', payload),
discardDesktopCleanup: (payload: unknown) => ipcRenderer.invoke('desktop:cleanup:discard', payload),
applyDesktopCleanup: (payload: unknown) => ipcRenderer.invoke('desktop:cleanup:apply', payload),
revealDesktopUserData: () => ipcRenderer.invoke('desktop:cleanup:reveal-user-data'),
migrationSummary: (payload?: unknown) => ipcRenderer.invoke('desktop:migration:summary', payload),
migrationBrowseSource: (payload: unknown) => ipcRenderer.invoke('desktop:migration:browse-source', payload),
migrationRun: (payload: unknown) => ipcRenderer.invoke('desktop:migration:run', payload),
migrationTakeLastResult: () => ipcRenderer.invoke('desktop:migration:last-result'),
migrationPeekLastResult: () => ipcRenderer.invoke('desktop:migration:peek-last-result'),
migrationDismissLastResult: () => ipcRenderer.invoke('desktop:migration:dismiss-last-result'),
selectOnboardingMigration: (payload: unknown) => ipcRenderer.invoke('desktop:onboarding:migrate:select', payload),
browseOnboardingMigration: (payload: unknown) => ipcRenderer.invoke('desktop:onboarding:migrate:browse', payload),
previewOnboardingMigration: () => ipcRenderer.invoke('desktop:onboarding:migrate:preview'),
applyOnboardingMigration: () => ipcRenderer.invoke('desktop:onboarding:migrate:apply'),
onBootStatus: (callback: (payload: unknown) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: unknown) => callback(payload)
ipcRenderer.on('desktop:boot:status', listener)
return () => ipcRenderer.removeListener('desktop:boot:status', listener)
},
onBootError: (callback: (payload: unknown) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: unknown) => callback(payload)
ipcRenderer.on('desktop:boot:error', listener)
return () => ipcRenderer.removeListener('desktop:boot:error', listener)
},
onRecoveryState: (callback: (payload: unknown) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: unknown) => callback(payload)
ipcRenderer.on('desktop:recovery:state-changed', listener)
return () => ipcRenderer.removeListener('desktop:recovery:state-changed', listener)
},
onUpdateState: (callback: (payload: unknown) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: unknown) => callback(payload)
ipcRenderer.on('desktop:update:state-changed', listener)
return () => ipcRenderer.removeListener('desktop:update:state-changed', listener)
},
onMigrationProgress: (callback: (payload: unknown) => void) => {
const listener = (_event: Electron.IpcRendererEvent, payload: unknown) => callback(payload)
ipcRenderer.on('desktop:migration:progress', listener)
return () => ipcRenderer.removeListener('desktop:migration:progress', listener)
},
})
@@ -0,0 +1,30 @@
export type SecretStorageBackend = 'safeStorage' | 'plain'
export interface SecretStoragePolicyInput {
envMode?: string
platform: NodeJS.Platform
appPackaged: boolean
codesignDiagnostic?: string
}
export function macCodeSignatureIsAdHoc(diagnostic: string): boolean {
return /\bSignature=adhoc\b/.test(diagnostic) || /\bflags=[^\n]*\badhoc\b/.test(diagnostic)
}
export function secretStorageBackendForPolicy(input: SecretStoragePolicyInput): SecretStorageBackend {
const mode = (input.envMode || '').trim().toLowerCase()
if (mode === 'plain' || mode === 'plaintext' || mode === 'none') return 'plain'
if (mode === 'safe' || mode === 'safe-storage' || mode === 'safestorage') return 'safeStorage'
if (input.platform === 'darwin' && input.appPackaged && macCodeSignatureIsAdHoc(input.codesignDiagnostic || '')) {
return 'plain'
}
return 'safeStorage'
}
export function shouldUseChromiumMockKeychainForPolicy(input: SecretStoragePolicyInput): boolean {
return input.platform === 'darwin' &&
input.appPackaged &&
secretStorageBackendForPolicy(input) === 'plain'
}
@@ -0,0 +1,75 @@
// Pure release-tag resolution for the macOS desktop updater, split out of
// main.ts so it can be unit-tested without pulling in Electron.
//
// OpenSquilla ships two spellings of the same release: PEP440 for the Git tag /
// Python wheel (e.g. v0.5.0rc2) and npm semver for the Electron app metadata
// (0.5.0-rc2). electron-updater's GitHub provider filters release tags with
// semver.valid(), which rejects PEP440 rc tags, so a packaged prerelease build
// finds no updates. Even semver-spelled rc tags land on distinct electron-updater
// channels (rc1 vs rc2), so rc1 would never see rc2. This module resolves the
// correct candidate release ourselves; a generic feed is then pointed at it.
export const GITHUB_UPDATE_OWNER = 'opensquilla'
export const GITHUB_UPDATE_REPO = 'opensquilla'
export const MAC_UPDATE_FEED_ASSET = 'latest-mac.yml'
export interface ParsedReleaseTag {
base: string
rc: number | null
}
export interface ReleaseSummary {
tag_name?: string
draft?: boolean
assets?: { name?: string }[]
}
export interface MacPrereleaseCandidate {
tag: string
version: string
feedUrl: string
}
// Accept the PEP440 rc tag (v0.5.0rc2), the semver rc spelling (v0.5.0-rc2 /
// v0.5.0-rc.2), and a plain stable tag (v0.5.0). Returns null for anything else
// (doc/website releases, monorepo tags, malformed tags).
export function parseOpenSquillaReleaseTag(tag: string): ParsedReleaseTag | null {
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-?rc\.?(\d+))?$/i.exec(String(tag ?? '').trim())
if (!match) return null
const [, major, minor, patch, rc] = match
return {
base: `${Number(major)}.${Number(minor)}.${Number(patch)}`,
rc: rc === undefined ? null : Number(rc),
}
}
// Given the running prerelease (base + rc) and the repo's releases, pick the
// highest same-base release that is newer than the current rc — a later rc or the
// final stable for this base — and that actually ships the macOS update feed. The
// final stable outranks any rc of the same base; among rcs, the higher wins.
// Returns null when nothing newer for this base is publishable (e.g. only the
// current rc exists, or the newer release is missing latest-mac.yml).
export function selectMacPrereleaseCandidate(
current: { base: string; rc: number },
releases: ReleaseSummary[],
): MacPrereleaseCandidate | null {
let best: { parsed: ParsedReleaseTag; tag: string; rank: number } | null = null
for (const release of releases) {
if (release?.draft) continue
const tag = String(release?.tag_name ?? '')
const parsed = parseOpenSquillaReleaseTag(tag)
if (!parsed || parsed.base !== current.base) continue
const isStable = parsed.rc === null
const isHigherRc = parsed.rc !== null && parsed.rc > current.rc
if (!isStable && !isHigherRc) continue
if (!(release.assets || []).some((asset) => asset?.name === MAC_UPDATE_FEED_ASSET)) continue
const rank = isStable ? Number.MAX_SAFE_INTEGER : (parsed.rc as number)
if (!best || rank > best.rank) best = { parsed, tag, rank }
}
if (!best) return null
return {
tag: best.tag,
version: best.parsed.rc === null ? best.parsed.base : `${best.parsed.base}-rc${best.parsed.rc}`,
feedUrl: `https://github.com/${GITHUB_UPDATE_OWNER}/${GITHUB_UPDATE_REPO}/releases/download/${best.tag}`,
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"rootDir": "src",
"outDir": "dist",
"types": ["node", "electron"]
},
"include": ["src/**/*.ts", "src/**/*.cts"]
}
+99
View File
@@ -0,0 +1,99 @@
# OpenSquilla Documentation
This directory is the user-facing product documentation set. It complements the
root release README with task-oriented guides.
## Read First
1. [`quickstart.md`](quickstart.md) - install, configure, run, and open the Web UI.
2. [`use-cases.md`](use-cases.md) - task-oriented recipes for common goals.
3. [`gateway.md`](gateway.md) - gateway lifecycle, host/port, safety, and status.
4. [`configuration.md`](configuration.md) - provider, router, search, channel,
memory, and permission configuration.
5. [`cli.md`](cli.md) - command groups and common CLI workflows.
6. [`tui.md`](tui.md) - terminal chat usage, slash commands, files, sessions,
and the OpenTUI preview.
7. [`web-ui.md`](web-ui.md) - local control console and chat UI.
8. [`sessions.md`](sessions.md) - session continuity, export, resume, abort,
and cleanup.
9. [`glossary.md`](glossary.md) - user-facing terminology.
## Feature Guides
- [`features.md`](features.md) - capability catalog.
- [`features/squilla-router.md`](features/squilla-router.md) - model routing.
- [`features/tui-frontend.md`](features/tui-frontend.md) - terminal backend
architecture, plugin slots, Router HUD, and OpenTUI evaluation.
- [`features/tool-compression.md`](features/tool-compression.md) - compact tool
results and handles.
- [`features/meta-skills.md`](features/meta-skills.md) - reusable workflow skills.
- [`features/meta-skill-user-guide.md`](features/meta-skill-user-guide.md) -
user-facing MetaSkill guide.
- [`authoring/meta-skills.md`](authoring/meta-skills.md) - MetaSkill authoring
guide.
- [`features/memory.md`](features/memory.md) - durable memory and recall.
- [`features/skills.md`](features/skills.md) - skill discovery, install, and
authoring.
- [`features/compaction-and-cache.md`](features/compaction-and-cache.md) -
long-session compaction and prompt-cache continuity.
## Surfaces and Operations
- [`releases/0.5.0rc3.md`](releases/0.5.0rc3.md) - OpenSquilla 0.5.0 Preview 3 release notes.
- [`releases/0.5.0rc2.md`](releases/0.5.0rc2.md) - OpenSquilla 0.5.0 Preview 2 release notes.
- [`releases/0.5.0rc1.md`](releases/0.5.0rc1.md) - OpenSquilla 0.5.0 Preview 1 release notes.
- [`releases/0.4.1.md`](releases/0.4.1.md) - OpenSquilla 0.4.1 release notes.
- [`releases/0.4.0.md`](releases/0.4.0.md) - OpenSquilla 0.4.0 release notes.
- [`releases/0.3.0.md`](releases/0.3.0.md) - OpenSquilla 0.3.0 release notes.
- [`channels.md`](channels.md) - supported messaging channels and setup flow.
- [`providers-and-models.md`](providers-and-models.md) - LLM provider catalog,
model selection, and runtime-backed model inspection.
- [`search.md`](search.md) - web search providers and query workflow.
- [`artifacts-and-media.md`](artifacts-and-media.md) - artifacts, generated
files, images, PDF, and TTS.
- [`tools-and-sandbox.md`](tools-and-sandbox.md) - built-in tools, approvals,
sandbox posture, and write policy.
- [`approvals-and-permissions.md`](approvals-and-permissions.md) - permission
profiles, approval commands, workspace containment, and sandbox posture.
- [`agents.md`](agents.md) - durable named agents and workspace defaults.
- [`scheduling.md`](scheduling.md) - recurring and one-time scheduled work.
- [`mcp-server.md`](mcp-server.md) - MCP server bridge for MCP-capable clients.
- [`usage-and-cost.md`](usage-and-cost.md) - token usage, estimated cost, and
cost investigation workflow.
- [`diagnostics-and-replay.md`](diagnostics-and-replay.md) - diagnostics,
raw capture guidance, read-only turn replay, and developer replay benchmarks.
- [`tui-real-terminal-harness.md`](tui-real-terminal-harness.md) - maintainer
real-terminal TUI integration harness and evidence capture.
- [`experiments.md`](experiments.md) - opt-in runtime toggle conventions and
the delivery-verification tooling in `scripts/experiments/`.
- [`docker.md`](docker.md) - Docker/Compose deployment on home servers and
NAS: prebuilt GHCR images, LAN exposure with token auth, and upgrades.
- [`operations.md`](operations.md) - sessions, cron, usage, diagnostics,
migration, MCP server, and install inventory commands.
- [`troubleshooting.md`](troubleshooting.md) - common install/runtime issues.
- [`glossary.md`](glossary.md) - short definitions for product terms.
## Improve These Docs
Documentation improvements are welcome. Start with
[`contributing-docs.md`](contributing-docs.md) for docs-specific guidance, then
open a small pull request against `main`.
Fast paths:
- Report a stale command, broken link, or confusing page with the
[documentation issue template](https://github.com/opensquilla/opensquilla/issues/new?template=docs_report.yml).
- Edit the affected Markdown page on GitHub and open a focused pull request
against `main`.
- For new feature documentation, keep independent features on independent pages
under `docs/features/`.
## Design Principle
OpenSquilla documentation should help users run the product first, then
understand its special advantages. Mechanism-heavy runtime detail belongs in
developer design notes or source comments, not in the first-run path.
---
[Product guide](../README.product.md) · [中文](../README.zh-Hans.md) · [日本語](../README.ja.md) · [Français](../README.fr.md) · [Deutsch](../README.de.md) · [Español](../README.es.md) · [Improve these docs](contributing-docs.md) · [Report a docs issue](https://github.com/opensquilla/opensquilla/issues/new?template=docs_report.yml) · [Contributing](../CONTRIBUTING.md)
+96
View File
@@ -0,0 +1,96 @@
# Durable Agents
OpenSquilla agents are named runtime profiles. Use them when different work
streams need different defaults, such as a research workspace, a writing
workspace, or a channel-facing assistant.
The built-in `main` agent is always available. Additional agents are configured
with `opensquilla agents`.
## When to Create an Agent
Create a durable agent when you want a stable identity for:
- a dedicated workspace;
- a default model choice;
- a separate channel or automation target;
- a recurring task profile;
- a specialized assistant name and description.
Do not create a new agent for every conversation. Use sessions for ordinary
conversation continuity.
## List Agents
```sh
opensquilla agents list
opensquilla agents list --json
```
## Add an Agent
```sh
opensquilla agents add research \
--name Research \
--description "Research and synthesis workspace" \
--workspace /path/to/research \
--model gpt-5.4-mini
```
Agent changes are written to configuration. Restart the gateway before relying
on the updated agent list:
```sh
opensquilla gateway restart
```
## Use an Agent With Sessions
Filter sessions by agent:
```sh
opensquilla sessions list --agent research
```
Create scheduled work for an agent:
```sh
opensquilla cron add \
--agent research \
--every 1h \
--text "Summarize new research notes" \
--name research-hourly-summary
```
Channel configuration can also route incoming messages to configured agents
depending on the channel setup.
## Delete an Agent
```sh
opensquilla agents delete research
opensquilla agents delete research --force
```
Deleting an agent entry leaves workspace files and state untouched. Clean those
up separately only when you are sure they are no longer needed.
## Agents vs Sessions vs Skills
| Concept | Use for |
| --- | --- |
| Agent | Durable identity and defaults for a work stream. |
| Session | Conversation history and active task continuity. |
| Skill | Reusable workflow instructions or tool routines. |
| Meta-skill | A composed workflow made from multiple skill steps. |
Read next:
- [`sessions.md`](sessions.md)
- [`features/skills.md`](features/skills.md)
- [`features/meta-skills.md`](features/meta-skills.md)
- [`channels.md`](channels.md)
---
[Docs index](README.md) · [Product guide](../README.product.md) · [Improve this page](contributing-docs.md) · [Report a docs issue](https://github.com/opensquilla/opensquilla/issues/new?template=docs_report.yml)
+137
View File
@@ -0,0 +1,137 @@
# Approvals and Permissions
Approvals and permissions control how OpenSquilla tools are allowed to act.
They matter most when an agent can write files, run shell commands, publish
artifacts, post into channels, or call external services.
Use this page before running unattended automation or giving a channel-connected
agent broad tool access.
## Permission Profiles
Single-shot automation accepts an explicit permission profile:
```sh
opensquilla agent --permissions restricted -m "Inspect this repo"
opensquilla agent --permissions on -m "Run with host execution and approvals"
opensquilla agent --permissions bypass -m "Trusted local automation"
opensquilla agent --permissions full -m "Fully trusted local automation"
```
Practical meaning:
| Profile | Use when |
| --- | --- |
| `restricted` / `off` | The task should stay conservative and avoid elevated execution. |
| `on` | Host execution is allowed, but approval checks still matter. |
| `bypass` | You trust the task enough to auto-grant approvals while keeping sensitive-path checks. |
| `full` | You fully trust the task and environment. Use sparingly. |
For automation, prefer the narrowest profile that can complete the task.
## Workspace Containment
Set a workspace for file and shell work:
```sh
opensquilla agent \
--workspace /path/to/project \
--workspace-strict \
-m "Summarize this repo"
```
Contain writes to the workspace or scratch directory:
```sh
opensquilla agent \
--workspace /path/to/project \
--workspace-lockdown \
--scratch-dir /path/to/project/.scratch \
-m "Investigate and prepare a minimal patch"
```
Use `--workspace-lockdown` for unattended runs where accidental writes outside
the project would be unacceptable.
## Interactive Approvals
Interactive chat surfaces can pause sensitive tool calls for a human decision.
Gateway-backed terminal chat supports:
```text
/approvals
/approvals reset
/permissions status
/permissions on
/permissions off
/permissions bypass
/permissions full
/forget
```
Use these commands when you need to inspect or reset cached approval decisions
during a chat.
The Web UI also provides an approvals surface for reviewing pending actions
outside the message scrollback.
## Sandbox Posture
Inspect sandbox posture:
```sh
opensquilla sandbox status
opensquilla sandbox status --json
```
Set posture:
```sh
opensquilla sandbox on
opensquilla sandbox bypass
opensquilla sandbox full
opensquilla sandbox reset
```
Restart the gateway after changing global sandbox posture:
```sh
opensquilla gateway restart
```
## Recommended Defaults
| Situation | Recommended approach |
| --- | --- |
| First run in a repo | `--workspace` plus `--workspace-strict` |
| Read-only investigation | `--permissions restricted` |
| Local patch with tests | `--workspace-lockdown` plus a scratch directory |
| Web UI task with writes | Keep approvals visible and review sensitive actions |
| Channel-connected agent | Conservative permissions and explicit channel setup |
| Unattended automation | Bound timeout/iterations and choose the narrowest workable permissions |
## Troubleshooting
If a tool is denied:
```sh
opensquilla sandbox status
opensquilla doctor
```
Then check:
- whether the surface supports live approvals;
- whether the workspace path is correct;
- whether cached approvals need to be reset;
- whether the task should run with a different permission profile.
Read next:
- [`tools-and-sandbox.md`](tools-and-sandbox.md)
- [`web-ui.md`](web-ui.md)
- [`channels.md`](channels.md)
---
[Docs index](README.md) · [Product guide](../README.product.md) · [Improve this page](contributing-docs.md) · [Report a docs issue](https://github.com/opensquilla/opensquilla/issues/new?template=docs_report.yml)
+121
View File
@@ -0,0 +1,121 @@
# Artifacts and Media
OpenSquilla can create and deliver files as part of agent work: reports, HTML
files, PDFs, slide decks, spreadsheets, generated images, and other artifacts.
Use artifacts when the output is too large, visual, structured, or important to
leave only in chat text.
## Artifacts
Artifacts are user-visible files created during a session. In Web UI chat they
appear as artifact cards when the runtime publishes them. In CLI runs, artifact
events can include file names, ids, and download URLs.
Common use cases:
- generate a report;
- create a standalone HTML prototype;
- build a CSV/XLSX workbook;
- create a PDF briefing;
- produce a slide deck;
- package generated output for channel delivery.
Ask directly:
```text
Create a one-page HTML dashboard from this data and publish it as an artifact.
```
```text
Generate a PDF briefing with sources and publish the final file.
```
## When to Use Artifacts Instead of Chat
Use artifacts for:
- files the user should download or share;
- tables or reports that need layout;
- generated apps, dashboards, or prototypes;
- long output that would be awkward in chat;
- channel delivery where the platform supports file upload.
Use chat text for short answers, decisions, and next steps.
## Document Skills
OpenSquilla includes skills for common document formats:
- `docx` for Word documents;
- `pptx` for PowerPoint decks;
- `xlsx` for Excel workbooks;
- `pdf-toolkit` for structured PDF work;
- `html-to-pdf` for styled PDF rendering.
Discover them:
```sh
opensquilla skills search pdf
opensquilla skills view pptx
opensquilla skills view xlsx
```
Some document features require optional native/system dependencies. Use
`opensquilla skills list` and `opensquilla doctor` to check readiness.
## Image Input and Generation
In terminal chat, send an image for analysis:
```text
/image /path/to/screenshot.png Describe what is wrong with this UI.
```
Configure image generation:
```sh
opensquilla configure image-generation
```
Then ask for images in chat:
```text
Generate a clean product mockup image for this landing page.
```
Image provider support depends on configured provider credentials, optional
dependencies, and runtime policy.
## Text to Speech and Media Helpers
The media tool family includes image, PDF, and TTS helpers. Availability can
depend on provider config, optional dependencies, and runtime policy.
Use media helpers when the requested output is naturally a file or asset rather
than a plain text answer.
## Channel Delivery
Channels differ in file-size limits, threading behavior, and upload APIs. If a
channel cannot deliver an artifact directly, use the Web UI artifact card or
session export as the recovery surface.
For channel setup, see [`channels.md`](channels.md).
## Troubleshooting
If an artifact does not appear:
1. Check the chat or CLI output for artifact events.
2. Open the Web UI session and inspect artifact cards.
3. Export the session if you need durable evidence:
```sh
opensquilla sessions export <session-key>
```
4. Run `opensquilla doctor` if a document or media dependency appears missing.
---
[Docs index](README.md) · [Product guide](../README.product.md) · [Improve this page](contributing-docs.md) · [Report a docs issue](https://github.com/opensquilla/opensquilla/issues/new?template=docs_report.yml)
+496
View File
@@ -0,0 +1,496 @@
# Meta-Skill Authoring Guide
This guide is for authors and maintainers who write, validate, and review
OpenSquilla MetaSkills. For user-facing guidance, read
[`../features/meta-skill-user-guide.md`](../features/meta-skill-user-guide.md).
## What a MetaSkill Is
A MetaSkill is a `SKILL.md` file with:
- `kind: meta`;
- one or more natural-language `triggers`;
- a `composition:` block that defines a directed acyclic graph of steps.
At runtime, the model may call:
```text
meta_invoke(name="<meta-skill-name>")
```
OpenSquilla then executes the declared composition step by step and returns the
final result to the user. The model chooses the workflow, but the runtime
enforces dependency order, template rendering, risk metadata, recursion guards,
tool gates, pauses, and final text selection.
Operators can disable model-visible MetaSkill behavior globally:
```toml
[meta_skill]
enabled = false
```
When disabled, MetaSkills remain installed for inventory and historical run
inspection, but they are not injected into prompts, `meta_invoke` is not
surfaced to the model, and explicit `meta_invoke` calls are rejected.
## When to Use a MetaSkill
Use a MetaSkill when a task is repeatable and naturally decomposes into a small
workflow, for example:
- classify the user request, then route to the right specialist skill;
- run two independent analysis skills, then merge their outputs;
- search or inspect context, then summarize it into a user-facing answer;
- execute a deterministic CLI-backed skill, then review or persist the result;
- pause for structured user input before continuing.
Do not use a MetaSkill for one-off instructions, open-ended planning that should
remain conversational, or flows that need arbitrary recursion. A MetaSkill
cannot compose another MetaSkill.
## Where to Put a MetaSkill
For local managed skills, create:
```text
~/.opensquilla/skills/<skill-name>/SKILL.md
```
For repository-bundled skills, place the skill under the bundled skills tree:
```text
src/opensquilla/skills/bundled/<skill-name>/SKILL.md
```
Generated proposals are reviewed before installation. After accepting a
proposal, OpenSquilla promotes it into the managed skills directory and refreshes
the live skill loader.
## Basic Authoring Flow
1. Define the task contract: inputs, output, boundaries, false positives, and
user-confirmation points.
2. Write a conservative `name`, `description`, and `triggers`.
3. Split the workflow into steps such as intake, collect, analyze, draft, audit,
and deliver.
4. Add `depends_on` whenever a step needs output from earlier steps.
5. Filter all user input and previous step output in templates.
6. Declare risk metadata and capabilities.
7. Run deterministic and soft-activation checks.
8. Inspect proposal and auto-enable audit output before accepting or enabling.
Users can activate a MetaSkill in two ways:
- Soft activation: ask naturally, and let the model choose the right
`meta_invoke` call.
- Explicit activation: ask for the named MetaSkill when debugging or testing.
## Required Frontmatter
Every MetaSkill should declare:
```yaml
---
name: short-stable-name
kind: meta
description: One sentence that tells the model when this workflow applies.
triggers:
- short phrase users naturally type
meta_priority: 50
always: false
final_text_mode: auto
metadata:
opensquilla:
risk: low
capabilities: []
composition:
steps: []
---
```
The fields have these meanings:
- `name`: stable identifier used by `meta_invoke`.
- `kind`: must be `meta`.
- `description`: model-facing description for when to use the workflow.
- `triggers`: phrases used by deterministic and model-assisted activation.
- `meta_priority`: sort key when multiple MetaSkills may match.
- `always`: normally `false`; MetaSkills should not be injected unconditionally.
- `final_text_mode`: how the final answer is derived.
- `metadata.opensquilla.risk`: highest unattended auto-enable risk.
- `metadata.opensquilla.capabilities`: explicit side-effect capabilities.
- `composition.steps`: ordered DAG definition.
## Risk Metadata
Use `metadata.opensquilla.risk` to declare the highest risk level required by
the workflow:
- `low`: read-only reasoning, classification, summarization, or safe local
inspection.
- `medium`: local file or artifact writes, deterministic document generation, or
network reads.
- `high`: shell/process control, credential use, network writes, external side
effects, or direct tool calls that can alter state.
Use `metadata.opensquilla.capabilities` to make side effects explicit. Common
capabilities include:
- `filesystem-write`;
- `artifact-write`;
- `document-export`;
- `network`;
- `network-read`;
- `network-write`;
- `external-side-effect`;
- `credential-use`;
- `process-control`;
- `shell`.
If a referenced sub-skill lacks risk metadata, unattended auto-enable treats the
dependency conservatively. New skills should declare risk and capabilities
instead of relying on legacy compatibility fallbacks.
## Step Types
MetaSkill steps support these execution kinds.
### `agent`
Use `agent` for a normal skill-backed sub-agent turn. This is the best default
for user-facing reasoning and synthesis.
```yaml
- id: summarize
kind: agent
skill: summarize
with:
text: "{{ outputs.search | truncate(2000) }}"
```
### `llm_chat`
Use `llm_chat` for one bounded LLM generation step with no tool loop. This is
useful for intake normalization, compact drafting, final audit, and lightweight
synthesis.
```yaml
- id: normalize
kind: llm_chat
with:
system: "Extract the request fields. Do not ask a question."
task: "{{ inputs.user_message | xml_escape | truncate(1000) }}"
```
### `llm_classify`
Use `llm_classify` when the step should return exactly one value from a closed
set. This is useful for routing, triage, and compact decisions.
```yaml
- id: classify
kind: llm_classify
output_choices: [BUG, FEATURE, QUESTION]
with:
text: "{{ inputs.user_message | xml_escape | truncate(512) }}"
```
### `user_input`
Use `user_input` when the workflow should pause and collect structured data from
the user. The step requires a `clarify:` schema.
```yaml
- id: collect_project
kind: user_input
when: "'NEEDS_CLARIFICATION: yes' in outputs.intake"
clarify:
mode: form
intro: "A few fields are needed before this workflow can continue."
nl_extract: true
fields:
- name: topic
type: string
required: true
prompt: "Topic"
max_chars: 200
```
The supported field types are `string`, `enum`, `int`, and `bool`. Use
`skip_if` or `when` to avoid pausing when intake has enough information.
### `tool_call`
Use `tool_call` only for deterministic direct tool execution. Declare a
`tool_allowlist`, keep arguments narrow, and mark the MetaSkill as high risk when
the tool can change state.
```yaml
- id: persist
kind: tool_call
tool: memory_save
tool_allowlist: [memory_save]
tool_args:
text: "{{ outputs.summary | truncate(2000) }}"
```
### `skill_exec`
Use `skill_exec` for a skill with an `entrypoint:` manifest that should run as a
subprocess. This is appropriate for deterministic CLI-backed skills such as
document conversion or report generation.
```yaml
- id: render
kind: skill_exec
skill: html-to-pdf
with:
html: "{{ outputs.report | truncate(12000) }}"
```
## Step Labels and Progress
Two optional step-level fields drive the WebUI run progress ribbon
(see [`../features/meta-skill-user-guide.md`](../features/meta-skill-user-guide.md)
"Run Progress Ribbon"):
- `label`: a short, human-readable name for the step. The ribbon renders
this as the chip text. If omitted, the WebUI humanizes the step `id`
(e.g. `intake``Intake`).
- `progress_emits`: whether the step's executor may publish live
`status_text` updates to the ribbon. Defaults by kind:
- `agent` / `skill_exec`: `true`
- `tool_call`: `false`
- `llm_chat` / `llm_classify` / `user_input`: ignored
Example:
```yaml
composition:
steps:
- id: intake
kind: llm_chat
label: 意图提取
with: { ... }
- id: search
kind: agent
skill: web-research
label: 检索证据
progress_emits: true
with: { ... }
```
Keep labels short (2-6 chars in CJK, 1-2 words in English). Long labels
get truncated in the ribbon header.
## Dependencies and Parallelism
Steps without dependencies may run in parallel. A step with `depends_on` waits
for all named steps to finish.
```yaml
composition:
steps:
- id: inspect_code
kind: agent
skill: code-reviewer
with:
request: "{{ inputs.user_message | xml_escape | truncate(512) }}"
- id: inspect_tests
kind: agent
skill: test-engineer
with:
request: "{{ inputs.user_message | xml_escape | truncate(512) }}"
- id: merge
kind: agent
skill: summarize
depends_on: [inspect_code, inspect_tests]
with:
text: |
Code review:
{{ outputs.inspect_code | truncate(2000) }}
Test review:
{{ outputs.inspect_tests | truncate(2000) }}
```
The graph must be acyclic. A step may only depend on step ids declared in the
same composition.
## Routing and Failure Handling
Use `route` when an `agent` or `skill_exec` step should choose a skill based on
previous outputs:
```yaml
- id: classify
kind: llm_classify
output_choices: [DOCS, BUG, SECURITY]
with:
text: "{{ inputs.user_message | xml_escape | truncate(512) }}"
- id: handle
kind: agent
skill: summarize
depends_on: [classify]
route:
- when: "outputs.classify == 'DOCS'"
to: writer
- when: "outputs.classify == 'BUG'"
to: debugger
- when: "outputs.classify == 'SECURITY'"
to: security-reviewer
with:
request: "{{ inputs.user_message | xml_escape | truncate(512) }}"
```
Use `on_failure` for a single substitute step. The substitute must exist in the
same plan, must not have its own dependencies, and must not have its own
`on_failure`.
## Final Text Modes
Use `final_text_mode` to control the final user-facing result:
- `auto`: default. The orchestrator summarizes step outputs into a concise final
answer.
- `raw`: return the last non-substitute step output verbatim.
- `step:<step_id>`: return one specific step output verbatim.
Examples:
```yaml
final_text_mode: auto
final_text_mode: raw
final_text_mode: "step:summarize"
```
Use `step:<step_id>` when one step is the intended deliverable. Use `raw` when
the final step already formats a complete report. Use `auto` when the workflow
produces several intermediate outputs that need a compact user-facing summary.
## Template Safety
Templates are Jinja expressions. Treat user input and previous step output as
untrusted:
- For user text, start with `xml_escape` or `slugify`, then bound it with
`truncate`.
- For `outputs.<step_id>`, always bound or encode with `truncate`, `xml_escape`,
`slugify`, or `tojson`.
- Do not pass raw `{{ inputs.user_message }}` into a downstream step.
- Do not pass raw `{{ outputs.some_step }}` into another step.
- Keep prompt-shaped strings explicit, short, and task-specific.
Safe examples:
```yaml
query: "{{ inputs.user_message | xml_escape | truncate(512) }}"
text: "{{ outputs.search | truncate(2000) }}"
slug: "{{ inputs.user_message | slugify | truncate(80) }}"
payload: "{{ outputs.plan | tojson }}"
```
Unsafe examples:
```yaml
query: "{{ inputs.user_message }}"
text: "{{ outputs.search }}"
```
## Activation Guidance
MetaSkills are manual-only by default in normal product use. Users launch them
with `/meta <name>`, so trigger and soft-activation checks are needed only when
you are intentionally supporting `meta_skill.auto_trigger = true`.
When maintaining auto-trigger compatibility, write triggers as short phrases
users naturally type:
- Prefer: `summarize recent history`
- Prefer: `review current diff`
- Avoid: `run the internal OpenSquilla DAG composition meta skill`
Use two to five triggers unless a production workflow has a tested reason to use
more. Avoid triggers that collide with explanation questions such as "how does
this meta-skill work?" A user asking about a MetaSkill should not accidentally
run it when auto-trigger compatibility is enabled.
Set `description` to explain when the model should choose the workflow in
auto-trigger mode. Do not hide critical constraints in the body only; the model
primarily sees the frontmatter and injected skill summary when auto-trigger is
enabled.
## Validation Checklist
Before sharing or enabling a MetaSkill:
1. Confirm the frontmatter parses as YAML.
2. Confirm `kind: meta` and `composition.steps` are present.
3. Confirm all `depends_on`, `route.to`, and `on_failure` references point to
valid steps or skills.
4. Confirm the graph has no cycles.
5. Confirm all user input and step outputs are filtered.
6. Confirm `metadata.opensquilla.risk` and `metadata.opensquilla.capabilities`
reflect the workflow's true side effects.
7. If supporting `meta_skill.auto_trigger = true`, run deterministic trigger
checks with `scripts/meta_trigger_accuracy.py`.
8. If supporting `meta_skill.auto_trigger = true`, run model-decision soft
activation checks with
`scripts/live_meta_soft_activation_e2e.py --env-file /path/to/.env`.
9. For generated skills, inspect the Web UI proposal detail and its auto-enable
audit before accepting or enabling.
## Troubleshooting
If the MetaSkill does not appear to run:
- Check that the `SKILL.md` is under a loaded skill directory.
- Refresh or restart the gateway if the skill was added outside the proposal
accept flow.
- Confirm you ran it with `/meta <name>` on a surface that supports MetaSkill
runs.
- Confirm `disable-model-invocation` is not set for a MetaSkill you expect the
model to invoke.
- If you expect natural-language auto-triggering, confirm
`meta_skill.auto_trigger = true`.
- Confirm the skill has `kind: meta` and a non-empty `composition.steps` list.
- Confirm the user wording matches the triggers or description.
If parsing fails:
- Check duplicate step ids.
- Check unknown `kind` values.
- Check missing `skill` for `agent` or `skill_exec` steps.
- Check missing `output_choices` for `llm_classify`.
- Check missing `clarify.fields` for `user_input`.
- Check missing `tool`, invalid `tool_args`, or mismatched `tool_allowlist` for
`tool_call`.
- Check cycles and undefined `depends_on` references.
If auto-enable is skipped:
- Inspect the proposal's auto-enable audit in the Web UI.
- Add missing risk metadata to referenced sub-skills.
- Lower the workflow's side effects, or require manual review for medium/high
risk workflows.
## Test Prompts
At minimum include:
- English positive trigger;
- explicit invocation;
- pasted-history negative case;
- neighboring-domain negative case;
- output-quality judge rubric.
Use realistic user phrasing with a clear subject and goal. Avoid operator-style
phrases that users would not naturally type.
---
[Docs index](../README.md) · [Product guide](../../README.product.md) · [Improve this page](../contributing-docs.md) · [Report a docs issue](https://github.com/opensquilla/opensquilla/issues/new?template=docs_report.yml)
+165
View File
@@ -0,0 +1,165 @@
# Channels
Channels let OpenSquilla run from messaging platforms while sharing the same
agent runtime as the CLI and Web UI. Use channels when you want the same agent
to answer from Slack, Telegram, Feishu/Lark, Discord, DingTalk, WeCom, Matrix,
QQ, or another supported adapter.
## Supported Channel Types
Inspect your local install:
```sh
opensquilla channels types
opensquilla channels types --json
opensquilla channels describe feishu
```
This build exposes the following channel families:
| Type | Label | Transport | Public URL needed |
| --- | --- | --- | :---: |
| `dingtalk` | DingTalk | websocket | no |
| `discord` | Discord | websocket | no |
| `feishu` | Feishu / Lark | mixed | depends on mode |
| `matrix` | Matrix | websocket | no |
| `qq` | QQ Bot | websocket | no |
| `slack` | Slack | mixed | depends on mode |
| `telegram` | Telegram | mixed | depends on mode |
| `wecom` | WeCom | webhook | yes |
The local `channels describe <type>` output is the source of truth for required
fields, secrets, extras, and restart behavior.
## Setup Flow
Interactive setup:
```sh
opensquilla configure channels
```
Add a channel explicitly:
```sh
opensquilla channels add telegram --name personal
```
Add provider-specific fields as needed. Slack supports two modes:
```sh
# Slack Socket Mode: outbound websocket, no public URL.
opensquilla channels add slack --name team \
--field connection_mode=socket \
--field app_token=xapp-... \
--token xoxb-...
# Slack Events API webhook: requires a public Request URL and signing secret.
opensquilla channels add slack --name team-webhook \
--field connection_mode=webhook \
--field signing_secret=... \
--token xoxb-...
```
Restart the gateway process after config edits:
```sh
opensquilla gateway restart
```
Verify runtime connection:
```sh
opensquilla channels status
opensquilla channels status personal --json
```
Saving a channel proves the config was written. `channels status` proves whether
the running gateway loaded and connected it.
## Manage Channels
```sh
opensquilla channels list
opensquilla channels enable <name>
opensquilla channels disable <name>
opensquilla channels edit <name>
opensquilla channels restart <name>
opensquilla channels logout <name>
opensquilla channels remove <name>
```
Use `gateway restart` after config changes. Use `channels restart <name>` only
for an already-loaded live adapter.
## Slack Modes
Slack Socket Mode uses an outbound websocket and does not require a public
Request URL. It requires the bot token (`xoxb-...`) plus an app-level token
(`xapp-...`) saved as `app_token`.
Slack webhook mode uses the Events API Request URL. It requires the bot token
plus `signing_secret`, and the gateway must be reachable by Slack.
Leave `slack_channel_id` empty when the adapter should reply to the incoming
conversation. Set it only when you want a default fallback channel. Enable
`reply_in_thread` when replies should stay in Slack threads.
## Webhook Channels
Slack webhook mode and WeCom require a public, provider-reachable URL. Feishu
and Telegram may require one depending on mode.
For public channels:
- bind the gateway to a reachable interface;
- place it behind a trusted reverse proxy or tunnel;
- configure auth;
- check provider callback URLs and secrets carefully.
Example bind for a controlled network:
```sh
opensquilla gateway run --listen 0.0.0.0 --port 18791
```
Do not expose an unauthenticated gateway to the public internet.
## Attachments and Artifacts
Channel adapters can differ in attachment and artifact delivery behavior.
OpenSquilla normalizes agent execution through the same runtime path, but the
platform transport still controls file size limits, message threading, and
download/upload capabilities.
When a channel cannot deliver a large artifact directly, use the Web UI artifact
card or session export as the recovery path.
## Troubleshooting
If a channel does not respond:
1. Check config entries:
```sh
opensquilla channels list
```
2. Check runtime status:
```sh
opensquilla channels status <name> --json
```
3. Restart the gateway process after config changes:
```sh
opensquilla gateway restart
```
4. For webhook channels, confirm the public URL, provider callback secret, and
gateway auth/network boundary.
---
[Docs index](README.md) · [Product guide](../README.product.md) · [Improve this page](contributing-docs.md) · [Report a docs issue](https://github.com/opensquilla/opensquilla/issues/new?template=docs_report.yml)

Some files were not shown because too many files have changed in this diff Show More