mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 18:25:26 -04:00
Compare commits
87 Commits
9d7a3d66c0
...
f7aa2de410
| Author | SHA1 | Date | |
|---|---|---|---|
| f7aa2de410 | |||
| 514d345334 | |||
| cd02ac7ef6 | |||
| e7abb7559d | |||
| 172a8ea7b0 | |||
| 2adae2bbba | |||
| f5d3e5098a | |||
| 4ee5ed4dce | |||
| f2bfe9b91f | |||
| 3f3c05e8c2 | |||
| 2e9f641c2c | |||
| 627a52ac44 | |||
| 397fce6e32 | |||
| 10cc2295e5 | |||
| 933ec8fec9 | |||
| 8fe98cf471 | |||
| 55b4a5e6ff | |||
| 3c0e9fcb25 | |||
| d5de061656 | |||
| 8b157f452c | |||
| daec3604f3 | |||
| e75a52efbb | |||
| f28703adf6 | |||
| d8e7cc7053 | |||
| f7e2d0c0b7 | |||
| b5a7d5ccda | |||
| f7a5047228 | |||
| 4ccb7c4890 | |||
| 1aa5ffb57c | |||
| 0939983ddf | |||
| ffc0f1dccc | |||
| 57646300a4 | |||
| f23e2e6ffb | |||
| 955455b797 | |||
| 674457384a | |||
| 2cf8bd14ae | |||
| a172522d87 | |||
| cd41de8043 | |||
| fb9e023381 | |||
| 65c7321ace | |||
| 2966ad6ef6 | |||
| d6a3c9a0fe | |||
| 7ebbc15377 | |||
| 23837f4571 | |||
| b28aa1f2c4 | |||
| 33c26bab88 | |||
| e52d078ea1 | |||
| 7ae6133d7f | |||
| 589fcd314a | |||
| 5e0cdb6cbb | |||
| 039431f5ea | |||
| aac589ee49 | |||
| 8cff1f87ee | |||
| ec4f91afdd | |||
| 268bc1d1a6 | |||
| 7f571c8f7e | |||
| 056d1fb960 | |||
| faf27c4a90 | |||
| ebbcdc15af | |||
| 4b0a977988 | |||
| 29180c4731 | |||
| 54690997ec | |||
| be046dd29a | |||
| 4d070ef4cb | |||
| 59af91cb22 | |||
| e39c9fbbd5 | |||
| ece6cebc03 | |||
| 4c41834dc7 | |||
| 96052c5e8a | |||
| afc81bdd7b | |||
| 71ccd59b54 | |||
| b20cea347a | |||
| a07fe35936 | |||
| a7766d0b7f | |||
| 6824fbb729 | |||
| f14ea6d67d | |||
| 59efa8a44b | |||
| dbd1e6572f | |||
| 2857723e47 | |||
| 011e6b07a5 | |||
| 4e0b65491e | |||
| a633611823 | |||
| 6d756215a2 | |||
| 7dedc51d9f | |||
| 9fd85f67e8 | |||
| 21ff44e9e8 | |||
| 2e99825a29 |
@@ -10,6 +10,12 @@ dist/
|
|||||||
build/
|
build/
|
||||||
.env
|
.env
|
||||||
.env.bak.*
|
.env.bak.*
|
||||||
|
# Secrets: keep plaintext and every transient secrets.env variant out of
|
||||||
|
# the build context. If an encrypted secrets.env is used, it is mounted
|
||||||
|
# at runtime — never baked into the image. Mirrored in .gitignore.
|
||||||
|
secrets.env
|
||||||
|
secrets.env.*
|
||||||
|
!secrets.env.example
|
||||||
/data/
|
/data/
|
||||||
/logs/
|
/logs/
|
||||||
.git/
|
.git/
|
||||||
|
|||||||
@@ -190,3 +190,10 @@ SEARXNG_INSTANCE=http://localhost:8080
|
|||||||
# These overlays only expose the GPU devices. The slim Odysseus image
|
# These overlays only expose the GPU devices. The slim Odysseus image
|
||||||
# still needs CUDA/ROCm userspace via Cookbook -> Dependencies (vLLM,
|
# still needs CUDA/ROCm userspace via Cookbook -> Dependencies (vLLM,
|
||||||
# llama-cpp-python, etc.) before models can actually serve on GPU.
|
# llama-cpp-python, etc.) before models can actually serve on GPU.
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Storage Paths (Docker Compose)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# APP_DATA_DIR=./data
|
||||||
|
# APP_LOGS_DIR=./logs
|
||||||
|
|||||||
+7
-6
@@ -1,8 +1,9 @@
|
|||||||
# Code owners.
|
# Code owners.
|
||||||
#
|
#
|
||||||
# Every file is owned by the maintainer, so that when branch protection has
|
# Intentionally empty for now. The catch-all rule that mapped every path to a
|
||||||
# "Require review from Code Owners" turned on, no pull request can be merged
|
# single owner froze all merges the moment "Require review from Code Owners"
|
||||||
# without the maintainer's review. This is the human gate that backs up the
|
# was enabled, because no other maintainer's approval could satisfy the gate.
|
||||||
# automated security checks. See docs/security-ci.md for how to turn it on.
|
# A per-area ownership map (security/auth, CI, frontend, agent internals, with
|
||||||
|
# multiple named owners per line) is being worked out in issue #593; once
|
||||||
* @pewdiepie-archdaemon
|
# agreed it replaces this file. Until then, required reviews and the security
|
||||||
|
# CI gate (docs/security-ci.md) remain in force via branch protection.
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Pull Request Review Template
|
||||||
|
|
||||||
|
Use this shape as a copyable reference for substantive PR reviews; GitHub does
|
||||||
|
not auto-apply this file to review comments. Omit sections that do not add
|
||||||
|
useful signal. Lead with confirmed findings; keep speculative notes out of the
|
||||||
|
public review unless they are framed as a concrete open question.
|
||||||
|
|
||||||
|
## Small PR Path
|
||||||
|
|
||||||
|
For narrow docs, typo, test-only, or obvious local fixes, a short review is
|
||||||
|
enough:
|
||||||
|
|
||||||
|
```md
|
||||||
|
LGTM after checking:
|
||||||
|
- scope:
|
||||||
|
- validation:
|
||||||
|
- residual risk:
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the fuller structure below for larger, risky, multi-finding, or
|
||||||
|
security-sensitive reviews.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
**<sub><sub></sub></sub> issue (test): Short issue title**
|
||||||
|
|
||||||
|
- **Problem:** Concrete broken flow, contract, input, or risk.
|
||||||
|
|
||||||
|
- **Impact:** Why this matters to users, CI, maintainers, data, security, or scale.
|
||||||
|
|
||||||
|
- **Ask:** Smallest practical correction or decision the author should make.
|
||||||
|
|
||||||
|
- **Location:** `path:line`
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- **question (scope, non-blocking): Short author question** Ask the concrete
|
||||||
|
intent, scope, or tradeoff question.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Ran:
|
||||||
|
- Not run:
|
||||||
|
- Residual risk:
|
||||||
|
|
||||||
|
## PR Hygiene
|
||||||
|
|
||||||
|
- Target/template/checks:
|
||||||
|
- Related, duplicate, or superseding context:
|
||||||
|
|
||||||
|
## No Findings Variant
|
||||||
|
|
||||||
|
```md
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
none confirmed
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Ran:
|
||||||
|
- Not run:
|
||||||
|
- Residual risk:
|
||||||
|
```
|
||||||
|
|
||||||
|
## Legend
|
||||||
|
|
||||||
|
- **Findings:** Verified, author-actionable issues that should be fixed or
|
||||||
|
consciously accepted before merge.
|
||||||
|
- **Priority badges:** The shields.io badges below are optional formatting for
|
||||||
|
priority labels. Plain `P0`, `P1`, `P2`, or `P3` text is also acceptable when
|
||||||
|
an external image dependency is undesirable or may not render.
|
||||||
|
- **P0:** `` -
|
||||||
|
release-blocking or actively dangerous.
|
||||||
|
- **P1:** `` -
|
||||||
|
serious bug, security risk, data-loss risk, or broken primary flow.
|
||||||
|
- **P2:** `` -
|
||||||
|
meaningful correctness, test, maintainability, or edge-case issue.
|
||||||
|
- **P3:** `` -
|
||||||
|
minor polish or low-risk cleanup.
|
||||||
|
- **Intent labels:**
|
||||||
|
- **`issue`:** A confirmed defect, regression, broken contract, or concrete
|
||||||
|
risk.
|
||||||
|
- **`suggestion`:** A non-blocking improvement that would make the PR clearer,
|
||||||
|
safer, or easier to maintain.
|
||||||
|
- **`nit`:** A tiny, non-blocking cleanup or style note. Use it only when the
|
||||||
|
author can safely ignore it without changing the review outcome.
|
||||||
|
- **`question`:** A real author-facing clarification about intent, scope, or
|
||||||
|
tradeoffs. Do not use questions to hide an issue that should be stated
|
||||||
|
directly.
|
||||||
|
- **`LGTM`:** "Looks good to me." Use only when the review found no blocking
|
||||||
|
issues, or when any remaining notes are clearly optional.
|
||||||
|
- **Decorations:** Optional labels in parentheses that clarify the finding type,
|
||||||
|
scope, or merge impact.
|
||||||
|
- **`security`:** Auth, authorization, ownership, secrets, SSRF, injection,
|
||||||
|
unsafe external input, or other trust-boundary concerns.
|
||||||
|
- **`test`:** Missing, failing, misleading, brittle, or insufficient tests.
|
||||||
|
- **`scope`:** PR scope, feature boundaries, unrelated churn, or work that
|
||||||
|
should be split into a separate issue or PR.
|
||||||
|
- **`ci`:** CI configuration, workflow failures, flaky checks, or validation
|
||||||
|
signal quality.
|
||||||
|
- **`api`:** Route, request/response, public function, schema, persistence, or
|
||||||
|
integration contract changes.
|
||||||
|
- **`docs`:** User-facing docs, contributor docs, examples, or comments that
|
||||||
|
need to change with the code.
|
||||||
|
- **`non-blocking`:** Useful feedback that should not prevent merge by
|
||||||
|
itself.
|
||||||
|
- **Finding fields:**
|
||||||
|
- **Problem:** What is wrong, what contract is ambiguous, or what risk the PR
|
||||||
|
introduces.
|
||||||
|
- **Impact:** Why the problem matters in practical terms.
|
||||||
|
- **Ask:** The smallest concrete fix, test, or decision requested from the PR
|
||||||
|
author.
|
||||||
|
- **Location:** The most useful repo-relative file and line reference for the
|
||||||
|
finding, using `path:line`.
|
||||||
|
- **Optional sections:**
|
||||||
|
- **Open Questions:** Genuine scope or intent questions; omit when there are
|
||||||
|
no real questions.
|
||||||
|
- **Validation:** What the reviewer ran, what was intentionally not run, and
|
||||||
|
what risk remains after review.
|
||||||
|
- **PR Hygiene:** Target-branch, template, CI/check, duplicate, related-work,
|
||||||
|
or superseding-PR notes.
|
||||||
|
- **`none confirmed`:** Use only when no review-worthy findings were confirmed;
|
||||||
|
still list validation gaps or residual risk when relevant.
|
||||||
@@ -19,10 +19,10 @@ jobs:
|
|||||||
name: Python syntax (compileall)
|
name: Python syntax (compileall)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
# Byte-compile sources — catches syntax errors without installing deps.
|
# Byte-compile sources — catches syntax errors without installing deps.
|
||||||
@@ -32,10 +32,10 @@ jobs:
|
|||||||
name: JS syntax (node --check)
|
name: JS syntax (node --check)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "20"
|
||||||
# Syntax-check our own JS (skip vendored libs in static/lib).
|
# Syntax-check our own JS (skip vendored libs in static/lib).
|
||||||
@@ -54,7 +54,7 @@ jobs:
|
|||||||
# ROADMAP "fresh install smoke tests" item; make this required once green.
|
# ROADMAP "fresh install smoke tests" item; make this required once green.
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -81,7 +81,7 @@ jobs:
|
|||||||
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
if: steps.docs-check.outputs.docs_only != 'true'
|
if: steps.docs-check.outputs.docs_only != 'true'
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
|
|||||||
@@ -45,17 +45,17 @@ jobs:
|
|||||||
language: [python, javascript-typescript]
|
language: [python, javascript-typescript]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
build-mode: none
|
build-mode: none
|
||||||
|
|
||||||
- name: Perform CodeQL analysis
|
- name: Perform CodeQL analysis
|
||||||
uses: github/codeql-action/analyze@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||||
with:
|
with:
|
||||||
category: "/language:${{ matrix.language }}"
|
category: "/language:${{ matrix.language }}"
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ jobs:
|
|||||||
security-events: write # upload SARIF to the Security tab
|
security-events: write # upload SARIF to the Security tab
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ jobs:
|
|||||||
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
|
TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db:2
|
||||||
|
|
||||||
- name: Upload Trivy results
|
- name: Upload Trivy results
|
||||||
uses: github/codeql-action/upload-sarif@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||||
with:
|
with:
|
||||||
sarif_file: trivy-results.sarif
|
sarif_file: trivy-results.sarif
|
||||||
category: trivy-image
|
category: trivy-image
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
# Full history so a secret committed in an earlier commit (and later
|
# Full history so a secret committed in an earlier commit (and later
|
||||||
# deleted) is still caught -- deletion does not remove it from Git.
|
# deleted) is still caught -- deletion does not remove it from Git.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
|||||||
+12
@@ -14,6 +14,15 @@ venv/
|
|||||||
.env
|
.env
|
||||||
.env.bak.*
|
.env.bak.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
# Local uv lockfile (optional, per-platform — see "Faster installs with uv" in README)
|
||||||
|
requirements.lock
|
||||||
|
|
||||||
|
# SOPS workflow — encrypted `secrets.env` is intentionally committable,
|
||||||
|
# but every variant (plaintext, manual decrypt copy, editor backup)
|
||||||
|
# must stay out of git. Mirrored in .dockerignore so the same artifacts
|
||||||
|
# also cannot enter image build layers.
|
||||||
|
secrets.env.*
|
||||||
|
!secrets.env.example
|
||||||
|
|
||||||
# Data — all user data stays local
|
# Data — all user data stays local
|
||||||
data/
|
data/
|
||||||
@@ -61,6 +70,9 @@ output.txt.txt
|
|||||||
*.tiff
|
*.tiff
|
||||||
*.pdf
|
*.pdf
|
||||||
|
|
||||||
|
# …except shipped static assets
|
||||||
|
!static/icons/*.png
|
||||||
|
|
||||||
# …except shipped demo assets in docs/ that the README links to.
|
# …except shipped demo assets in docs/ that the README links to.
|
||||||
!docs/*.jpg
|
!docs/*.jpg
|
||||||
!docs/*.jpeg
|
!docs/*.jpeg
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.14-slim
|
||||||
|
|
||||||
# System deps. tmux is required by Cookbook for background downloads/serves.
|
# System deps. tmux is required by Cookbook for background downloads/serves.
|
||||||
# openssh-client is required for Cookbook remote server tests, setup, probes,
|
# openssh-client is required for Cookbook remote server tests, setup, probes,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
A self-hosted AI workspace -- meant to be the self-hosted version of the UI experience you get from ChatGPT and Claude. But with more jank and fun. Running on your own hardware, with your own data -- local-first, privacy-first, and no trojan.
|
A self-hosted AI workspace -- meant to be the self-hosted version of the UI experience you get from ChatGPT and Claude. But with more jank and fun. Running on your own hardware, with your own data -- local-first, privacy-first, and no trojan.
|
||||||
|
|
||||||
|
[](https://repology.org/project/odysseus-ai/versions)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- **Chat** -- chat with any local model or API; adding them is super simple.<br> <sub>vLLM · llama.cpp · Ollama · OpenRouter · OpenAI · GitHub Copilot</sub>
|
- **Chat** -- chat with any local model or API; adding them is super simple.<br> <sub>vLLM · llama.cpp · Ollama · OpenRouter · OpenAI · GitHub Copilot</sub>
|
||||||
- **Agent** -- hand it tools and let it run the whole task itself.<br> <sub>built on [opencode](https://github.com/anomalyco/opencode) · MCP · web · files · shell · skills · memory</sub>
|
- **Agent** -- hand it tools and let it run the whole task itself.<br> <sub>built on [opencode](https://github.com/anomalyco/opencode) · MCP · web · files · shell · skills · memory</sub>
|
||||||
@@ -73,6 +75,10 @@ binds the web UI to `127.0.0.1` by default. If the port is taken, set
|
|||||||
`APP_PORT=7001` in `.env` and recreate the container. Set `APP_BIND=0.0.0.0`
|
`APP_PORT=7001` in `.env` and recreate the container. Set `APP_BIND=0.0.0.0`
|
||||||
only when you intentionally want LAN/reverse-proxy access.
|
only when you intentionally want LAN/reverse-proxy access.
|
||||||
|
|
||||||
|
> **On Apple Silicon (M-series) Macs:** Docker can't reach the Metal GPU, so
|
||||||
|
> Cookbook serves local models on CPU only. For GPU-accelerated model serving,
|
||||||
|
> run natively instead — see [Apple Silicon](#apple-silicon) below.
|
||||||
|
|
||||||
### Native Linux / macOS
|
### Native Linux / macOS
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
git clone https://github.com/pewdiepie-archdaemon/odysseus.git
|
||||||
@@ -333,6 +339,25 @@ To expose Odysseus on a local network or Tailscale with HTTPS:
|
|||||||
| `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) |
|
| `PyMuPDF` | PDF page rendering in the side viewer panel and form-filling. (Note: AGPL-3.0) |
|
||||||
| `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). |
|
| `markitdown` | Office/EPUB document text extraction (converts .docx/.xlsx/.pptx/.xls/.epub to Markdown). |
|
||||||
|
|
||||||
|
### Faster, reproducible installs with uv (optional)
|
||||||
|
[uv](https://docs.astral.sh/uv/) works as a drop-in replacement for the
|
||||||
|
venv + pip steps in the native install guides, no project changes are needed but this change results in faster installs along with a lockfile for reproducible environments. After [installing `uv`](https://docs.astral.sh/uv/getting-started/installation/), use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv venv venv --python 3.13
|
||||||
|
uv pip install -r requirements.txt
|
||||||
|
# then continue as usual: python setup.py, uvicorn, ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`requirements.txt` is intentionally unpinned, so two installs at different times can produce different package versions. If you want a reproducible environment (e.g. across your own machines, or to roll back after a bad upgrade), snapshot and restore exact versions with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv pip compile requirements.txt -o requirements.lock # snapshot current resolution
|
||||||
|
uv pip sync requirements.lock # reproduce it exactly later
|
||||||
|
```
|
||||||
|
|
||||||
|
`requirements.lock` is gitignored and platform-specific (compile it on the OS you deploy to). Regenerate it deliberately when you want to take upgrades. The plain `uv pip install -r requirements.txt` keeps following the unpinned requirements like pip does.
|
||||||
|
|
||||||
### Outlook / Office 365 email
|
### Outlook / Office 365 email
|
||||||
Odysseus email accounts currently use IMAP/SMTP username-password auth. Outlook
|
Odysseus email accounts currently use IMAP/SMTP username-password auth. Outlook
|
||||||
and Microsoft 365 generally require OAuth instead, so normal Microsoft mailbox
|
and Microsoft 365 generally require OAuth instead, so normal Microsoft mailbox
|
||||||
@@ -364,6 +389,7 @@ Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and th
|
|||||||
4. Keep raw service and model ports internal-only.
|
4. Keep raw service and model ports internal-only.
|
||||||
|
|
||||||
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`.
|
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`.
|
||||||
|
`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry.
|
||||||
|
|
||||||
Common internal-only ports from the default docs/compose setup:
|
Common internal-only ports from the default docs/compose setup:
|
||||||
|
|
||||||
@@ -395,8 +421,11 @@ Key settings:
|
|||||||
| `SEARXNG_SECRET` | generated on first Docker boot | Optional SearXNG cookie/CSRF secret. Leave blank unless you need to pin it. |
|
| `SEARXNG_SECRET` | generated on first Docker boot | Optional SearXNG cookie/CSRF secret. Leave blank unless you need to pin it. |
|
||||||
| `APP_BIND` | `127.0.0.1` | Docker Compose host bind address for the web UI. Use `0.0.0.0` only for intentional LAN/reverse-proxy access. |
|
| `APP_BIND` | `127.0.0.1` | Docker Compose host bind address for the web UI. Use `0.0.0.0` only for intentional LAN/reverse-proxy access. |
|
||||||
| `APP_PORT` | `7000` | Docker Compose host port for the web UI. |
|
| `APP_PORT` | `7000` | Docker Compose host port for the web UI. |
|
||||||
|
| `APP_DATA_DIR` | `./data` | Docker Compose host directory for application data volumes. |
|
||||||
|
| `APP_LOGS_DIR` | `./logs` | Docker Compose host directory for application logs. |
|
||||||
| `AUTH_ENABLED` | `true` | Enable/disable login |
|
| `AUTH_ENABLED` | `true` | Enable/disable login |
|
||||||
| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. |
|
| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. |
|
||||||
|
| `ALLOWED_ORIGINS` | `http://localhost,http://127.0.0.1` | Comma-separated exact permitted origins for cross-origin browser/API clients. |
|
||||||
| `SECURE_COOKIES` | `false` | Set true when serving Odysseus through HTTPS at a trusted proxy or private access gateway. |
|
| `SECURE_COOKIES` | `false` | Set true when serving Odysseus through HTTPS at a trusted proxy or private access gateway. |
|
||||||
| `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string |
|
| `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string |
|
||||||
| `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. |
|
| `CHROMADB_HOST` | `localhost` | ChromaDB host for vector memory. Docker overrides this to `chromadb`. |
|
||||||
@@ -440,6 +469,9 @@ docs/ landing page (index.html) + preview clips
|
|||||||
All user data lives in `data/` (gitignored): `app.db` (sessions, messages, documents),
|
All user data lives in `data/` (gitignored): `app.db` (sessions, messages, documents),
|
||||||
`memory.json`, `presets.json`, `uploads/`, `personal_docs/`, `chroma/`, `settings.json`.
|
`memory.json`, `presets.json`, `uploads/`, `personal_docs/`, `chroma/`, `settings.json`.
|
||||||
|
|
||||||
|
To back up or restore everything in `data/`, see the
|
||||||
|
[Backup & Restore guide](docs/backup-restore.md).
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
<a href="https://www.star-history.com/?repos=pewdiepie-archdaemon%2Fodysseus&type=date&legend=top-left">
|
<a href="https://www.star-history.com/?repos=pewdiepie-archdaemon%2Fodysseus&type=date&legend=top-left">
|
||||||
|
|||||||
@@ -69,10 +69,37 @@ from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_imag
|
|||||||
from starlette.responses import RedirectResponse
|
from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
# ========= LOGGING =========
|
# ========= LOGGING =========
|
||||||
logging.basicConfig(
|
import logging.handlers
|
||||||
level=logging.INFO,
|
from core.constants import DATA_DIR
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
||||||
)
|
_root_logger = logging.getLogger()
|
||||||
|
_root_logger.setLevel(logging.INFO)
|
||||||
|
_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||||
|
|
||||||
|
# Clear existing handlers to avoid duplicates
|
||||||
|
for _h in list(_root_logger.handlers):
|
||||||
|
_root_logger.removeHandler(_h)
|
||||||
|
|
||||||
|
_console_h = logging.StreamHandler()
|
||||||
|
_console_h.setFormatter(_formatter)
|
||||||
|
_root_logger.addHandler(_console_h)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_log_dir = os.path.join(DATA_DIR, "logs")
|
||||||
|
os.makedirs(_log_dir, exist_ok=True)
|
||||||
|
_log_file = os.path.join(_log_dir, "app.log")
|
||||||
|
|
||||||
|
# RotatingFileHandler is not multi-process safe (e.g. if uvicorn is run with --workers N).
|
||||||
|
# Odysseus is single-process by convention, so this is acceptable, but be aware that
|
||||||
|
# concurrent log rotation issues can arise if multiple workers are configured.
|
||||||
|
_file_h = logging.handlers.RotatingFileHandler(
|
||||||
|
_log_file, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8"
|
||||||
|
)
|
||||||
|
_file_h.setFormatter(_formatter)
|
||||||
|
_root_logger.addHandler(_file_h)
|
||||||
|
except Exception as e:
|
||||||
|
_root_logger.warning(f"Failed to initialize file logging handler (falling back to console-only): {e}")
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ========= APP =========
|
# ========= APP =========
|
||||||
@@ -140,6 +167,7 @@ _TIMEOUT_EXEMPT_PREFIXES = (
|
|||||||
"/api/cookbook/setup", # remote pacman/apt installs
|
"/api/cookbook/setup", # remote pacman/apt installs
|
||||||
"/api/upload", # large files
|
"/api/upload", # large files
|
||||||
"/api/image", # diffusion proxies (inpaint/harmonize/upscale/etc.) — own 120s httpx timeout
|
"/api/image", # diffusion proxies (inpaint/harmonize/upscale/etc.) — own 120s httpx timeout
|
||||||
|
"/api/memory/audit", # retains own 120s LLM inactivity timeout
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Authentication module — multi-user password hashing, session tokens, config pe
|
|||||||
Config stored in data/auth.json. Uses bcrypt directly.
|
Config stored in data/auth.json. Uses bcrypt directly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
@@ -83,6 +84,15 @@ def _verify_password(password: str, hashed: str) -> bool:
|
|||||||
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
|
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
class SetAdminResult(enum.Enum):
|
||||||
|
"""Outcome of AuthManager.set_admin, so callers can map each case to a
|
||||||
|
precise response instead of guessing from a bare bool."""
|
||||||
|
OK = "ok"
|
||||||
|
USER_NOT_FOUND = "user_not_found"
|
||||||
|
NOT_AUTHORIZED = "not_authorized" # requester is not an admin
|
||||||
|
LAST_ADMIN = "last_admin" # would remove the last remaining admin
|
||||||
|
|
||||||
|
|
||||||
class AuthManager:
|
class AuthManager:
|
||||||
"""Manages multi-user password + session-token auth system."""
|
"""Manages multi-user password + session-token auth system."""
|
||||||
|
|
||||||
@@ -387,6 +397,69 @@ class AuthManager:
|
|||||||
logger.info(f"Updated privileges for '{username}': {current}")
|
logger.info(f"Updated privileges for '{username}': {current}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def set_admin(self, username: str, is_admin: bool,
|
||||||
|
requesting_user: str) -> SetAdminResult:
|
||||||
|
"""Promote/demote an existing user to/from admin. Admin only.
|
||||||
|
|
||||||
|
Refuses to remove the last remaining admin so the instance can never
|
||||||
|
be locked out of admin access; self-demotion is allowed as long as
|
||||||
|
another admin remains. Admin status is re-checked live on every
|
||||||
|
request, so unlike delete/rename no session or token revocation is
|
||||||
|
needed — a demoted admin simply fails the next is_admin() gate.
|
||||||
|
|
||||||
|
Promotion stashes the user's current privilege map and demotion
|
||||||
|
restores it, so a temporary admin stint can't silently broaden a
|
||||||
|
user's non-admin access; users without a stash (created as admin,
|
||||||
|
or promoted before stashing existed) demote to DEFAULT_PRIVILEGES.
|
||||||
|
|
||||||
|
Counting admins and flipping the flag happen in one critical section
|
||||||
|
so two concurrent demotions can't race the admin count to zero.
|
||||||
|
"""
|
||||||
|
username = (username or "").strip().lower()
|
||||||
|
requesting_user = (requesting_user or "").strip().lower()
|
||||||
|
is_admin = bool(is_admin)
|
||||||
|
with self._config_lock:
|
||||||
|
target = self._config.get("users", {}).get(username)
|
||||||
|
if target is None:
|
||||||
|
return SetAdminResult.USER_NOT_FOUND
|
||||||
|
if not self.users.get(requesting_user, {}).get("is_admin"):
|
||||||
|
return SetAdminResult.NOT_AUTHORIZED
|
||||||
|
currently_admin = bool(target.get("is_admin"))
|
||||||
|
if currently_admin == is_admin:
|
||||||
|
return SetAdminResult.OK # no-op; leave privileges untouched
|
||||||
|
if currently_admin and not is_admin:
|
||||||
|
admin_count = sum(1 for d in self.users.values() if d.get("is_admin"))
|
||||||
|
if admin_count <= 1:
|
||||||
|
return SetAdminResult.LAST_ADMIN
|
||||||
|
# Write order matters for lock-free readers: get_privileges()
|
||||||
|
# reads without _config_lock and trusts is_admin, so the admin
|
||||||
|
# flag must be flipped while the stored map is safe to expose —
|
||||||
|
# before writing admin privileges on promote, after restoring
|
||||||
|
# the pre-admin map on demote.
|
||||||
|
if is_admin:
|
||||||
|
target["is_admin"] = True
|
||||||
|
# Stash the pre-admin map so a later demotion can restore it.
|
||||||
|
# While is_admin is set the stored map is inert: get_privileges
|
||||||
|
# short-circuits to ADMIN_PRIVILEGES and set_privileges refuses
|
||||||
|
# admins, so only set_admin ever touches the stash.
|
||||||
|
target["privileges_before_admin"] = dict(
|
||||||
|
target.get("privileges") or DEFAULT_PRIVILEGES
|
||||||
|
)
|
||||||
|
target["privileges"] = dict(ADMIN_PRIVILEGES)
|
||||||
|
else:
|
||||||
|
# Restore the stashed pre-admin map. Fall back to defaults for
|
||||||
|
# users created as admins (their stored map is ADMIN_PRIVILEGES,
|
||||||
|
# which must not leak past demotion — e.g. can_use_bash) and
|
||||||
|
# for admins promoted before the stash existed.
|
||||||
|
target["privileges"] = dict(
|
||||||
|
target.pop("privileges_before_admin", None)
|
||||||
|
or DEFAULT_PRIVILEGES
|
||||||
|
)
|
||||||
|
target["is_admin"] = False
|
||||||
|
self._save()
|
||||||
|
logger.info("Set is_admin=%s for '%s' (by '%s')", is_admin, username, requesting_user)
|
||||||
|
return SetAdminResult.OK
|
||||||
|
|
||||||
def change_password(self, username: str, current_password: str, new_password: str) -> bool:
|
def change_password(self, username: str, current_password: str, new_password: str) -> bool:
|
||||||
username = username.strip().lower()
|
username = username.strip().lower()
|
||||||
if username not in self.users:
|
if username not in self.users:
|
||||||
|
|||||||
@@ -1602,6 +1602,7 @@ class CalendarCal(TimestampMixin, Base):
|
|||||||
# NULL for local calendars and for CalDAV calendars created before
|
# NULL for local calendars and for CalDAV calendars created before
|
||||||
# multi-account support was added (treated as "use any configured account").
|
# multi-account support was added (treated as "use any configured account").
|
||||||
account_id = Column(String, nullable=True, index=True)
|
account_id = Column(String, nullable=True, index=True)
|
||||||
|
caldav_base_url = Column(String, nullable=True)
|
||||||
|
|
||||||
events = relationship("CalendarEvent", back_populates="calendar", cascade="all, delete-orphan")
|
events = relationship("CalendarEvent", back_populates="calendar", cascade="all, delete-orphan")
|
||||||
|
|
||||||
@@ -1632,10 +1633,27 @@ class CalendarEvent(TimestampMixin, Base):
|
|||||||
# vanishes upstream). NULL/local = created locally (agent, email triage, or
|
# vanishes upstream). NULL/local = created locally (agent, email triage, or
|
||||||
# a UI event whose write-back failed) and must NOT be pruned by the sync.
|
# a UI event whose write-back failed) and must NOT be pruned by the sync.
|
||||||
origin = Column(String, nullable=True, index=True)
|
origin = Column(String, nullable=True, index=True)
|
||||||
|
remote_href = Column(String, nullable=True) # CalDAV object URL for updates/deletes
|
||||||
|
remote_etag = Column(String, nullable=True) # Last seen CalDAV ETag, when available
|
||||||
|
caldav_sync_pending = Column(String, nullable=True) # create | update | delete retry marker
|
||||||
|
|
||||||
calendar = relationship("CalendarCal", back_populates="events")
|
calendar = relationship("CalendarCal", back_populates="events")
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarDeletedEvent(TimestampMixin, Base):
|
||||||
|
"""Hidden CalDAV delete tombstone retained until remote delete succeeds."""
|
||||||
|
__tablename__ = "caldav_deleted_events"
|
||||||
|
|
||||||
|
uid = Column(String, primary_key=True, index=True)
|
||||||
|
owner = Column(String, nullable=True, index=True)
|
||||||
|
calendar_id = Column(String, nullable=True, index=True)
|
||||||
|
remote_href = Column(String, nullable=True)
|
||||||
|
remote_etag = Column(String, nullable=True)
|
||||||
|
caldav_base_url = Column(String, nullable=True)
|
||||||
|
summary = Column(String, nullable=True)
|
||||||
|
last_error = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class Integration(TimestampMixin, Base):
|
class Integration(TimestampMixin, Base):
|
||||||
"""An external service connection (email, RSS, webhook, etc.)."""
|
"""An external service connection (email, RSS, webhook, etc.)."""
|
||||||
__tablename__ = "integrations"
|
__tablename__ = "integrations"
|
||||||
@@ -1767,6 +1785,7 @@ def init_db():
|
|||||||
_migrate_add_calendar_is_utc()
|
_migrate_add_calendar_is_utc()
|
||||||
_migrate_add_calendar_origin()
|
_migrate_add_calendar_origin()
|
||||||
_migrate_add_calendar_account_id()
|
_migrate_add_calendar_account_id()
|
||||||
|
_migrate_add_caldav_sync_columns()
|
||||||
_migrate_chat_messages_fts()
|
_migrate_chat_messages_fts()
|
||||||
_migrate_encrypt_email_passwords()
|
_migrate_encrypt_email_passwords()
|
||||||
_migrate_encrypt_signatures()
|
_migrate_encrypt_signatures()
|
||||||
@@ -2067,6 +2086,31 @@ def _migrate_add_calendar_account_id():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_add_caldav_sync_columns():
|
||||||
|
"""Add remote CalDAV metadata used for bidirectional sync."""
|
||||||
|
import sqlite3
|
||||||
|
db_path = DATABASE_URL.replace("sqlite:///", "")
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
ev_columns = [row[1] for row in conn.execute("PRAGMA table_info(calendar_events)").fetchall()]
|
||||||
|
if ev_columns and "remote_href" not in ev_columns:
|
||||||
|
conn.execute("ALTER TABLE calendar_events ADD COLUMN remote_href TEXT")
|
||||||
|
if ev_columns and "remote_etag" not in ev_columns:
|
||||||
|
conn.execute("ALTER TABLE calendar_events ADD COLUMN remote_etag TEXT")
|
||||||
|
if ev_columns and "caldav_sync_pending" not in ev_columns:
|
||||||
|
conn.execute("ALTER TABLE calendar_events ADD COLUMN caldav_sync_pending TEXT")
|
||||||
|
|
||||||
|
cal_columns = [row[1] for row in conn.execute("PRAGMA table_info(calendars)").fetchall()]
|
||||||
|
if cal_columns and "caldav_base_url" not in cal_columns:
|
||||||
|
conn.execute("ALTER TABLE calendars ADD COLUMN caldav_base_url TEXT")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
logging.getLogger(__name__).warning(f"CalDAV sync metadata migration failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _migrate_add_calendar_metadata():
|
def _migrate_add_calendar_metadata():
|
||||||
"""Add importance/event_type/last_pinged columns to calendar_events table."""
|
"""Add importance/event_type/last_pinged columns to calendar_events table."""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|||||||
@@ -16,18 +16,18 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
|
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data:z
|
- ${APP_DATA_DIR:-./data}:/app/data:z
|
||||||
- ./logs:/app/logs:z
|
- ${APP_LOGS_DIR:-./logs}:/app/logs:z
|
||||||
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
|
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
|
||||||
# add the shown public key to each remote server's authorized_keys.
|
# add the shown public key to each remote server's authorized_keys.
|
||||||
- ./data/ssh:/app/.ssh:z
|
- ${APP_DATA_DIR:-./data}/ssh:/app/.ssh:z
|
||||||
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
|
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
|
||||||
# container, so persist its HuggingFace cache under ./data/huggingface.
|
# container, so persist its HuggingFace cache under ./data/huggingface.
|
||||||
- ./data/huggingface:/app/.cache/huggingface:z
|
- ${APP_DATA_DIR:-./data}/huggingface:/app/.cache/huggingface:z
|
||||||
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
|
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
|
||||||
# land under /app/.local for the odysseus user. Persist them so a
|
# land under /app/.local for the odysseus user. Persist them so a
|
||||||
# container recreate does not silently remove installed serve engines.
|
# container recreate does not silently remove installed serve engines.
|
||||||
- ./data/local:/app/.local:z
|
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
# Lets the container reach local services on the Docker host, including
|
# Lets the container reach local services on the Docker host, including
|
||||||
# Ollama at http://host.docker.internal:11434.
|
# Ollama at http://host.docker.internal:11434.
|
||||||
|
|||||||
@@ -15,18 +15,18 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
|
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data:z
|
- ${APP_DATA_DIR:-./data}:/app/data:z
|
||||||
- ./logs:/app/logs:z
|
- ${APP_LOGS_DIR:-./logs}:/app/logs:z
|
||||||
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
|
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
|
||||||
# add the shown public key to each remote server's authorized_keys.
|
# add the shown public key to each remote server's authorized_keys.
|
||||||
- ./data/ssh:/app/.ssh:z
|
- ${APP_DATA_DIR:-./data}/ssh:/app/.ssh:z
|
||||||
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
|
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
|
||||||
# container, so persist its HuggingFace cache under ./data/huggingface.
|
# container, so persist its HuggingFace cache under ./data/huggingface.
|
||||||
- ./data/huggingface:/app/.cache/huggingface:z
|
- ${APP_DATA_DIR:-./data}/huggingface:/app/.cache/huggingface:z
|
||||||
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
|
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
|
||||||
# land under /app/.local for the odysseus user. Persist them so a
|
# land under /app/.local for the odysseus user. Persist them so a
|
||||||
# container recreate does not silently remove installed serve engines.
|
# container recreate does not silently remove installed serve engines.
|
||||||
- ./data/local:/app/.local:z
|
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
# Lets the container reach local services on the Docker host, including
|
# Lets the container reach local services on the Docker host, including
|
||||||
# Ollama at http://host.docker.internal:11434.
|
# Ollama at http://host.docker.internal:11434.
|
||||||
|
|||||||
+5
-5
@@ -4,18 +4,18 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
|
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data:z
|
- ${APP_DATA_DIR:-./data}:/app/data:z
|
||||||
- ./logs:/app/logs:z
|
- ${APP_LOGS_DIR:-./logs}:/app/logs:z
|
||||||
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
|
# Cookbook remote-server SSH identity. Odysseus can generate a key here;
|
||||||
# add the shown public key to each remote server's authorized_keys.
|
# add the shown public key to each remote server's authorized_keys.
|
||||||
- ./data/ssh:/app/.ssh:z
|
- ${APP_DATA_DIR:-./data}/ssh:/app/.ssh:z
|
||||||
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
|
# Cookbook local model cache. Inside Docker, "Local" means the Odysseus
|
||||||
# container, so persist its HuggingFace cache under ./data/huggingface.
|
# container, so persist its HuggingFace cache under ./data/huggingface.
|
||||||
- ./data/huggingface:/app/.cache/huggingface:z
|
- ${APP_DATA_DIR:-./data}/huggingface:/app/.cache/huggingface:z
|
||||||
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
|
# Cookbook-installed Python CLIs/packages (vLLM, llama-cpp-python, etc.)
|
||||||
# land under /app/.local for the odysseus user. Persist them so a
|
# land under /app/.local for the odysseus user. Persist them so a
|
||||||
# container recreate does not silently remove installed serve engines.
|
# container recreate does not silently remove installed serve engines.
|
||||||
- ./data/local:/app/.local:z
|
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
# Lets the container reach local services on the Docker host, including
|
# Lets the container reach local services on the Docker host, including
|
||||||
# Ollama at http://host.docker.internal:11434.
|
# Ollama at http://host.docker.internal:11434.
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Agent migration manifests
|
||||||
|
|
||||||
|
Odysseus should be able to learn from another agent without blindly trusting
|
||||||
|
that agent's whole state. The safe migration path is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
source agent export -> source adapter -> agent-migration.v1 manifest -> preview -> apply
|
||||||
|
```
|
||||||
|
|
||||||
|
The manifest is intentionally source-neutral. OpenClaw, Hermes, a folder of
|
||||||
|
Markdown notes, or any other agent can have its own adapter, but Odysseus only
|
||||||
|
needs to understand the normalized manifest.
|
||||||
|
|
||||||
|
## Why not import everything as memory?
|
||||||
|
|
||||||
|
Durable memory should stay compact and useful. Long notes, logs, session
|
||||||
|
transcripts, and project archives are useful context, but they are not all
|
||||||
|
memories. A good migration keeps two layers separate:
|
||||||
|
|
||||||
|
- **Archive documents** preserve source material for search, reading, and later
|
||||||
|
extraction.
|
||||||
|
- **Memory candidates** are short facts or preferences that can be reviewed
|
||||||
|
before being saved into Odysseus memory.
|
||||||
|
|
||||||
|
This keeps Odysseus' existing memory-review flow intact while giving it better
|
||||||
|
source material to review.
|
||||||
|
|
||||||
|
## Manifest shape
|
||||||
|
|
||||||
|
`agent-migration.v1` is a JSON object:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": "agent-migration.v1",
|
||||||
|
"generated_at": "2026-06-06T00:00:00Z",
|
||||||
|
"source": {
|
||||||
|
"name": "example-agent",
|
||||||
|
"kind": "generic"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"item_count": 3,
|
||||||
|
"counts_by_kind": {
|
||||||
|
"memory": 1,
|
||||||
|
"skill": 1,
|
||||||
|
"conversation_thread": 1,
|
||||||
|
"archive_document": 1
|
||||||
|
},
|
||||||
|
"warning_count": 0
|
||||||
|
},
|
||||||
|
"items": [],
|
||||||
|
"warnings": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each item has a stable `id`, a `kind`, source metadata, and enough content for a
|
||||||
|
future importer to preview it before applying.
|
||||||
|
|
||||||
|
Supported item kinds in the first pass:
|
||||||
|
|
||||||
|
- `memory` — a candidate memory with `text`, `category`, `source`, and
|
||||||
|
provenance metadata.
|
||||||
|
- `skill` — a `SKILL.md` file with content and parsed frontmatter metadata.
|
||||||
|
- `conversation_thread` — a normalized transcript thread from an exported chat
|
||||||
|
history. Message content is optional; adapters can preserve only thread
|
||||||
|
metadata, message counts, timestamps, and hashes when a manifest should stay
|
||||||
|
small or avoid embedding private transcript text.
|
||||||
|
- `archive_document` — long-form source material. Content is optional; adapters
|
||||||
|
can preserve only path/hash/size metadata when a manifest should stay small.
|
||||||
|
|
||||||
|
## Build a manifest
|
||||||
|
|
||||||
|
Use the read-only helper:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/agent_migration_manifest.py \
|
||||||
|
--source-name old-agent \
|
||||||
|
--source-kind generic \
|
||||||
|
--memory-json /path/to/memories.json \
|
||||||
|
--skills-dir /path/to/skills \
|
||||||
|
--conversation-json /path/to/conversations.json \
|
||||||
|
--archive /path/to/notes \
|
||||||
|
--output /tmp/agent-migration.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The helper does not write to `data/`, call an LLM, import Odysseus modules, or
|
||||||
|
modify the source. It only writes JSON.
|
||||||
|
|
||||||
|
Memory JSON may be:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
"A plain memory string",
|
||||||
|
{
|
||||||
|
"text": "A categorized memory",
|
||||||
|
"category": "preference",
|
||||||
|
"source": "old-agent"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
or an object containing a list under `memories`, `memory`, `items`, or `data`.
|
||||||
|
|
||||||
|
Skills are scanned recursively for `SKILL.md`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/agent_migration_manifest.py \
|
||||||
|
--source-name hermes \
|
||||||
|
--source-kind hermes \
|
||||||
|
--skills-dir ~/.hermes/skills \
|
||||||
|
--output /tmp/hermes-skills-manifest.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Archive documents are metadata-only by default. To embed text content:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/agent_migration_manifest.py \
|
||||||
|
--source-name notes-export \
|
||||||
|
--archive /path/to/markdown-notes \
|
||||||
|
--include-archive-content \
|
||||||
|
--output /tmp/notes-manifest.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Conversation exports are also metadata-only by default:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/agent_migration_manifest.py \
|
||||||
|
--source-name chatgpt-export \
|
||||||
|
--source-kind chatgpt \
|
||||||
|
--conversation-json /path/to/conversations.json \
|
||||||
|
--output /tmp/chatgpt-conversations-manifest.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The first pass supports generic conversation JSON such as:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "thread-1",
|
||||||
|
"title": "Project plan",
|
||||||
|
"messages": [
|
||||||
|
{"role": "user", "content": "Can we design this?"},
|
||||||
|
{"role": "assistant", "content": "Yes, start with a narrow slice."}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
It also recognizes ChatGPT-style `mapping` exports from `conversations.json`.
|
||||||
|
To embed normalized messages:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/agent_migration_manifest.py \
|
||||||
|
--source-name chatgpt-export \
|
||||||
|
--source-kind chatgpt \
|
||||||
|
--conversation-json /path/to/conversations.json \
|
||||||
|
--include-conversation-content \
|
||||||
|
--max-conversation-messages 2000 \
|
||||||
|
--output /tmp/chatgpt-conversations-with-content.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Content embedding is explicit because exported chat histories can be huge and
|
||||||
|
private. A future source-specific adapter can add ZIP traversal, attachment
|
||||||
|
metadata, and provider-specific project/workspace fields while still emitting
|
||||||
|
the same `conversation_thread` manifest item.
|
||||||
|
|
||||||
|
## Recommended apply behavior
|
||||||
|
|
||||||
|
A future Odysseus importer should treat the manifest as untrusted user-provided
|
||||||
|
data and apply it in stages:
|
||||||
|
|
||||||
|
1. Show a dry-run summary with counts, warnings, duplicates, and sample items.
|
||||||
|
2. Back up current `data/` state before writing anything.
|
||||||
|
3. Import archive documents as documents or another searchable source, not as
|
||||||
|
memory.
|
||||||
|
4. Import conversation threads as searchable archived context first, with
|
||||||
|
citations back to the source thread. Do not turn whole transcripts into
|
||||||
|
memory.
|
||||||
|
5. Show memory candidates for review before saving through the normal memory
|
||||||
|
path.
|
||||||
|
6. Import skills only after name/category conflict checks.
|
||||||
|
7. Skip secrets by default. Credentials need explicit, provider-specific flows.
|
||||||
|
|
||||||
|
## What belongs in source adapters?
|
||||||
|
|
||||||
|
Adapters can be source-specific. The core manifest should not be.
|
||||||
|
|
||||||
|
For example, an OpenClaw adapter may know about OpenClaw's workspace files. A
|
||||||
|
Hermes adapter may know about `~/.hermes/config.yaml` and `~/.hermes/skills`.
|
||||||
|
A ChatGPT adapter may know about `conversations.json`, uploaded-file metadata,
|
||||||
|
and image attachment directories. A Claude adapter may know about Claude's
|
||||||
|
export shape and project boundaries. A generic adapter may only know about
|
||||||
|
memory JSON, conversation JSON, `SKILL.md`, and Markdown folders.
|
||||||
|
|
||||||
|
Nonstandard folders should be adapter details, not required Odysseus concepts.
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Backup & Restore
|
||||||
|
|
||||||
|
Odysseus keeps all of your state in the `data/` directory — the SQLite database
|
||||||
|
(`app.db`), the Fernet encryption key (`data/.app_key`), the vault, memory, RAG
|
||||||
|
indexes, personal documents, and uploads. The `scripts/odysseus-backup` tool
|
||||||
|
snapshots that directory into a single gzip tarball and restores it later.
|
||||||
|
|
||||||
|
Snapshots are safe to take while the app is running: SQLite databases are copied
|
||||||
|
through SQLite's own `.backup` API rather than a raw file copy, so an in-flight
|
||||||
|
write can't corrupt the snapshot.
|
||||||
|
|
||||||
|
> **A snapshot contains your secrets.** The tarball includes the Fernet
|
||||||
|
> encryption key (`data/.app_key`), the vault, sessions, and any stored
|
||||||
|
> provider/API tokens — so treat it like a password. Store backups somewhere
|
||||||
|
> private, never commit them to Git, and prefer an encrypted destination when
|
||||||
|
> copying them offsite.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
Run the tool from the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a snapshot → backups/odysseus-backup-<YYYYMMDD-HHMMSS>.tar.gz
|
||||||
|
./scripts/odysseus-backup snapshot
|
||||||
|
|
||||||
|
# List existing snapshots (most recent first)
|
||||||
|
./scripts/odysseus-backup list
|
||||||
|
|
||||||
|
# Check a tarball's integrity without extracting it
|
||||||
|
./scripts/odysseus-backup verify backups/odysseus-backup-20260101-120000.tar.gz
|
||||||
|
|
||||||
|
# Restore (destructive — see the warning below)
|
||||||
|
./scripts/odysseus-backup restore backups/odysseus-backup-20260101-120000.tar.gz --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
The script depends only on the Python standard library, so any `python3` on your
|
||||||
|
`PATH` will run it — you don't need the app's virtualenv active.
|
||||||
|
|
||||||
|
Every command prints a JSON result. Add `--pretty` for indented output.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `snapshot`
|
||||||
|
|
||||||
|
Writes a `tar.gz` of `data/` to `backups/<timestamp>.tar.gz`.
|
||||||
|
|
||||||
|
| Flag | Effect |
|
||||||
|
| --- | --- |
|
||||||
|
| `--out PATH` | Write to a specific path instead of the default `backups/` location. Must be **outside** `data/`. |
|
||||||
|
| `--include-research` | Include `data/deep_research/` (skipped by default — research runs are large). |
|
||||||
|
| `--include-attachments` | Include `data/mail-attachments/` (skipped by default — cached IMAP extractions, re-derivable). |
|
||||||
|
|
||||||
|
By default the snapshot includes everything under `data/` **except**
|
||||||
|
`deep_research/` and `mail-attachments/`. Personal uploads and documents are
|
||||||
|
included.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Snapshot straight to a mounted NAS path
|
||||||
|
./scripts/odysseus-backup snapshot --out /mnt/nas/odysseus-$(date +%F).tar.gz
|
||||||
|
|
||||||
|
# Full snapshot including research runs and mail attachments
|
||||||
|
./scripts/odysseus-backup snapshot --include-research --include-attachments
|
||||||
|
```
|
||||||
|
|
||||||
|
### `list`
|
||||||
|
|
||||||
|
Lists the tarballs in `backups/`, most recent first, with size and modification
|
||||||
|
time.
|
||||||
|
|
||||||
|
### `verify PATH`
|
||||||
|
|
||||||
|
Opens the tarball read-only and walks every member to confirm it is intact and
|
||||||
|
safe to restore. Nothing is extracted. Use this before relying on an old backup
|
||||||
|
or after copying one across machines.
|
||||||
|
|
||||||
|
### `restore PATH --yes`
|
||||||
|
|
||||||
|
Overwrites `data/` from a tarball.
|
||||||
|
|
||||||
|
> **Restore is destructive.** It replaces the current `data/` directory. `--yes`
|
||||||
|
> is required so a mistyped command can't wipe your live state.
|
||||||
|
|
||||||
|
Restore is not a blind delete: before extracting, the tool **renames your current
|
||||||
|
`data/` to `data.before-restore-<timestamp>`** in the repository root. If a
|
||||||
|
restore turns out to be wrong, your previous state is still there — delete the
|
||||||
|
restored `data/` and rename the stashed directory back. The restore path is also
|
||||||
|
validated entry-by-entry: archives containing absolute paths, `..` segments,
|
||||||
|
symlinks, or anything outside `data/` are rejected.
|
||||||
|
|
||||||
|
## Scheduling offsite backups
|
||||||
|
|
||||||
|
The tarball output composes cleanly with cron and any copy tool. For example, a
|
||||||
|
nightly snapshot copied offsite:
|
||||||
|
|
||||||
|
```cron
|
||||||
|
0 3 * * * cd /path/to/odysseus && ./scripts/odysseus-backup snapshot --out "/mnt/nas/odysseus-$(date +\%F).tar.gz"
|
||||||
|
```
|
||||||
|
|
||||||
|
Swap the `--out` target for `scp`, `rclone`, `s3cmd`, or similar to push the
|
||||||
|
snapshot to remote storage.
|
||||||
|
|
||||||
|
## Docker vs native installs
|
||||||
|
|
||||||
|
The tool reads `data/` and writes `backups/` relative to the repository root, so
|
||||||
|
where you run it matters:
|
||||||
|
|
||||||
|
- **Native installs** — run it from the repo root as shown above. `data/` and
|
||||||
|
`backups/` are both in the repo directory.
|
||||||
|
- **Docker** — `docker-compose.yml` bind-mounts the host's `./data` to
|
||||||
|
`/app/data`, so the live data is also present on the host. **Run the tool on
|
||||||
|
the host** from the repo root; the snapshot reads the bind-mounted `./data` and
|
||||||
|
writes to `./backups` on the host. Running it *inside* the container is not
|
||||||
|
recommended, because `backups/` is not a mounted volume and the tarball would
|
||||||
|
be lost when the container is recreated.
|
||||||
|
|
||||||
|
> **ChromaDB caveat (Docker only).** In the Docker setup, ChromaDB stores its
|
||||||
|
> vectors in a separate Compose-managed volume (declared as `chromadb-data`),
|
||||||
|
> **not** under `./data`. `odysseus-backup` therefore does not capture the Docker
|
||||||
|
> ChromaDB store. Back it up separately if you need it. Compose prefixes the
|
||||||
|
> volume with the project name, so find the real name first
|
||||||
|
> (`docker volume ls | grep chromadb`), then archive it — for example:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> docker run --rm -v <project>_chromadb-data:/data -v "$PWD":/backup \
|
||||||
|
> alpine tar czf /backup/chromadb.tar.gz -C /data .
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> On native installs ChromaDB lives at `data/chroma/` and is included in the
|
||||||
|
> snapshot normally.
|
||||||
+14
-1
@@ -141,7 +141,20 @@ if (-not (Find-GitBash)) {
|
|||||||
Write-Host " https://git-scm.com/download/win" -ForegroundColor Yellow
|
Write-Host " https://git-scm.com/download/win" -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
|
||||||
# 6. Start the server (use `python -m uvicorn` - bare `uvicorn` may not be on PATH)
|
# 6. Point CUDA_PATH at a real CUDA toolkit so GPU llama-cpp-python can import.
|
||||||
|
$cudaBase = "C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA"
|
||||||
|
if (Test-Path $cudaBase) {
|
||||||
|
$cudaBest = Get-ChildItem $cudaBase -Directory -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { Test-Path (Join-Path $_.FullName "bin") } |
|
||||||
|
Sort-Object { try { [version]($_.Name -replace "^v", "") } catch { [version]"0.0" } } -Descending |
|
||||||
|
Select-Object -First 1
|
||||||
|
if ($cudaBest) {
|
||||||
|
$env:CUDA_PATH = $cudaBest.FullName
|
||||||
|
Write-Host ("Using CUDA_PATH = " + $cudaBest.FullName) -ForegroundColor Cyan
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 7. Start the server (use `python -m uvicorn` - bare `uvicorn` may not be on PATH)
|
||||||
Write-Step ("Starting Odysseus at http://{0}:{1}" -f $BindHost, $Port)
|
Write-Step ("Starting Odysseus at http://{0}:{1}" -f $BindHost, $Port)
|
||||||
Write-Host "Press Ctrl+C to stop."
|
Write-Host "Press Ctrl+C to stop."
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|||||||
@@ -93,16 +93,15 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|||||||
if category_filter:
|
if category_filter:
|
||||||
msg += f" in category '{category_filter}'"
|
msg += f" in category '{category_filter}'"
|
||||||
return [TextContent(type="text", text=msg + ".")]
|
return [TextContent(type="text", text=msg + ".")]
|
||||||
|
|
||||||
lines = [f"Found {len(memories)} memory entries:\n"]
|
lines = [f"Found {len(memories)} memory entries:\n"]
|
||||||
for m in memories[:100]:
|
for m in memories:
|
||||||
cat = m.get("category", "fact")
|
cat = m.get("category", "fact")
|
||||||
mid = m.get("id", "?")[:8]
|
mid = m.get("id", "?")[:8]
|
||||||
text = m.get("text", "")
|
text = m.get("text", "")
|
||||||
if len(text) > 150:
|
if len(text) > 150:
|
||||||
text = text[:150] + "..."
|
text = text[:150] + "..."
|
||||||
lines.append(f"- [{cat}] `{mid}` — {text}")
|
lines.append(f"- [{cat}] `{mid}` — {text}")
|
||||||
if len(memories) > 100:
|
|
||||||
lines.append(f"... and {len(memories) - 100} more")
|
|
||||||
return [TextContent(type="text", text="\n".join(lines))]
|
return [TextContent(type="text", text="\n".join(lines))]
|
||||||
|
|
||||||
elif action == "add":
|
elif action == "add":
|
||||||
|
|||||||
Generated
+12
-9
@@ -5,16 +5,16 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.98.0"
|
"@anthropic-ai/sdk": "^0.104.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antithesishq/bombadil": "^0.3.2"
|
"@antithesishq/bombadil": "^0.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@anthropic-ai/sdk": {
|
"node_modules/@anthropic-ai/sdk": {
|
||||||
"version": "0.98.0",
|
"version": "0.104.1",
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.98.0.tgz",
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.1.tgz",
|
||||||
"integrity": "sha512-N7aXtCvC5g6T1Y4V29lJjceu/zTkVkIZF0jdBvagr0TRFHuKeImffalGWEfqZKrvjH+IQbzJWw6TmSmUzrlMgg==",
|
"integrity": "sha512-gGACa/+IaiXzRRmF96aOhamoBgapKRBiFWbmmTFP8aMkpaEcuStF+Q61bjo4vPxBM7gqWJNZqsngslRdnLHv0Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"json-schema-to-ts": "^3.1.1",
|
"json-schema-to-ts": "^3.1.1",
|
||||||
@@ -33,11 +33,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@antithesishq/bombadil": {
|
"node_modules/@antithesishq/bombadil": {
|
||||||
"version": "0.3.2",
|
"version": "0.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@antithesishq/bombadil/-/bombadil-0.5.0.tgz",
|
||||||
"integrity": "sha512-ATy1w9ZY5gbny1H8DFc7rxZitT7DLLLFDiGcRZe+8TQiUrV5tLO+IJGOVNNLp3RpCqjZqSsxGiKoQsx31ipV1g==",
|
"integrity": "sha512-s0zImmr0iyvSP6QcVLvf40CUiZYIdWBAxiq20uhzujwvfitYa3PGJN652k/pLtVccHM/JrGQxZdvLnihZpltHA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"bombadil": "bin/bombadil.js"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/runtime": {
|
"node_modules/@babel/runtime": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
|
|||||||
+2
-2
@@ -4,9 +4,9 @@
|
|||||||
"url": "https://github.com/pewdiepie-archdaemon/odysseus.git"
|
"url": "https://github.com/pewdiepie-archdaemon/odysseus.git"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antithesishq/bombadil": "^0.3.2"
|
"@antithesishq/bombadil": "^0.5.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.98.0"
|
"@anthropic-ai/sdk": "^0.104.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,4 +33,4 @@ PyMuPDF
|
|||||||
# magika (onnxruntime), already a core dep via fastembed. We avoid the
|
# magika (onnxruntime), already a core dep via fastembed. We avoid the
|
||||||
# [all]/Azure/audio extras (cloud + heavy). Pinned to a release >30 days old per
|
# [all]/Azure/audio extras (cloud + heavy). Pinned to a release >30 days old per
|
||||||
# the dependency-age discussion in issue #485.
|
# the dependency-age discussion in issue #485.
|
||||||
markitdown[docx,pptx,xlsx,xls]==0.1.5
|
markitdown[docx,pptx,xlsx,xls]==0.1.6
|
||||||
|
|||||||
+2
-2
@@ -3,8 +3,8 @@ uvicorn
|
|||||||
python-multipart
|
python-multipart
|
||||||
python-dotenv
|
python-dotenv
|
||||||
httpx
|
httpx
|
||||||
pydantic>=2.0
|
pydantic>=2.13.4
|
||||||
pydantic-settings>=2.0
|
pydantic-settings>=2.14.1
|
||||||
SQLAlchemy
|
SQLAlchemy
|
||||||
pypdf
|
pypdf
|
||||||
beautifulsoup4
|
beautifulsoup4
|
||||||
|
|||||||
+31
-1
@@ -12,7 +12,7 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from core.atomic_io import atomic_write_json, atomic_write_text
|
from core.atomic_io import atomic_write_json, atomic_write_text
|
||||||
from core.auth import AuthManager
|
from core.auth import AuthManager, SetAdminResult
|
||||||
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, SKILLS_DIR
|
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, SKILLS_DIR
|
||||||
from src.rate_limiter import RateLimiter
|
from src.rate_limiter import RateLimiter
|
||||||
from src.settings_scrub import scrub_settings
|
from src.settings_scrub import scrub_settings
|
||||||
@@ -73,6 +73,11 @@ class DeleteUserRequest(BaseModel):
|
|||||||
class RenameUserRequest(BaseModel):
|
class RenameUserRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
|
|
||||||
|
|
||||||
|
class SetAdminRequest(BaseModel):
|
||||||
|
is_admin: bool
|
||||||
|
|
||||||
|
|
||||||
class SetOpenRegistrationRequest(BaseModel):
|
class SetOpenRegistrationRequest(BaseModel):
|
||||||
enabled: bool
|
enabled: bool
|
||||||
|
|
||||||
@@ -487,6 +492,31 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
invalidator()
|
invalidator()
|
||||||
return {"ok": True, "username": new_username, "renamed_self": old_username == user}
|
return {"ok": True, "username": new_username, "renamed_self": old_username == user}
|
||||||
|
|
||||||
|
@router.put("/users/{username}/admin")
|
||||||
|
async def set_user_admin(username: str, body: SetAdminRequest, request: Request):
|
||||||
|
"""Promote/demote a user to/from admin. Admin only.
|
||||||
|
|
||||||
|
The last remaining admin can't be demoted (no lockout). Self-demotion
|
||||||
|
is allowed while another admin exists; the `self` flag tells the UI to
|
||||||
|
reload the acting user into the normal-user view.
|
||||||
|
"""
|
||||||
|
user = _get_current_user(request)
|
||||||
|
if not user or not auth_manager.is_admin(user):
|
||||||
|
raise HTTPException(403, "Admin only")
|
||||||
|
result = auth_manager.set_admin(username, body.is_admin, user)
|
||||||
|
if result is SetAdminResult.USER_NOT_FOUND:
|
||||||
|
raise HTTPException(404, "User not found")
|
||||||
|
if result is SetAdminResult.NOT_AUTHORIZED:
|
||||||
|
raise HTTPException(403, "Admin only")
|
||||||
|
if result is SetAdminResult.LAST_ADMIN:
|
||||||
|
raise HTTPException(400, "Cannot demote the last admin")
|
||||||
|
target = (username or "").strip().lower()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"is_admin": body.is_admin,
|
||||||
|
"self": target == (user or "").strip().lower(),
|
||||||
|
}
|
||||||
|
|
||||||
@router.post("/signup-toggle", deprecated=True)
|
@router.post("/signup-toggle", deprecated=True)
|
||||||
async def toggle_signup(request: Request):
|
async def toggle_signup(request: Request):
|
||||||
"""
|
"""
|
||||||
|
|||||||
+65
-29
@@ -11,7 +11,7 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy import or_, and_
|
from sqlalchemy import or_, and_
|
||||||
from dateutil.rrule import rrulestr
|
from dateutil.rrule import rrulestr
|
||||||
|
|
||||||
from core.database import SessionLocal, CalendarCal, CalendarEvent
|
from core.database import SessionLocal, CalendarCal, CalendarDeletedEvent, CalendarEvent
|
||||||
from src.auth_helpers import require_user
|
from src.auth_helpers import require_user
|
||||||
from src.upload_limits import read_upload_limited, ICS_MAX_BYTES
|
from src.upload_limits import read_upload_limited, ICS_MAX_BYTES
|
||||||
|
|
||||||
@@ -126,6 +126,54 @@ def _resolve_base_uid(uid: str) -> str:
|
|||||||
raise ValueError("malformed compound UID: missing base before ::")
|
raise ValueError("malformed compound UID: missing base before ::")
|
||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
async def _push_caldav_event_after_commit(owner: str, uid: str, action: str):
|
||||||
|
"""Best-effort CalDAV write-through. Local writes stay authoritative if
|
||||||
|
the remote server is unreachable; pending flags let /sync retry later."""
|
||||||
|
try:
|
||||||
|
result = {"ok": True}
|
||||||
|
if action == "create":
|
||||||
|
from src.caldav_sync import push_event_create
|
||||||
|
result = await push_event_create(owner, uid)
|
||||||
|
elif action == "update":
|
||||||
|
from src.caldav_sync import push_event_update
|
||||||
|
result = await push_event_update(owner, uid)
|
||||||
|
elif action == "delete":
|
||||||
|
from src.caldav_sync import push_event_delete
|
||||||
|
result = await push_event_delete(owner, uid)
|
||||||
|
if result and not result.get("ok") and not result.get("skipped"):
|
||||||
|
raise RuntimeError(result.get("error") or result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("CalDAV %s push failed for uid=%s: %s", action, uid, e)
|
||||||
|
if action in {"create", "update"}:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
ev = _get_or_404_event(db, uid, owner)
|
||||||
|
ev.caldav_sync_pending = action
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _record_caldav_delete_tombstone(db, ev: CalendarEvent, owner: str) -> None:
|
||||||
|
if not (ev.calendar and ev.calendar.source == "caldav"):
|
||||||
|
return
|
||||||
|
tombstone = db.query(CalendarDeletedEvent).filter(
|
||||||
|
CalendarDeletedEvent.uid == ev.uid,
|
||||||
|
CalendarDeletedEvent.owner == owner,
|
||||||
|
).first()
|
||||||
|
if not tombstone:
|
||||||
|
tombstone = CalendarDeletedEvent(uid=ev.uid, owner=owner)
|
||||||
|
db.add(tombstone)
|
||||||
|
tombstone.calendar_id = ev.calendar_id
|
||||||
|
tombstone.remote_href = ev.remote_href
|
||||||
|
tombstone.remote_etag = ev.remote_etag
|
||||||
|
tombstone.caldav_base_url = getattr(ev.calendar, "caldav_base_url", None)
|
||||||
|
tombstone.summary = ev.summary or ""
|
||||||
|
tombstone.last_error = None
|
||||||
|
|
||||||
# ── Pydantic models ──
|
# ── Pydantic models ──
|
||||||
|
|
||||||
class EventCreate(BaseModel):
|
class EventCreate(BaseModel):
|
||||||
@@ -843,13 +891,13 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
return {"ok": False, "error": str(e)[:200]}
|
return {"ok": False, "error": str(e)[:200]}
|
||||||
|
|
||||||
@router.post("/sync")
|
@router.post("/sync")
|
||||||
async def sync_caldav_endpoint(request: Request):
|
async def sync_caldav_endpoint(request: Request, direction: str = "pull"):
|
||||||
"""Pull events from the configured CalDAV server into local DB.
|
"""Sync events with the configured CalDAV server.
|
||||||
Returns counts + any per-calendar errors. Called by the frontend
|
Returns counts + any per-calendar errors. Called by the frontend
|
||||||
on calendar open and by the periodic scheduler loop."""
|
on calendar open and by the periodic scheduler loop."""
|
||||||
owner = _require_user(request)
|
owner = _require_user(request)
|
||||||
from src.caldav_sync import sync_caldav
|
from src.caldav_sync import sync_caldav_direction
|
||||||
return await sync_caldav(owner)
|
return await sync_caldav_direction(owner, direction)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/calendars/{cal_id}")
|
@router.delete("/calendars/{cal_id}")
|
||||||
@@ -1002,19 +1050,12 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
is_utc=_is_utc and not data.all_day,
|
is_utc=_is_utc and not data.all_day,
|
||||||
rrule=data.rrule or "",
|
rrule=data.rrule or "",
|
||||||
color=data.color or None,
|
color=data.color or None,
|
||||||
|
caldav_sync_pending="create" if cal.source == "caldav" else None,
|
||||||
)
|
)
|
||||||
db.add(ev)
|
db.add(ev)
|
||||||
db.commit()
|
db.commit()
|
||||||
if cal.source == "caldav":
|
if cal.source == "caldav":
|
||||||
# Push the new event to the remote so it appears on the user's
|
await _push_caldav_event_after_commit(owner, uid, "create")
|
||||||
# other devices — the sync is otherwise pull-only (#800).
|
|
||||||
from src.caldav_writeback import writeback_event
|
|
||||||
await writeback_event(owner, cal.source, cal.id, {
|
|
||||||
"uid": uid, "summary": data.summary, "description": data.description,
|
|
||||||
"location": data.location, "dtstart": dtstart, "dtend": dtend,
|
|
||||||
"all_day": data.all_day, "is_utc": _is_utc and not data.all_day,
|
|
||||||
"rrule": data.rrule or "",
|
|
||||||
})
|
|
||||||
return {"ok": True, "uid": uid}
|
return {"ok": True, "uid": uid}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -1060,15 +1101,12 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
ev.rrule = data.rrule
|
ev.rrule = data.rrule
|
||||||
if data.color is not None:
|
if data.color is not None:
|
||||||
ev.color = data.color if data.color else None
|
ev.color = data.color if data.color else None
|
||||||
|
is_caldav = ev.calendar and ev.calendar.source == "caldav"
|
||||||
|
if is_caldav:
|
||||||
|
ev.caldav_sync_pending = "update"
|
||||||
db.commit()
|
db.commit()
|
||||||
cal = db.query(CalendarCal).filter(CalendarCal.id == ev.calendar_id).first()
|
if is_caldav:
|
||||||
if cal and cal.source == "caldav":
|
await _push_caldav_event_after_commit(owner, base_uid, "update")
|
||||||
from src.caldav_writeback import writeback_event
|
|
||||||
await writeback_event(owner, cal.source, cal.id, {
|
|
||||||
"uid": ev.uid, "summary": ev.summary, "description": ev.description,
|
|
||||||
"location": ev.location, "dtstart": ev.dtstart, "dtend": ev.dtend,
|
|
||||||
"all_day": ev.all_day, "is_utc": ev.is_utc, "rrule": ev.rrule or "",
|
|
||||||
})
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -1089,15 +1127,13 @@ def setup_calendar_routes() -> APIRouter:
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
ev = _get_or_404_event(db, base_uid, owner)
|
ev = _get_or_404_event(db, base_uid, owner)
|
||||||
# Capture what the remote push needs BEFORE the row is gone.
|
is_caldav = ev.calendar and ev.calendar.source == "caldav"
|
||||||
_cal = db.query(CalendarCal).filter(CalendarCal.id == ev.calendar_id).first()
|
if is_caldav:
|
||||||
_is_caldav = bool(_cal and _cal.source == "caldav")
|
_record_caldav_delete_tombstone(db, ev, owner)
|
||||||
_cal_id, _ev_uid = ev.calendar_id, ev.uid
|
|
||||||
db.delete(ev)
|
db.delete(ev)
|
||||||
db.commit()
|
db.commit()
|
||||||
if _is_caldav:
|
if is_caldav:
|
||||||
from src.caldav_writeback import writeback_event
|
await _push_caldav_event_after_commit(owner, base_uid, "delete")
|
||||||
await writeback_event(owner, "caldav", _cal_id, {"uid": _ev_uid}, delete=True)
|
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
+44
-5
@@ -159,9 +159,17 @@ async def auto_name_session(session_manager, sess):
|
|||||||
return
|
return
|
||||||
|
|
||||||
owner = getattr(sess, "owner", None)
|
owner = getattr(sess, "owner", None)
|
||||||
t_url, t_model, t_headers = resolve_task_endpoint(
|
t_url, t_model, t_headers = resolve_task_endpoint(owner=owner)
|
||||||
sess.endpoint_url, sess.model, sess.headers, owner=owner,
|
if not t_model:
|
||||||
)
|
# If no task/utility model is configured at all, fall back to
|
||||||
|
# the session's own model so auto-naming still works even on
|
||||||
|
# minimal setups.
|
||||||
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
|
_fallback = resolve_endpoint("default", owner=owner)
|
||||||
|
if _fallback and _fallback[1]:
|
||||||
|
t_url, t_model, t_headers = _fallback
|
||||||
|
else:
|
||||||
|
t_url, t_model, t_headers = sess.endpoint_url, sess.model, sess.headers
|
||||||
if not t_model:
|
if not t_model:
|
||||||
logger.debug("[auto-name] No model provided, skipping")
|
logger.debug("[auto-name] No model provided, skipping")
|
||||||
return
|
return
|
||||||
@@ -497,6 +505,29 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _session_is_research_spinoff(sess) -> bool:
|
||||||
|
"""True if this session was created via research "Discuss" spin-off.
|
||||||
|
|
||||||
|
Detected by the primer system message the spin-off endpoint seeds into
|
||||||
|
history (metadata ``research_spinoff_from``). Such sessions are grounded
|
||||||
|
on the seeded report, so global memory + personal-doc RAG injection is
|
||||||
|
suppressed for them (the report is the sole knowledge base). Handles both
|
||||||
|
ChatMessage objects and plain dicts.
|
||||||
|
"""
|
||||||
|
for m in getattr(sess, "history", []) or []:
|
||||||
|
role = getattr(m, "role", None)
|
||||||
|
if role is None and isinstance(m, dict):
|
||||||
|
role = m.get("role")
|
||||||
|
if role != "system":
|
||||||
|
continue
|
||||||
|
md = getattr(m, "metadata", None)
|
||||||
|
if md is None and isinstance(m, dict):
|
||||||
|
md = m.get("metadata")
|
||||||
|
if (md or {}).get("research_spinoff_from"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def build_chat_context(
|
async def build_chat_context(
|
||||||
sess,
|
sess,
|
||||||
request,
|
request,
|
||||||
@@ -562,9 +593,17 @@ async def build_chat_context(
|
|||||||
mem_enabled, user, incognito, no_memory, uprefs.get("memory_enabled", "NOT_SET"),
|
mem_enabled, user, incognito, no_memory, uprefs.get("memory_enabled", "NOT_SET"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Research-spinoff ("Discuss") sessions are grounded on the seeded report:
|
||||||
|
# the primer system message IS the knowledge base. Injecting global memory
|
||||||
|
# or personal-doc RAG on every turn pulls in keyword-matched but off-topic
|
||||||
|
# facts ("wrong data") and competes with the report, so suppress both here.
|
||||||
|
is_research_spinoff = _session_is_research_spinoff(sess)
|
||||||
|
if is_research_spinoff:
|
||||||
|
mem_enabled = False
|
||||||
|
|
||||||
# Use RAG?
|
# Use RAG?
|
||||||
use_rag_val = (str(use_rag).lower() != "false") if use_rag is not None else True
|
use_rag_val = (str(use_rag).lower() != "false") if use_rag is not None else True
|
||||||
if incognito or not allow_tool_preprocessing:
|
if incognito or not allow_tool_preprocessing or is_research_spinoff:
|
||||||
use_rag_val = False
|
use_rag_val = False
|
||||||
|
|
||||||
# If pre-fetched search context was provided (compare mode), skip live web search
|
# If pre-fetched search context was provided (compare mode), skip live web search
|
||||||
@@ -587,7 +626,7 @@ async def build_chat_context(
|
|||||||
incognito=incognito,
|
incognito=incognito,
|
||||||
use_skills=skills_enabled,
|
use_skills=skills_enabled,
|
||||||
)
|
)
|
||||||
if use_rag is not None:
|
if use_rag is not None or is_research_spinoff:
|
||||||
_preface_kwargs["use_rag"] = use_rag_val
|
_preface_kwargs["use_rag"] = use_rag_val
|
||||||
preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs)
|
preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -696,7 +696,12 @@ def setup_chat_routes(
|
|||||||
# by default without having to send allow_bash in every request.
|
# by default without having to send allow_bash in every request.
|
||||||
if allow_bash is not None and str(allow_bash).lower() != "true":
|
if allow_bash is not None and str(allow_bash).lower() != "true":
|
||||||
disabled_tools.add("bash")
|
disabled_tools.add("bash")
|
||||||
if allow_web_search is not None and str(allow_web_search).lower() != "true":
|
_explicit_web_intent = bool(_tool_intent and _tool_intent.category == "web")
|
||||||
|
if (
|
||||||
|
allow_web_search is not None
|
||||||
|
and str(allow_web_search).lower() != "true"
|
||||||
|
and not _explicit_web_intent
|
||||||
|
):
|
||||||
disabled_tools.add("web_search")
|
disabled_tools.add("web_search")
|
||||||
disabled_tools.add("web_fetch")
|
disabled_tools.add("web_fetch")
|
||||||
|
|
||||||
|
|||||||
+18
-6
@@ -18,6 +18,7 @@ from fastapi.responses import StreamingResponse
|
|||||||
from src.auth_helpers import require_authenticated_request, require_user
|
from src.auth_helpers import require_authenticated_request, require_user
|
||||||
from src.tool_implementations import do_manage_notes
|
from src.tool_implementations import do_manage_notes
|
||||||
from src.constants import COOKBOOK_STATE_FILE
|
from src.constants import COOKBOOK_STATE_FILE
|
||||||
|
from routes._validators import validate_remote_host, validate_ssh_port
|
||||||
|
|
||||||
|
|
||||||
COOKBOOK_READ_SCOPES = {"cookbook:read", "cookbook:launch"}
|
COOKBOOK_READ_SCOPES = {"cookbook:read", "cookbook:launch"}
|
||||||
@@ -36,6 +37,21 @@ DOCS_WRITE_SCOPES = {"documents:write"}
|
|||||||
WRITE_ACTIONS = {"add", "create", "new", "save", "remind", "update", "delete", "toggle_item", "remove", "remove_item"}
|
WRITE_ACTIONS = {"add", "create", "new", "save", "remind", "update", "delete", "toggle_item", "remove", "remove_item"}
|
||||||
|
|
||||||
|
|
||||||
|
def _ssh_prefix_for_task(task: dict) -> tuple[str, str]:
|
||||||
|
"""Resolve a cookbook task's stored SSH target into ``(host, port_flag)``.
|
||||||
|
|
||||||
|
``host`` is ``""`` for a local task. ``remoteHost`` / ``sshPort`` come from
|
||||||
|
cookbook_state.json and get interpolated into an ``ssh`` command string, so
|
||||||
|
validate them the same way the cookbook routes do. A tampered entry with
|
||||||
|
shell metacharacters in ``remoteHost`` is rejected with 400 rather than
|
||||||
|
injected.
|
||||||
|
"""
|
||||||
|
host = validate_remote_host((task.get("remoteHost") or "").strip() or None) or ""
|
||||||
|
ssh_port = validate_ssh_port((task.get("sshPort") or "").strip() or None) or ""
|
||||||
|
port_flag = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else ""
|
||||||
|
return host, port_flag
|
||||||
|
|
||||||
|
|
||||||
async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
|
async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
|
||||||
"""Run an existing route handler with request.state.current_user temporarily
|
"""Run an existing route handler with request.state.current_user temporarily
|
||||||
set to ``owner`` so its internal get_current_user/require_user calls see
|
set to ``owner`` so its internal get_current_user/require_user calls see
|
||||||
@@ -486,8 +502,7 @@ def setup_codex_routes(
|
|||||||
task = next((t for t in tasks if t.get("sessionId") == session_id), None)
|
task = next((t for t in tasks if t.get("sessionId") == session_id), None)
|
||||||
if task is None:
|
if task is None:
|
||||||
raise HTTPException(404, "task not found")
|
raise HTTPException(404, "task not found")
|
||||||
host = (task.get("remoteHost") or "").strip()
|
host, port_flag = _ssh_prefix_for_task(task)
|
||||||
ssh_port = (task.get("sshPort") or "").strip()
|
|
||||||
# Prefer the persisted log file over the tmux pane. The pane gets
|
# Prefer the persisted log file over the tmux pane. The pane gets
|
||||||
# overwritten by the post-crash neofetch banner + bash prompt the
|
# overwritten by the post-crash neofetch banner + bash prompt the
|
||||||
# moment vllm exits; the log file is the raw stdout/stderr and
|
# moment vllm exits; the log file is the raw stdout/stderr and
|
||||||
@@ -499,7 +514,6 @@ def setup_codex_routes(
|
|||||||
f"else tmux capture-pane -t {session_id} -p -S -{tail}; fi"
|
f"else tmux capture-pane -t {session_id} -p -S -{tail}; fi"
|
||||||
)
|
)
|
||||||
if host:
|
if host:
|
||||||
port_flag = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else ""
|
|
||||||
import shlex
|
import shlex
|
||||||
cmd = f"ssh {port_flag}{host} {shlex.quote(inner)}"
|
cmd = f"ssh {port_flag}{host} {shlex.quote(inner)}"
|
||||||
else:
|
else:
|
||||||
@@ -561,10 +575,8 @@ def setup_codex_routes(
|
|||||||
state = _read_cookbook_state()
|
state = _read_cookbook_state()
|
||||||
tasks = state.get("tasks") or []
|
tasks = state.get("tasks") or []
|
||||||
task = next((t for t in tasks if t.get("sessionId") == session_id), None)
|
task = next((t for t in tasks if t.get("sessionId") == session_id), None)
|
||||||
host = ((task or {}).get("remoteHost") or "").strip()
|
host, port_flag = _ssh_prefix_for_task(task or {})
|
||||||
ssh_port = ((task or {}).get("sshPort") or "").strip()
|
|
||||||
if host:
|
if host:
|
||||||
port_flag = f"-p {ssh_port} " if ssh_port and ssh_port != "22" else ""
|
|
||||||
cmd = f"ssh {port_flag}{host} \"tmux kill-session -t {session_id}\""
|
cmd = f"ssh {port_flag}{host} \"tmux kill-session -t {session_id}\""
|
||||||
else:
|
else:
|
||||||
cmd = f"tmux kill-session -t {session_id}"
|
cmd = f"tmux kill-session -t {session_id}"
|
||||||
|
|||||||
@@ -45,10 +45,14 @@ def _save_settings(settings):
|
|||||||
def _get_carddav_config():
|
def _get_carddav_config():
|
||||||
import os
|
import os
|
||||||
settings = _load_settings()
|
settings = _load_settings()
|
||||||
|
password = settings.get("carddav_password", os.environ.get("CARDDAV_PASSWORD", ""))
|
||||||
|
if password and "carddav_password" in settings:
|
||||||
|
from src.secret_storage import decrypt
|
||||||
|
password = decrypt(password)
|
||||||
return {
|
return {
|
||||||
"url": settings.get("carddav_url", os.environ.get("CARDDAV_URL", "")),
|
"url": settings.get("carddav_url", os.environ.get("CARDDAV_URL", "")),
|
||||||
"username": settings.get("carddav_username", os.environ.get("CARDDAV_USERNAME", "")),
|
"username": settings.get("carddav_username", os.environ.get("CARDDAV_USERNAME", "")),
|
||||||
"password": settings.get("carddav_password", os.environ.get("CARDDAV_PASSWORD", "")),
|
"password": password,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -785,7 +789,11 @@ def setup_contacts_routes():
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(400, str(e))
|
raise HTTPException(400, str(e))
|
||||||
else:
|
else:
|
||||||
settings[key] = data[key]
|
value = data[key]
|
||||||
|
if key == "carddav_password" and value:
|
||||||
|
from src.secret_storage import encrypt
|
||||||
|
value = encrypt(value)
|
||||||
|
settings[key] = value
|
||||||
_save_settings(settings)
|
_save_settings(settings)
|
||||||
# Force re-fetch
|
# Force re-fetch
|
||||||
_contact_cache["fetched_at"] = None
|
_contact_cache["fetched_at"] = None
|
||||||
|
|||||||
@@ -362,7 +362,12 @@ def _user_shell_path_bootstrap() -> list[str]:
|
|||||||
' ODYSSEUS_USER_PATH="$("$ODYSSEUS_USER_SHELL" -ic \'printf "__ODYSSEUS_PATH__%s\\n" "$PATH"\' 2>/dev/null | sed -n \'s/^__ODYSSEUS_PATH__//p\' | tail -n 1 || true)"',
|
' ODYSSEUS_USER_PATH="$("$ODYSSEUS_USER_SHELL" -ic \'printf "__ODYSSEUS_PATH__%s\\n" "$PATH"\' 2>/dev/null | sed -n \'s/^__ODYSSEUS_PATH__//p\' | tail -n 1 || true)"',
|
||||||
' if [ -n "$ODYSSEUS_USER_PATH" ]; then export PATH="$ODYSSEUS_USER_PATH:$PATH"; fi',
|
' if [ -n "$ODYSSEUS_USER_PATH" ]; then export PATH="$ODYSSEUS_USER_PATH:$PATH"; fi',
|
||||||
'fi',
|
'fi',
|
||||||
'command -v python3 >/dev/null 2>&1 || python3() { python "$@"; }',
|
# Windows can expose python3 as a Microsoft Store App Execution Alias
|
||||||
|
# under WindowsApps. Git Bash sees that stub as present, but it exits
|
||||||
|
# before running Python. A Windows venv usually has python.exe, not
|
||||||
|
# python3.exe, so treat a missing or WindowsApps python3 as absent.
|
||||||
|
'_odys_py3="$(command -v python3 2>/dev/null || true)"',
|
||||||
|
'case "$_odys_py3" in ""|*[Ww]indows[Aa]pps*) python3() { python "$@"; } ;; esac',
|
||||||
'command -v python >/dev/null 2>&1 || python() { python3 "$@"; }',
|
'command -v python >/dev/null 2>&1 || python() { python3 "$@"; }',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -573,6 +578,36 @@ _GGUF_PRELUDE_RE = re.compile(
|
|||||||
_OLLAMA_HOST_ASSIGNMENT_RE = re.compile(r"(?:^|\s)OLLAMA_HOST=([^\s]+)")
|
_OLLAMA_HOST_ASSIGNMENT_RE = re.compile(r"(?:^|\s)OLLAMA_HOST=([^\s]+)")
|
||||||
_OLLAMA_BIND_RE = re.compile(r"^\[([^\]]+)\]:(\d+)$|^([^:]+):(\d+)$")
|
_OLLAMA_BIND_RE = re.compile(r"^\[([^\]]+)\]:(\d+)$|^([^:]+):(\d+)$")
|
||||||
_OLLAMA_BIND_HOST_RE = re.compile(r"^[A-Za-z0-9._:-]+$")
|
_OLLAMA_BIND_HOST_RE = re.compile(r"^[A-Za-z0-9._:-]+$")
|
||||||
|
_LLAMA_CPP_PYTHON_GGML_TYPES = {
|
||||||
|
"f32": "0",
|
||||||
|
"f16": "1",
|
||||||
|
"q4_0": "2",
|
||||||
|
"q4_1": "3",
|
||||||
|
"q5_0": "6",
|
||||||
|
"q5_1": "7",
|
||||||
|
"q8_0": "8",
|
||||||
|
"q8_1": "9",
|
||||||
|
"q2_k": "10",
|
||||||
|
"q3_k": "11",
|
||||||
|
"q4_k": "12",
|
||||||
|
"q5_k": "13",
|
||||||
|
"q6_k": "14",
|
||||||
|
"q8_k": "15",
|
||||||
|
"iq2_xxs": "16",
|
||||||
|
"iq2_xs": "17",
|
||||||
|
"iq3_xxs": "18",
|
||||||
|
"iq1_s": "19",
|
||||||
|
"iq4_nl": "20",
|
||||||
|
"iq3_s": "21",
|
||||||
|
"iq2_s": "22",
|
||||||
|
"iq4_xs": "23",
|
||||||
|
"mxfp4": "39",
|
||||||
|
"nvfp4": "40",
|
||||||
|
"q1_0": "41",
|
||||||
|
}
|
||||||
|
_LLAMA_CPP_PYTHON_TYPE_FLAG_RE = re.compile(
|
||||||
|
r"(?P<flag>--type_[kv])(?P<sep>\s+|=)(?P<quote>['\"]?)(?P<value>[A-Za-z0-9_]+)(?P=quote)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _ollama_bind_from_cmd(cmd: str | None, *, default_host: str = "127.0.0.1") -> tuple[str, str]:
|
def _ollama_bind_from_cmd(cmd: str | None, *, default_host: str = "127.0.0.1") -> tuple[str, str]:
|
||||||
@@ -604,6 +639,22 @@ def _ollama_bind_from_cmd(cmd: str | None, *, default_host: str = "127.0.0.1") -
|
|||||||
return f"[{host}]" if bracketed_host else host, port
|
return f"[{host}]" if bracketed_host else host, port
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_llama_cpp_python_cache_types(cmd: str | None) -> str | None:
|
||||||
|
"""Map llama.cpp KV cache type names to llama-cpp-python's integer enum."""
|
||||||
|
if not cmd or "llama_cpp.server" not in cmd:
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def repl(match: re.Match[str]) -> str:
|
||||||
|
value = match.group("value")
|
||||||
|
mapped = _LLAMA_CPP_PYTHON_GGML_TYPES.get(value.lower())
|
||||||
|
if not mapped:
|
||||||
|
return match.group(0)
|
||||||
|
quote = match.group("quote")
|
||||||
|
return f"{match.group('flag')}{match.group('sep')}{quote}{mapped}{quote}"
|
||||||
|
|
||||||
|
return _LLAMA_CPP_PYTHON_TYPE_FLAG_RE.sub(repl, cmd)
|
||||||
|
|
||||||
|
|
||||||
def _check_serve_binary(seg: str) -> None:
|
def _check_serve_binary(seg: str) -> None:
|
||||||
"""Validate that a single command segment starts with an allowlisted binary
|
"""Validate that a single command segment starts with an allowlisted binary
|
||||||
(after skipping leading env-var assignments like `CUDA_VISIBLE_DEVICES=0`)."""
|
(after skipping leading env-var assignments like `CUDA_VISIBLE_DEVICES=0`)."""
|
||||||
@@ -742,6 +793,7 @@ def _append_llama_cpp_linux_accel_build_lines(runner_lines: list[str]) -> None:
|
|||||||
runner_lines.append(' done')
|
runner_lines.append(' done')
|
||||||
# rm -rf build so a prior poisoned CMakeCache.txt (e.g. from a failed CUDA
|
# rm -rf build so a prior poisoned CMakeCache.txt (e.g. from a failed CUDA
|
||||||
# or HIP attempt) doesn't cause the next configure to reuse stale settings.
|
# or HIP attempt) doesn't cause the next configure to reuse stale settings.
|
||||||
|
runner_lines.append(' mkdir -p ~/bin')
|
||||||
runner_lines.append(' cd ~/llama.cpp && rm -rf build')
|
runner_lines.append(' cd ~/llama.cpp && rm -rf build')
|
||||||
runner_lines.append(' if command -v hipconfig &>/dev/null || [ -d /opt/rocm ] || [ -n "$ROCM_PATH" ] || [ -n "$HIP_PATH" ]; then')
|
runner_lines.append(' if command -v hipconfig &>/dev/null || [ -d /opt/rocm ] || [ -n "$ROCM_PATH" ] || [ -n "$HIP_PATH" ]; then')
|
||||||
runner_lines.append(' if command -v hipconfig &>/dev/null; then')
|
runner_lines.append(' if command -v hipconfig &>/dev/null; then')
|
||||||
@@ -1046,6 +1098,16 @@ def _diagnose_serve_output(text: str) -> dict | None:
|
|||||||
"vLLM is not installed or not in PATH on this server.",
|
"vLLM is not installed or not in PATH on this server.",
|
||||||
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
|
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops)|"
|
||||||
|
r"(Python\.h|libnuma\.so\.1|common_ops)[\s\S]*sgl_kernel|"
|
||||||
|
r"Please ensure sgl_kernel is properly installed",
|
||||||
|
"SGLang native dependencies are missing on this server.",
|
||||||
|
[
|
||||||
|
{"label": "install OS packages: libnuma-dev python3.12-dev build-essential", "op": "manual"},
|
||||||
|
{"label": "upgrade sglang-kernel after OS packages are installed", "op": "manual"},
|
||||||
|
],
|
||||||
|
),
|
||||||
(
|
(
|
||||||
r"sglang.*command not found|No module named sglang|SGLang is not installed",
|
r"sglang.*command not found|No module named sglang|SGLang is not installed",
|
||||||
"SGLang is not installed or not in PATH on this server.",
|
"SGLang is not installed or not in PATH on this server.",
|
||||||
|
|||||||
@@ -4,6 +4,62 @@ Kept dependency-free (no FastAPI / SQLAlchemy imports) so the behavior can be
|
|||||||
unit-tested without standing up the whole app.
|
unit-tested without standing up the whole app.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
|
||||||
|
|
||||||
|
# Probe scripts for the dead-session download check, run as
|
||||||
|
# `python3 -c <PROBE> <repo_id> <cache_root>` (locally or over SSH).
|
||||||
|
# cache_root is the task's custom download dir, '' for the default HF cache.
|
||||||
|
# It has to be passed explicitly: the download runner exports
|
||||||
|
# HF_HOME=<local_dir>, so that task's cache lives under <local_dir>/hub, and
|
||||||
|
# the probe process's own environment knows nothing about it.
|
||||||
|
HF_CACHE_COMPLETE_PROBE = (
|
||||||
|
"import os,sys;"
|
||||||
|
"repo=sys.argv[1];"
|
||||||
|
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
||||||
|
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
|
||||||
|
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
|
||||||
|
"snap=os.path.join(d,'snapshots');"
|
||||||
|
"ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));"
|
||||||
|
"inc=False;"
|
||||||
|
"blobs=os.path.join(d,'blobs');"
|
||||||
|
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
|
||||||
|
"sys.exit(0 if ok and not inc else 1)"
|
||||||
|
)
|
||||||
|
|
||||||
|
HF_CACHE_INCOMPLETE_PROBE = (
|
||||||
|
"import os,sys;"
|
||||||
|
"repo=sys.argv[1];"
|
||||||
|
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
|
||||||
|
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
|
||||||
|
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
|
||||||
|
"blobs=os.path.join(d,'blobs');"
|
||||||
|
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
|
||||||
|
"sys.exit(0 if inc else 1)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_dead_download(full_snapshot: str):
|
||||||
|
"""Resolve a dead download session's status from its runner markers.
|
||||||
|
|
||||||
|
The runner prints DOWNLOAD_OK only after exiting 0 (and DOWNLOAD_FAILED
|
||||||
|
otherwise), so the markers stay trustworthy after the tmux pane is gone.
|
||||||
|
Returns (status, zero_files), or None when the snapshot carries no marker
|
||||||
|
and the caller has to fall back to the cache probe. Same precedence as
|
||||||
|
the live-session branch: DOWNLOAD_OK wins, except a "Fetching 0 files"
|
||||||
|
run is an error (nothing matched the include/quant pattern).
|
||||||
|
"""
|
||||||
|
if not full_snapshot:
|
||||||
|
return None
|
||||||
|
if "DOWNLOAD_OK" in full_snapshot:
|
||||||
|
if _FETCHING_ZERO_FILES_RE.search(full_snapshot):
|
||||||
|
return ("error", True)
|
||||||
|
return ("completed", False)
|
||||||
|
if "DOWNLOAD_FAILED" in full_snapshot:
|
||||||
|
return ("error", False)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def error_aware_output_tail(full_snapshot: str, status: str) -> str:
|
def error_aware_output_tail(full_snapshot: str, status: str) -> str:
|
||||||
"""Return the trailing slice of a task log for the status response.
|
"""Return the trailing slice of a task log for the status response.
|
||||||
|
|||||||
+41
-33
@@ -30,7 +30,10 @@ from core.platform_compat import (
|
|||||||
which_tool,
|
which_tool,
|
||||||
)
|
)
|
||||||
from routes.shell_routes import TMUX_LOG_DIR
|
from routes.shell_routes import TMUX_LOG_DIR
|
||||||
from routes.cookbook_output import error_aware_output_tail
|
from routes.cookbook_output import (
|
||||||
|
error_aware_output_tail, classify_dead_download,
|
||||||
|
HF_CACHE_COMPLETE_PROBE, HF_CACHE_INCOMPLETE_PROBE,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -46,6 +49,7 @@ from routes.cookbook_helpers import (
|
|||||||
_diagnose_serve_output, run_ssh_command_async,
|
_diagnose_serve_output, run_ssh_command_async,
|
||||||
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
|
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
|
||||||
_user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
_user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
||||||
|
_normalize_llama_cpp_python_cache_types,
|
||||||
ModelDownloadRequest, ServeRequest,
|
ModelDownloadRequest, ServeRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,7 +58,7 @@ _HF_TOKEN_STATUS_SNIPPET = (
|
|||||||
'echo "[odysseus] HF token: applied"; '
|
'echo "[odysseus] HF token: applied"; '
|
||||||
'else '
|
'else '
|
||||||
'echo "[odysseus] HF token: NOT SET — gated/private models will be denied. '
|
'echo "[odysseus] HF token: NOT SET — gated/private models will be denied. '
|
||||||
'Add one in Odysseus Settings -> Cookbook -> HuggingFace Token."; '
|
'Add one in Odysseus Cookbook -> Settings -> HuggingFace Token."; '
|
||||||
'fi'
|
'fi'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -170,6 +174,16 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
"vLLM is not installed or not in PATH on this server.",
|
"vLLM is not installed or not in PATH on this server.",
|
||||||
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
|
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops)|"
|
||||||
|
r"(Python\.h|libnuma\.so\.1|common_ops)[\s\S]*sgl_kernel|"
|
||||||
|
r"Please ensure sgl_kernel is properly installed",
|
||||||
|
"SGLang native dependencies are missing on this server.",
|
||||||
|
[
|
||||||
|
{"label": "install OS packages: libnuma-dev python3.12-dev build-essential", "op": "manual"},
|
||||||
|
{"label": "upgrade sglang-kernel after OS packages are installed", "op": "manual"},
|
||||||
|
],
|
||||||
|
),
|
||||||
(
|
(
|
||||||
r"sglang.*command not found|No module named sglang|SGLang is not installed",
|
r"sglang.*command not found|No module named sglang|SGLang is not installed",
|
||||||
"SGLang is not installed or not in PATH on this server.",
|
"SGLang is not installed or not in PATH on this server.",
|
||||||
@@ -353,7 +367,11 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
# all output to the log the poller reads. Paths handed to bash use
|
# all output to the log the poller reads. Paths handed to bash use
|
||||||
# POSIX form + shell-quoting so drive paths / spaces survive.
|
# POSIX form + shell-quoting so drive paths / spaces survive.
|
||||||
inner = TMUX_LOG_DIR / f"{session_id}_run.sh"
|
inner = TMUX_LOG_DIR / f"{session_id}_run.sh"
|
||||||
inner.write_text("\n".join(bash_lines) + "\n", encoding="utf-8")
|
pp = shlex.quote(pid_path.as_posix())
|
||||||
|
inner.write_text(
|
||||||
|
f"printf '%s\\n' \"$$\" > {pp}\n" + "\n".join(bash_lines) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
lp = shlex.quote(log_path.as_posix())
|
lp = shlex.quote(log_path.as_posix())
|
||||||
ip = shlex.quote(inner.as_posix())
|
ip = shlex.quote(inner.as_posix())
|
||||||
script_path = TMUX_LOG_DIR / f"{session_id}.sh"
|
script_path = TMUX_LOG_DIR / f"{session_id}.sh"
|
||||||
@@ -1211,6 +1229,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
# many downstream `"engine" in req.cmd` membership checks can't hit
|
# many downstream `"engine" in req.cmd` membership checks can't hit
|
||||||
# `TypeError: argument of type 'NoneType'` (a 500 instead of a clean 400).
|
# `TypeError: argument of type 'NoneType'` (a 500 instead of a clean 400).
|
||||||
req.cmd = _validate_serve_cmd(req.cmd) or ""
|
req.cmd = _validate_serve_cmd(req.cmd) or ""
|
||||||
|
req.cmd = _normalize_llama_cpp_python_cache_types(req.cmd) or ""
|
||||||
req.cmd = _venv_safe_local_pip_install_cmd(
|
req.cmd = _venv_safe_local_pip_install_cmd(
|
||||||
req.cmd,
|
req.cmd,
|
||||||
local=not bool(req.remote_host),
|
local=not bool(req.remote_host),
|
||||||
@@ -2620,30 +2639,20 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
def _cookbook_tasks_status_sync():
|
def _cookbook_tasks_status_sync():
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
def _download_cache_complete(repo_id: str, remote_host: str = "", ssh_port: str = "") -> bool:
|
def _download_cache_complete(repo_id: str, remote_host: str = "", ssh_port: str = "", cache_root: str = "") -> bool:
|
||||||
"""Best-effort check for a completed HF cache entry.
|
"""Best-effort check for a completed HF cache entry.
|
||||||
|
|
||||||
tmux output can stop at a stale progress line if the pane/session
|
tmux output can stop at a stale progress line if the pane/session
|
||||||
disappears before Cookbook captures the final DOWNLOAD_OK marker.
|
disappears before Cookbook captures the final DOWNLOAD_OK marker.
|
||||||
In that case, trust the cache shape: a snapshot directory with files
|
In that case, trust the cache shape: a snapshot directory with files
|
||||||
and no *.incomplete blobs means HuggingFace finished materializing the
|
and no *.incomplete blobs means HuggingFace finished materializing the
|
||||||
model.
|
model. cache_root is the task's custom download dir — the runner
|
||||||
|
pointed HF_HOME there, so the cache lives under <cache_root>/hub,
|
||||||
|
not wherever this probe's environment says.
|
||||||
"""
|
"""
|
||||||
if not repo_id or "/" not in repo_id:
|
if not repo_id or "/" not in repo_id:
|
||||||
return False
|
return False
|
||||||
py = (
|
cmd = ["python3", "-c", HF_CACHE_COMPLETE_PROBE, repo_id, cache_root or ""]
|
||||||
"import os,sys;"
|
|
||||||
"repo=sys.argv[1];"
|
|
||||||
"base=os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub');"
|
|
||||||
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
|
|
||||||
"snap=os.path.join(d,'snapshots');"
|
|
||||||
"ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));"
|
|
||||||
"inc=False;"
|
|
||||||
"blobs=os.path.join(d,'blobs');"
|
|
||||||
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
|
|
||||||
"sys.exit(0 if ok and not inc else 1)"
|
|
||||||
)
|
|
||||||
cmd = ["python3", "-c", py, repo_id]
|
|
||||||
try:
|
try:
|
||||||
if remote_host:
|
if remote_host:
|
||||||
ssh_base = ["ssh"]
|
ssh_base = ["ssh"]
|
||||||
@@ -2657,7 +2666,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _download_cache_incomplete(repo_id: str, remote_host: str = "", ssh_port: str = "") -> bool:
|
def _download_cache_incomplete(repo_id: str, remote_host: str = "", ssh_port: str = "", cache_root: str = "") -> bool:
|
||||||
"""Best-effort check for resumable HF partial blobs.
|
"""Best-effort check for resumable HF partial blobs.
|
||||||
|
|
||||||
A lost SSH/tmux session can leave a real download still incomplete.
|
A lost SSH/tmux session can leave a real download still incomplete.
|
||||||
@@ -2666,16 +2675,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
"""
|
"""
|
||||||
if not repo_id or "/" not in repo_id:
|
if not repo_id or "/" not in repo_id:
|
||||||
return False
|
return False
|
||||||
py = (
|
cmd = ["python3", "-c", HF_CACHE_INCOMPLETE_PROBE, repo_id, cache_root or ""]
|
||||||
"import os,sys;"
|
|
||||||
"repo=sys.argv[1];"
|
|
||||||
"base=os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub');"
|
|
||||||
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
|
|
||||||
"blobs=os.path.join(d,'blobs');"
|
|
||||||
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
|
|
||||||
"sys.exit(0 if inc else 1)"
|
|
||||||
)
|
|
||||||
cmd = ["python3", "-c", py, repo_id]
|
|
||||||
try:
|
try:
|
||||||
if remote_host:
|
if remote_host:
|
||||||
ssh_base = ["ssh"]
|
ssh_base = ["ssh"]
|
||||||
@@ -2880,7 +2880,7 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
and (
|
and (
|
||||||
".incomplete" in full_snapshot
|
".incomplete" in full_snapshot
|
||||||
or bool(re.search(r'model-\d+-of-\d+\.[A-Za-z0-9_.-]+:\s+(?:[0-9]|[1-8][0-9])%', full_snapshot))
|
or bool(re.search(r'model-\d+-of-\d+\.[A-Za-z0-9_.-]+:\s+(?:[0-9]|[1-8][0-9])%', full_snapshot))
|
||||||
or _download_cache_incomplete(_payload.get("repo_id") or model, remote, str(_tport or ""))
|
or _download_cache_incomplete(_payload.get("repo_id") or model, remote, str(_tport or ""), _payload.get("local_dir") or "")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if is_alive or (local_win_task and full_snapshot):
|
if is_alive or (local_win_task and full_snapshot):
|
||||||
@@ -2921,11 +2921,19 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
else:
|
else:
|
||||||
status = "running"
|
status = "running"
|
||||||
else:
|
else:
|
||||||
# Session is dead — check if it completed or crashed
|
# Session is dead — check if it completed or crashed. The
|
||||||
if (
|
# runner markers in the retained output are conclusive
|
||||||
|
# (DOWNLOAD_OK only prints after exit 0), so check them before
|
||||||
|
# the cache probe, which can't see ollama pulls at all.
|
||||||
|
marker = classify_dead_download(full_snapshot) if task_type == "download" else None
|
||||||
|
if marker is not None:
|
||||||
|
status, download_zero_files = marker
|
||||||
|
if status == "completed" and not progress_text:
|
||||||
|
progress_text = "Download complete"
|
||||||
|
elif (
|
||||||
task_type == "download"
|
task_type == "download"
|
||||||
and not download_has_incomplete_evidence
|
and not download_has_incomplete_evidence
|
||||||
and _download_cache_complete(_payload.get("repo_id") or model, remote, str(_tport or ""))
|
and _download_cache_complete(_payload.get("repo_id") or model, remote, str(_tport or ""), _payload.get("local_dir") or "")
|
||||||
):
|
):
|
||||||
status = "completed"
|
status = "completed"
|
||||||
if not progress_text:
|
if not progress_text:
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"""Diagnostics routes — /api/db/stats, /api/rag/stats, /api/test/youtube, /api/test-research."""
|
"""Diagnostics routes — /api/db/stats, /api/rag/stats, /api/test/youtube, /api/test-research."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Form, Request
|
from fastapi import APIRouter, HTTPException, Form, Request
|
||||||
|
|
||||||
from services.youtube.youtube_handler import extract_youtube_id, extract_transcript_async
|
from services.youtube.youtube_handler import extract_youtube_id, extract_transcript_async
|
||||||
from core.constants import DEFAULT_HOST
|
from core.constants import DEFAULT_HOST, DATA_DIR
|
||||||
from core.middleware import require_admin
|
from core.middleware import require_admin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -28,6 +29,30 @@ def setup_diagnostics_routes(
|
|||||||
from src.service_health import collect_service_health
|
from src.service_health import collect_service_health
|
||||||
return await collect_service_health(rag_manager, memory_vector)
|
return await collect_service_health(rag_manager, memory_vector)
|
||||||
|
|
||||||
|
@router.get("/api/diagnostics/logs")
|
||||||
|
async def get_diagnostics_logs(request: Request, limit: int = 200) -> Dict[str, Any]:
|
||||||
|
require_admin(request)
|
||||||
|
limit = max(1, min(limit, 1000))
|
||||||
|
try:
|
||||||
|
log_file = os.path.join(DATA_DIR, "logs", "app.log")
|
||||||
|
if not os.path.exists(log_file):
|
||||||
|
return {"status": "success", "logs": []}
|
||||||
|
|
||||||
|
# Safe tail read of the log file (max 5MB via rotation)
|
||||||
|
with open(log_file, "r", encoding="utf-8", errors="ignore") as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
tail_lines = lines[-limit:] if len(lines) > limit else lines
|
||||||
|
tail_lines = [line.rstrip('\r\n') for line in tail_lines]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"logs": tail_lines
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Diagnostics logs retrieval error: {e}")
|
||||||
|
raise HTTPException(500, f"Failed to retrieve logs: {str(e)}")
|
||||||
|
|
||||||
@router.get("/api/db/stats")
|
@router.get("/api/db/stats")
|
||||||
async def get_database_stats(request: Request) -> Dict[str, Any]:
|
async def get_database_stats(request: Request) -> Dict[str, Any]:
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
|
|||||||
@@ -1087,7 +1087,10 @@ def setup_email_routes():
|
|||||||
return {"contacts": [], "error": "Mail operation failed"}
|
return {"contacts": [], "error": "Mail operation failed"}
|
||||||
|
|
||||||
@router.get("/search")
|
@router.get("/search")
|
||||||
async def search_emails(
|
# Sync def: the body is blocking IMAP I/O with no awaits. As `async def` it ran
|
||||||
|
# directly on the event loop and stalled the whole app during a search; as a sync
|
||||||
|
# def FastAPI runs it in a threadpool, keeping the loop responsive.
|
||||||
|
def search_emails(
|
||||||
q: str = Query(""),
|
q: str = Query(""),
|
||||||
folder: str = Query("INBOX"),
|
folder: str = Query("INBOX"),
|
||||||
limit: int = Query(50),
|
limit: int = Query(50),
|
||||||
@@ -1736,7 +1739,9 @@ def setup_email_routes():
|
|||||||
return {"success": False, "error": "Mail operation failed"}
|
return {"success": False, "error": "Mail operation failed"}
|
||||||
|
|
||||||
@router.post("/archive/{uid}")
|
@router.post("/archive/{uid}")
|
||||||
async def archive_email(uid: str, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)):
|
# Sync def: blocking IMAP I/O with no awaits — see search_emails above. Runs in a
|
||||||
|
# threadpool instead of blocking the event loop.
|
||||||
|
def archive_email(uid: str, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)):
|
||||||
"""Move email to Archive folder."""
|
"""Move email to Archive folder."""
|
||||||
try:
|
try:
|
||||||
with _imap(account_id, owner=owner) as conn:
|
with _imap(account_id, owner=owner) as conn:
|
||||||
|
|||||||
+47
-14
@@ -19,6 +19,7 @@ from src.upload_limits import (
|
|||||||
GALLERY_TRANSFORM_UPLOAD_MAX_BYTES,
|
GALLERY_TRANSFORM_UPLOAD_MAX_BYTES,
|
||||||
)
|
)
|
||||||
from src.constants import GENERATED_IMAGES_DIR
|
from src.constants import GENERATED_IMAGES_DIR
|
||||||
|
from src.optional_deps import patch_realesrgan_torchvision_compat
|
||||||
|
|
||||||
from routes.gallery_helpers import (
|
from routes.gallery_helpers import (
|
||||||
GalleryPatch, _extract_exif, _image_to_dict, _owner_filter, _human_size,
|
GalleryPatch, _extract_exif, _image_to_dict, _owner_filter, _human_size,
|
||||||
@@ -108,6 +109,32 @@ def _visible_image_endpoint_for_base(db, base: str, owner: str | None):
|
|||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_result_image_b64(url: str) -> Optional[str]:
|
||||||
|
"""Fetch an image URL returned in an upstream response body, base64-encoded
|
||||||
|
(or None on a non-200).
|
||||||
|
|
||||||
|
The URL comes from the diffusion/OpenAI server's response, not from our own
|
||||||
|
config, so a malicious or compromised endpoint could otherwise steer this
|
||||||
|
fetch at an internal or cloud-metadata address. Validate it the same way the
|
||||||
|
client-supplied endpoint is validated before the first request.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import httpx
|
||||||
|
from src.url_safety import check_outbound_url
|
||||||
|
|
||||||
|
ok, reason = check_outbound_url(
|
||||||
|
url,
|
||||||
|
block_private=os.getenv("IMAGE_BLOCK_PRIVATE_IPS", "false").lower() == "true",
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(502, f"Upstream returned an unsafe image URL: {reason}")
|
||||||
|
async with httpx.AsyncClient(timeout=60) as c2:
|
||||||
|
ir = await c2.get(url)
|
||||||
|
if ir.status_code == 200:
|
||||||
|
return base64.b64encode(ir.content).decode()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def setup_gallery_routes() -> APIRouter:
|
def setup_gallery_routes() -> APIRouter:
|
||||||
router = APIRouter(tags=["gallery"])
|
router = APIRouter(tags=["gallery"])
|
||||||
|
|
||||||
@@ -904,14 +931,22 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
raise HTTPException(404, "Image not found")
|
raise HTTPException(404, "Image not found")
|
||||||
|
|
||||||
img_filename = img.filename
|
img_filename = img.filename
|
||||||
# Remove the file from disk
|
# Soft-delete the record first; the DB is the source of truth.
|
||||||
|
img.is_active = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Only after the soft-delete commit succeeds do we remove the file.
|
||||||
|
# If the file were deleted first and the commit then failed/rolled
|
||||||
|
# back, the still-active record would point at a missing file.
|
||||||
|
# Best-effort so a missing or locked file can't 500 a delete that
|
||||||
|
# already succeeded logically. Uses the path-confined resolver so a
|
||||||
|
# malformed stored filename can't escape generated_images.
|
||||||
|
try:
|
||||||
img_path = _gallery_image_path(img_filename)
|
img_path = _gallery_image_path(img_filename)
|
||||||
if img_path.exists():
|
if img_path.exists():
|
||||||
img_path.unlink()
|
img_path.unlink()
|
||||||
|
except Exception as e:
|
||||||
# Soft-delete the record
|
logger.warning(f"Could not remove gallery image file for {img_filename}: {e}")
|
||||||
img.is_active = False
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# Strip stale chat-history references so the image bubble
|
# Strip stale chat-history references so the image bubble
|
||||||
# (and its prompt caption) doesn't come back after a server
|
# (and its prompt caption) doesn't come back after a server
|
||||||
@@ -1142,10 +1177,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
if item.get("b64_json"):
|
if item.get("b64_json"):
|
||||||
raw_b64 = item["b64_json"]
|
raw_b64 = item["b64_json"]
|
||||||
elif item.get("url"):
|
elif item.get("url"):
|
||||||
async with httpx.AsyncClient(timeout=60) as c2:
|
raw_b64 = await _fetch_result_image_b64(item["url"])
|
||||||
img_r = await c2.get(item["url"])
|
|
||||||
if img_r.status_code == 200:
|
|
||||||
raw_b64 = base64.b64encode(img_r.content).decode()
|
|
||||||
if not raw_b64:
|
if not raw_b64:
|
||||||
raise HTTPException(502, "OpenAI returned no image")
|
raise HTTPException(502, "OpenAI returned no image")
|
||||||
|
|
||||||
@@ -1206,7 +1238,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
original and regenerates `strength` fraction. With strength ~0.4
|
original and regenerates `strength` fraction. With strength ~0.4
|
||||||
you get edge blending + lighting unification while keeping the
|
you get edge blending + lighting unification while keeping the
|
||||||
composition recognisable."""
|
composition recognisable."""
|
||||||
import httpx, base64 as _b64
|
import httpx
|
||||||
user = require_privilege(request, "can_generate_images")
|
user = require_privilege(request, "can_generate_images")
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
|
||||||
@@ -1382,10 +1414,9 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
if item.get("b64_json"):
|
if item.get("b64_json"):
|
||||||
return {"image": item["b64_json"]}
|
return {"image": item["b64_json"]}
|
||||||
if item.get("url"):
|
if item.get("url"):
|
||||||
async with httpx.AsyncClient(timeout=60) as c2:
|
img_b64 = await _fetch_result_image_b64(item["url"])
|
||||||
ir = await c2.get(item["url"])
|
if img_b64:
|
||||||
if ir.status_code == 200:
|
return {"image": img_b64}
|
||||||
return {"image": _b64.b64encode(ir.content).decode()}
|
|
||||||
last_err = f"{path}: server returned no image"
|
last_err = f"{path}: server returned no image"
|
||||||
except httpx.ConnectError as e:
|
except httpx.ConnectError as e:
|
||||||
raise HTTPException(502, f"Can't reach diffusion server at {base}: {e}")
|
raise HTTPException(502, f"Can't reach diffusion server at {base}: {e}")
|
||||||
@@ -1445,6 +1476,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
img_bytes = base64.b64decode(image_b64)
|
img_bytes = base64.b64decode(image_b64)
|
||||||
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||||
try:
|
try:
|
||||||
|
patch_realesrgan_torchvision_compat()
|
||||||
from realesrgan import RealESRGANer
|
from realesrgan import RealESRGANer
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return {"error": "realesrgan not installed. Install it from Cookbook → Dependencies (search 'realesrgan')."}
|
return {"error": "realesrgan not installed. Install it from Cookbook → Dependencies (search 'realesrgan')."}
|
||||||
@@ -1494,6 +1526,7 @@ def setup_gallery_routes() -> APIRouter:
|
|||||||
img_bytes = base64.b64decode(image_b64)
|
img_bytes = base64.b64decode(image_b64)
|
||||||
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
src = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||||
try:
|
try:
|
||||||
|
patch_realesrgan_torchvision_compat()
|
||||||
from basicsr.archs.rrdbnet_arch import RRDBNet
|
from basicsr.archs.rrdbnet_arch import RRDBNet
|
||||||
from realesrgan import RealESRGANer
|
from realesrgan import RealESRGANer
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
|||||||
+18
-6
@@ -108,6 +108,12 @@ def _load_disabled_map():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _mcp_oauth_redirect_uri() -> str:
|
||||||
|
"""Shared callback URL for legacy Google and generic MCP OAuth flows."""
|
||||||
|
from src.mcp_oauth import REDIRECT_URI
|
||||||
|
return REDIRECT_URI
|
||||||
|
|
||||||
|
|
||||||
def setup_mcp_routes(mcp_manager: McpManager):
|
def setup_mcp_routes(mcp_manager: McpManager):
|
||||||
"""Setup MCP routes with the provided manager."""
|
"""Setup MCP routes with the provided manager."""
|
||||||
|
|
||||||
@@ -445,9 +451,9 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
|||||||
client_id = keys["client_id"]
|
client_id = keys["client_id"]
|
||||||
scopes = oauth_cfg.get("scopes", [])
|
scopes = oauth_cfg.get("scopes", [])
|
||||||
|
|
||||||
# For Desktop App creds, redirect to localhost — the user will
|
# For Desktop App creds, default to localhost — the user will
|
||||||
# paste the resulting URL back if they're on a different device.
|
# paste the resulting URL back if they're on a different device.
|
||||||
redirect_uri = "http://localhost:7000/api/mcp/oauth/callback"
|
redirect_uri = _mcp_oauth_redirect_uri()
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
"client_id": client_id,
|
"client_id": client_id,
|
||||||
@@ -469,7 +475,7 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
|||||||
return RedirectResponse(auth_url)
|
return RedirectResponse(auth_url)
|
||||||
else:
|
else:
|
||||||
# Remote device — show paste-back page
|
# Remote device — show paste-back page
|
||||||
return HTMLResponse(_oauth_authorize_page(auth_url, server_id, host))
|
return HTMLResponse(_oauth_authorize_page(auth_url, server_id, host, redirect_uri))
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@@ -536,7 +542,7 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
|||||||
client_id = keys["client_id"]
|
client_id = keys["client_id"]
|
||||||
client_secret = keys["client_secret"]
|
client_secret = keys["client_secret"]
|
||||||
|
|
||||||
redirect_uri = "http://localhost:7000/api/mcp/oauth/callback"
|
redirect_uri = _mcp_oauth_redirect_uri()
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
@@ -603,13 +609,19 @@ def setup_mcp_routes(mcp_manager: McpManager):
|
|||||||
return router
|
return router
|
||||||
|
|
||||||
|
|
||||||
def _oauth_authorize_page(auth_url: str, server_id: str, host: str) -> str:
|
def _oauth_authorize_page(
|
||||||
|
auth_url: str,
|
||||||
|
server_id: str,
|
||||||
|
host: str,
|
||||||
|
redirect_uri: str = "http://localhost:7000/api/mcp/oauth/callback",
|
||||||
|
) -> str:
|
||||||
"""Page with Google sign-in link and URL paste-back form for remote access."""
|
"""Page with Google sign-in link and URL paste-back form for remote access."""
|
||||||
# Escape values interpolated into the page: `host` comes from the request
|
# Escape values interpolated into the page: `host` comes from the request
|
||||||
# Host header and `server_id` from the OAuth state — neither is trusted.
|
# Host header and `server_id` from the OAuth state — neither is trusted.
|
||||||
auth_url = html.escape(auth_url, quote=True)
|
auth_url = html.escape(auth_url, quote=True)
|
||||||
server_id = html.escape(server_id, quote=True)
|
server_id = html.escape(server_id, quote=True)
|
||||||
host = html.escape(host, quote=True)
|
host = html.escape(host, quote=True)
|
||||||
|
redirect_uri = html.escape(redirect_uri, quote=True)
|
||||||
return f"""<!DOCTYPE html>
|
return f"""<!DOCTYPE html>
|
||||||
<html><head>
|
<html><head>
|
||||||
<meta charset="UTF-8"><title>Authorize — Odysseus</title>
|
<meta charset="UTF-8"><title>Authorize — Odysseus</title>
|
||||||
@@ -654,7 +666,7 @@ def _oauth_authorize_page(auth_url: str, server_id: str, host: str) -> str:
|
|||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<form method="POST" action="http://{host}/api/mcp/oauth/exchange/{server_id}">
|
<form method="POST" action="http://{host}/api/mcp/oauth/exchange/{server_id}">
|
||||||
<p>Paste the URL from your browser after signing in:</p>
|
<p>Paste the URL from your browser after signing in:</p>
|
||||||
<input type="text" name="callback_url" placeholder="http://localhost:7000/api/mcp/oauth/callback?code=..." required>
|
<input type="text" name="callback_url" placeholder="{redirect_uri}?code=..." required>
|
||||||
<br><button type="submit">Connect</button>
|
<br><button type="submit">Connect</button>
|
||||||
</form>
|
</form>
|
||||||
</div></body></html>"""
|
</div></body></html>"""
|
||||||
|
|||||||
+23
-9
@@ -29,6 +29,7 @@ from src.llm_core import llm_call_async
|
|||||||
from services.memory.memory_extractor import audit_memories
|
from services.memory.memory_extractor import audit_memories
|
||||||
from src.auth_helpers import get_current_user, require_user
|
from src.auth_helpers import get_current_user, require_user
|
||||||
from src.endpoint_resolver import resolve_endpoint
|
from src.endpoint_resolver import resolve_endpoint
|
||||||
|
from src.task_endpoint import resolve_task_endpoint
|
||||||
from src.upload_limits import read_upload_limited, MEMORY_IMPORT_MAX_BYTES
|
from src.upload_limits import read_upload_limited, MEMORY_IMPORT_MAX_BYTES
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -240,14 +241,18 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
|||||||
}
|
}
|
||||||
messages = [system_msg] + sess.get_context_messages()
|
messages = [system_msg] + sess.get_context_messages()
|
||||||
|
|
||||||
|
t_url, t_model, t_headers = resolve_task_endpoint(
|
||||||
|
sess.endpoint_url, sess.model, sess.headers, owner=_owner(request)
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
suggestion_text = await llm_call_async(
|
suggestion_text = await llm_call_async(
|
||||||
sess.endpoint_url,
|
t_url,
|
||||||
sess.model,
|
t_model,
|
||||||
messages,
|
messages,
|
||||||
temperature=0.2,
|
temperature=0.2,
|
||||||
max_tokens=500,
|
max_tokens=500,
|
||||||
headers=sess.headers,
|
headers=t_headers,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
suggestions = json.loads(suggestion_text)
|
suggestions = json.loads(suggestion_text)
|
||||||
@@ -278,7 +283,15 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
|||||||
endpoint_url = model = None
|
endpoint_url = model = None
|
||||||
headers = {}
|
headers = {}
|
||||||
|
|
||||||
# Try default model from settings first
|
# Try utility model from settings first — memory audit is a background
|
||||||
|
# task and should prefer the lighter utility model over the main chat model.
|
||||||
|
from src.task_endpoint import resolve_task_endpoint
|
||||||
|
user = _owner(request)
|
||||||
|
t_url, t_model, t_headers = resolve_task_endpoint(owner=user)
|
||||||
|
if t_url and t_model:
|
||||||
|
endpoint_url, model, headers = t_url, t_model, t_headers
|
||||||
|
else:
|
||||||
|
# Fall back to default model if no task/utility model configured
|
||||||
settings = _load_settings()
|
settings = _load_settings()
|
||||||
ep_id = settings.get("default_endpoint_id", "")
|
ep_id = settings.get("default_endpoint_id", "")
|
||||||
default_model = settings.get("default_model", "")
|
default_model = settings.get("default_model", "")
|
||||||
@@ -360,13 +373,14 @@ def setup_memory_routes(memory_manager: MemoryManager, session_manager: SessionM
|
|||||||
try:
|
try:
|
||||||
sess = session_manager.get_session(session)
|
sess = session_manager.get_session(session)
|
||||||
_assert_session_owner(sess, _owner(request))
|
_assert_session_owner(sess, _owner(request))
|
||||||
endpoint_url = sess.endpoint_url
|
endpoint_url, model, headers = resolve_task_endpoint(
|
||||||
model = sess.model
|
sess.endpoint_url, sess.model, sess.headers, owner=_owner(request)
|
||||||
headers = sess.headers
|
)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise HTTPException(404, "Session not found — needed for LLM config")
|
logger.warning("Session %s not found, falling back to utility endpoint", session)
|
||||||
else:
|
|
||||||
endpoint_url, model, headers = resolve_endpoint("utility", owner=_owner(request))
|
endpoint_url, model, headers = resolve_endpoint("utility", owner=_owner(request))
|
||||||
|
else:
|
||||||
|
endpoint_url, model, headers = resolve_task_endpoint(owner=_owner(request))
|
||||||
|
|
||||||
if not endpoint_url or not model:
|
if not endpoint_url or not model:
|
||||||
raise HTTPException(400, "No LLM model configured. Set a default model in Settings.")
|
raise HTTPException(400, "No LLM model configured. Set a default model in Settings.")
|
||||||
|
|||||||
+52
-4
@@ -248,6 +248,9 @@ _PROVIDER_CURATED = {
|
|||||||
"zai-coding": [
|
"zai-coding": [
|
||||||
"glm-5.1", "glm-5v-turbo", "glm-5-turbo", "glm-4.7", "glm-4.5-air",
|
"glm-5.1", "glm-5v-turbo", "glm-5-turbo", "glm-4.7", "glm-4.5-air",
|
||||||
],
|
],
|
||||||
|
"kimi-code": [
|
||||||
|
"kimi-for-coding",
|
||||||
|
],
|
||||||
"deepseek": [
|
"deepseek": [
|
||||||
"deepseek-chat", "deepseek-reasoner",
|
"deepseek-chat", "deepseek-reasoner",
|
||||||
],
|
],
|
||||||
@@ -315,6 +318,8 @@ def _match_provider_curated(base_url: str, provider: str) -> str:
|
|||||||
parsed = urlparse(base_url)
|
parsed = urlparse(base_url)
|
||||||
if _host_match(base_url, "z.ai") and "/api/coding" in (parsed.path or ""):
|
if _host_match(base_url, "z.ai") and "/api/coding" in (parsed.path or ""):
|
||||||
return "zai-coding"
|
return "zai-coding"
|
||||||
|
if _host_match(base_url, "kimi.com") and "/coding" in (parsed.path or ""):
|
||||||
|
return "kimi-code"
|
||||||
for domain, key in _HOST_TO_CURATED:
|
for domain, key in _HOST_TO_CURATED:
|
||||||
if _host_match(base_url, domain):
|
if _host_match(base_url, domain):
|
||||||
return key
|
return key
|
||||||
@@ -703,6 +708,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
|||||||
"""Probe a base URL's /models endpoint and return list of model IDs.
|
"""Probe a base URL's /models endpoint and return list of model IDs.
|
||||||
For Anthropic, queries their /v1/models API, falling back to hardcoded list."""
|
For Anthropic, queries their /v1/models API, falling back to hardcoded list."""
|
||||||
from src.endpoint_resolver import resolve_url
|
from src.endpoint_resolver import resolve_url
|
||||||
|
from src.llm_core import httpx_get_kimi_aware
|
||||||
base = resolve_url(_normalize_base(base_url))
|
base = resolve_url(_normalize_base(base_url))
|
||||||
provider = _safe_detect_provider(base)
|
provider = _safe_detect_provider(base)
|
||||||
if provider == "chatgpt-subscription":
|
if provider == "chatgpt-subscription":
|
||||||
@@ -738,7 +744,7 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
|||||||
url = _safe_build_models_url(base)
|
url = _safe_build_models_url(base)
|
||||||
headers = _safe_build_headers(api_key, base)
|
headers = _safe_build_headers(api_key, base)
|
||||||
try:
|
try:
|
||||||
r = httpx.get(url, headers=headers, timeout=timeout, verify=llm_verify())
|
r = httpx_get_kimi_aware(url, headers, timeout=timeout, verify=llm_verify())
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
# OpenAI format: {"data": [{"id": "model-name"}]}
|
# OpenAI format: {"data": [{"id": "model-name"}]}
|
||||||
@@ -754,6 +760,11 @@ def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> Lis
|
|||||||
for _e in _PROVIDER_CURATED.get(_ck, []):
|
for _e in _PROVIDER_CURATED.get(_ck, []):
|
||||||
if _e not in set(models) and not any(m.startswith(_e) for m in models):
|
if _e not in set(models) and not any(m.startswith(_e) for m in models):
|
||||||
models.append(_e)
|
models.append(_e)
|
||||||
|
if _host_match(base, "kimi.com") and "/coding" in (urlparse(base).path or ""):
|
||||||
|
_ck = _match_provider_curated(base, None)
|
||||||
|
for _e in _PROVIDER_CURATED.get(_ck, []):
|
||||||
|
if _e not in set(models) and not any(m.startswith(_e) for m in models):
|
||||||
|
models.append(_e)
|
||||||
return [m for m in models if _is_chat_model(m)]
|
return [m for m in models if _is_chat_model(m)]
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
if api_key:
|
if api_key:
|
||||||
@@ -870,15 +881,52 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
|
|||||||
|
|
||||||
|
|
||||||
def _model_endpoint_error_message(base_url: str, ping: Dict[str, Any] = None) -> str:
|
def _model_endpoint_error_message(base_url: str, ping: Dict[str, Any] = None) -> str:
|
||||||
"""Return a provider-aware error message for failed endpoint probes."""
|
"""Return a provider-aware error message for failed endpoint probes.
|
||||||
|
|
||||||
|
Surfaces the URL we actually probed and, when the endpoint looks like
|
||||||
|
LM Studio (port 1234 or hostname match), adds a hint about loading a
|
||||||
|
model and confirming the Developer Server is running. The user previously
|
||||||
|
saw a generic "No models found for that provider/key" with no way to
|
||||||
|
tell whether the URL was wrong, the server was down, or the server was
|
||||||
|
reachable but had no model loaded (issue #25).
|
||||||
|
"""
|
||||||
ping = ping or {}
|
ping = ping or {}
|
||||||
error = ping.get("error")
|
error = ping.get("error")
|
||||||
|
from src.endpoint_resolver import build_models_url
|
||||||
|
try:
|
||||||
|
probed = build_models_url(base_url) or base_url
|
||||||
|
except Exception:
|
||||||
|
probed = base_url
|
||||||
parsed = urlparse(base_url)
|
parsed = urlparse(base_url)
|
||||||
host = (parsed.hostname or "").lower()
|
host = (parsed.hostname or "").lower()
|
||||||
is_ollama = parsed.port == 11434 or "ollama" in host or "ollama" in base_url.lower()
|
is_ollama = parsed.port == 11434 or "ollama" in host or "ollama" in base_url.lower()
|
||||||
|
is_lmstudio = (
|
||||||
|
parsed.port == 1234
|
||||||
|
or "lmstudio" in host
|
||||||
|
or "lm-studio" in host
|
||||||
|
or "lm_studio" in host
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_lmstudio:
|
||||||
|
parts = [
|
||||||
|
"LM Studio is reachable, but no models were reported.",
|
||||||
|
f"Probed {probed}.",
|
||||||
|
]
|
||||||
|
if error:
|
||||||
|
parts.append(f"Last probe error: {error}.")
|
||||||
|
parts.append(
|
||||||
|
"Open LM Studio, load at least one model, and confirm the "
|
||||||
|
"Developer Server is running on port 1234."
|
||||||
|
)
|
||||||
|
parts.append(
|
||||||
|
"Base URL should be http://localhost:1234/v1 (native) or "
|
||||||
|
"http://host.docker.internal:1234/v1 (Docker)."
|
||||||
|
)
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
if is_ollama:
|
if is_ollama:
|
||||||
parts = ["No Ollama models found for that endpoint."]
|
parts = ["No Ollama models found for that endpoint."]
|
||||||
|
parts.append(f"Probed {probed}.")
|
||||||
if error:
|
if error:
|
||||||
parts.append(f"Last probe error: {error}.")
|
parts.append(f"Last probe error: {error}.")
|
||||||
parts.append("Check that Ollama is running and that the base URL is correct.")
|
parts.append("Check that Ollama is running and that the base URL is correct.")
|
||||||
@@ -888,9 +936,9 @@ def _model_endpoint_error_message(base_url: str, ping: Dict[str, Any] = None) ->
|
|||||||
return " ".join(parts)
|
return " ".join(parts)
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
return f"No models found for that provider/key. Last probe error: {error}."
|
return f"No models found for that provider/key. Probed {probed}. Last probe error: {error}."
|
||||||
|
|
||||||
return "No models found for that provider/key."
|
return f"No models found for that provider/key. Probed {probed}."
|
||||||
|
|
||||||
|
|
||||||
def _normalize_model_ids(value):
|
def _normalize_model_ids(value):
|
||||||
|
|||||||
@@ -160,8 +160,11 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
|
|||||||
JSON response confirming removal
|
JSON response confirming removal
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not directory:
|
# Confine to PERSONAL_DIR — parity with add_directory_to_rag (which
|
||||||
raise HTTPException(400, "Directory path is required")
|
# resolves the path the same way). Without this, an arbitrary or
|
||||||
|
# `..`-escaping path is passed straight to
|
||||||
|
# personal_docs_manager.remove_directory / rag.remove_directory.
|
||||||
|
directory = _resolve_allowed_personal_dir(directory)
|
||||||
|
|
||||||
logger.info(f"Removing directory from RAG: {directory}")
|
logger.info(f"Removing directory from RAG: {directory}")
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -1,6 +1,7 @@
|
|||||||
"""Shell routes — user-facing command execution endpoint."""
|
"""Shell routes — user-facing command execution endpoint."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import importlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -14,6 +15,7 @@ from collections import namedtuple
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from core.platform_compat import IS_APPLE_SILICON, which_tool
|
from core.platform_compat import IS_APPLE_SILICON, which_tool
|
||||||
|
from src.optional_deps import prepare_optional_dependency_import
|
||||||
|
|
||||||
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
|
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
|
||||||
# on Windows, so importing them unconditionally crashed app startup there
|
# on Windows, so importing them unconditionally crashed app startup there
|
||||||
@@ -149,6 +151,11 @@ def _pip_dist_name(pkg: dict) -> str:
|
|||||||
return (pkg.get("name") or "").replace("_", "-")
|
return (pkg.get("name") or "").replace("_", "-")
|
||||||
|
|
||||||
|
|
||||||
|
def _import_optional_dependency_for_status(name: str):
|
||||||
|
prepare_optional_dependency_import(name)
|
||||||
|
return importlib.import_module(name)
|
||||||
|
|
||||||
|
|
||||||
def _package_installed_from_probe(name: str, probe: dict) -> bool:
|
def _package_installed_from_probe(name: str, probe: dict) -> bool:
|
||||||
"""Return whether an optional dependency is usable by Cookbook.
|
"""Return whether an optional dependency is usable by Cookbook.
|
||||||
|
|
||||||
@@ -970,7 +977,6 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
"""
|
"""
|
||||||
_require_admin(request)
|
_require_admin(request)
|
||||||
_reject_cross_site(request)
|
_reject_cross_site(request)
|
||||||
import importlib
|
|
||||||
import importlib.metadata as importlib_metadata
|
import importlib.metadata as importlib_metadata
|
||||||
import shlex
|
import shlex
|
||||||
import json as _json
|
import json as _json
|
||||||
@@ -1057,6 +1063,13 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
"category": "Image",
|
"category": "Image",
|
||||||
"target": "remote",
|
"target": "remote",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "transformers",
|
||||||
|
"pip": "transformers",
|
||||||
|
"desc": "Hugging Face model components used by SD/Flux pipelines and image tools",
|
||||||
|
"category": "Image",
|
||||||
|
"target": "remote",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "rembg",
|
"name": "rembg",
|
||||||
"pip": "rembg[gpu]",
|
"pip": "rembg[gpu]",
|
||||||
@@ -1202,7 +1215,7 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
pkg["status_note"] = _package_status_note("vllm", probe)
|
pkg["status_note"] = _package_status_note("vllm", probe)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
importlib.import_module(pkg["name"])
|
_import_optional_dependency_for_status(pkg["name"])
|
||||||
importlib_metadata.version(_pip_dist_name(pkg))
|
importlib_metadata.version(_pip_dist_name(pkg))
|
||||||
pkg["installed"] = True
|
pkg["installed"] = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -1251,6 +1264,7 @@ def setup_shell_routes() -> APIRouter:
|
|||||||
"sglang[all]",
|
"sglang[all]",
|
||||||
"diffusers",
|
"diffusers",
|
||||||
"diffusers[torch]",
|
"diffusers[torch]",
|
||||||
|
"transformers",
|
||||||
"TTS",
|
"TTS",
|
||||||
"bark",
|
"bark",
|
||||||
"faster-whisper",
|
"faster-whisper",
|
||||||
|
|||||||
@@ -198,6 +198,8 @@ def setup_webhook_routes(
|
|||||||
"opencode-go": "https://opencode.ai/zen/go/v1",
|
"opencode-go": "https://opencode.ai/zen/go/v1",
|
||||||
"fireworks": "https://api.fireworks.ai/inference/v1",
|
"fireworks": "https://api.fireworks.ai/inference/v1",
|
||||||
"venice": "https://api.venice.ai/api/v1",
|
"venice": "https://api.venice.ai/api/v1",
|
||||||
|
"kimi-code": "https://api.kimi.com/coding/v1",
|
||||||
|
"kimicode": "https://api.kimi.com/coding/v1",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Model prefix → provider mapping for auto-detection
|
# Model prefix → provider mapping for auto-detection
|
||||||
@@ -210,6 +212,8 @@ def setup_webhook_routes(
|
|||||||
"mistral": "mistral",
|
"mistral": "mistral",
|
||||||
"llama": "groq",
|
"llama": "groq",
|
||||||
"mixtral": "groq",
|
"mixtral": "groq",
|
||||||
|
"kimi-for-coding": "kimi-code",
|
||||||
|
"kimi": "kimi-code",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _resolve_base_url(model: Optional[str], provider: Optional[str]) -> Optional[str]:
|
def _resolve_base_url(model: Optional[str], provider: Optional[str]) -> Optional[str]:
|
||||||
|
|||||||
Executable
+635
@@ -0,0 +1,635 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build a neutral agent migration manifest.
|
||||||
|
|
||||||
|
This helper is intentionally read-only. It does not import the Odysseus
|
||||||
|
application package, write to data/, call an LLM, or apply anything. It turns
|
||||||
|
common agent export shapes into a portable JSON manifest that Odysseus can
|
||||||
|
preview or import later.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_VERSION = "agent-migration.v1"
|
||||||
|
TEXT_EXTENSIONS = {
|
||||||
|
".cfg",
|
||||||
|
".conf",
|
||||||
|
".csv",
|
||||||
|
".json",
|
||||||
|
".log",
|
||||||
|
".md",
|
||||||
|
".markdown",
|
||||||
|
".py",
|
||||||
|
".rst",
|
||||||
|
".toml",
|
||||||
|
".txt",
|
||||||
|
".yaml",
|
||||||
|
".yml",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class InputWarning:
|
||||||
|
path: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_text(text: str) -> str:
|
||||||
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_bytes(data: bytes) -> str:
|
||||||
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_path(path: Path) -> str:
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with path.open("rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(65536), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def stable_id(kind: str, source_name: str, *parts: Any) -> str:
|
||||||
|
raw = "\x1f".join([kind, source_name, *[str(part) for part in parts]])
|
||||||
|
return f"{kind}:{hashlib.sha256(raw.encode('utf-8')).hexdigest()[:16]}"
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path: Path) -> Any:
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
return json.load(handle)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_category(value: Any) -> str:
|
||||||
|
category = str(value or "fact").strip().lower()
|
||||||
|
return category or "fact"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_memory_text(item: Any) -> str:
|
||||||
|
if isinstance(item, str):
|
||||||
|
return item.strip()
|
||||||
|
if isinstance(item, dict):
|
||||||
|
for key in ("text", "content", "memory", "value"):
|
||||||
|
value = item.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def memory_metadata(item: Any, source_path: Path, index: int) -> dict[str, Any]:
|
||||||
|
metadata: dict[str, Any] = {
|
||||||
|
"source_path": str(source_path),
|
||||||
|
"source_index": index,
|
||||||
|
}
|
||||||
|
if isinstance(item, dict):
|
||||||
|
for key in ("id", "timestamp", "created_at", "updated_at", "source", "tags", "pinned"):
|
||||||
|
if key in item:
|
||||||
|
metadata[f"source_{key}"] = item.get(key)
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
|
||||||
|
def payload_items(payload: Any, keys: tuple[str, ...]) -> Any:
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
for key in keys:
|
||||||
|
if isinstance(payload.get(key), list):
|
||||||
|
return payload[key]
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def collect_memory_json(path: Path, source_name: str) -> tuple[list[dict[str, Any]], list[InputWarning]]:
|
||||||
|
warnings: list[InputWarning] = []
|
||||||
|
try:
|
||||||
|
payload = read_json(path)
|
||||||
|
except Exception as exc:
|
||||||
|
return [], [InputWarning(str(path), f"could not read JSON: {exc}")]
|
||||||
|
|
||||||
|
payload = payload_items(payload, ("memories", "memory", "items", "data"))
|
||||||
|
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
return [], [InputWarning(str(path), "expected a JSON list or an object containing a memory list")]
|
||||||
|
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for index, item in enumerate(payload):
|
||||||
|
text = normalize_memory_text(item)
|
||||||
|
if not text:
|
||||||
|
warnings.append(InputWarning(str(path), f"skipped memory at index {index}: missing text"))
|
||||||
|
continue
|
||||||
|
digest = sha256_text(text.strip().lower())
|
||||||
|
if digest in seen:
|
||||||
|
warnings.append(InputWarning(str(path), f"skipped duplicate memory at index {index}"))
|
||||||
|
continue
|
||||||
|
seen.add(digest)
|
||||||
|
category = normalize_category(item.get("category") if isinstance(item, dict) else "fact")
|
||||||
|
source = str(item.get("source") or source_name) if isinstance(item, dict) else source_name
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": stable_id("memory", source_name, path, index, digest),
|
||||||
|
"kind": "memory",
|
||||||
|
"text": text,
|
||||||
|
"category": category,
|
||||||
|
"source": source,
|
||||||
|
"metadata": memory_metadata(item, path, index),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items, warnings
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_timestamp(value: Any) -> str | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
try:
|
||||||
|
return (
|
||||||
|
datetime.fromtimestamp(float(value), timezone.utc)
|
||||||
|
.replace(microsecond=0)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z")
|
||||||
|
)
|
||||||
|
except (OverflowError, OSError, ValueError):
|
||||||
|
return str(value)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_role(value: Any) -> str:
|
||||||
|
role = str(value or "unknown").strip().lower()
|
||||||
|
if role in {"human", "user"}:
|
||||||
|
return "user"
|
||||||
|
if role in {"assistant", "ai", "bot", "model"}:
|
||||||
|
return "assistant"
|
||||||
|
if role in {"system", "tool"}:
|
||||||
|
return role
|
||||||
|
return role or "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def content_part_text(part: Any) -> str:
|
||||||
|
if isinstance(part, str):
|
||||||
|
return part
|
||||||
|
if isinstance(part, dict):
|
||||||
|
for key in ("text", "content", "value"):
|
||||||
|
value = part.get(key)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
if part.get("type") == "text" and isinstance(part.get("text"), str):
|
||||||
|
return part["text"]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_message_text(message: dict[str, Any]) -> str:
|
||||||
|
content = message.get("content")
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, list):
|
||||||
|
return "\n".join(text for text in (content_part_text(part).strip() for part in content) if text)
|
||||||
|
if isinstance(content, dict):
|
||||||
|
parts = content.get("parts")
|
||||||
|
if isinstance(parts, list):
|
||||||
|
return "\n".join(text for text in (content_part_text(part).strip() for part in parts) if text)
|
||||||
|
for key in ("text", "content", "value"):
|
||||||
|
value = content.get(key)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
for key in ("text", "body", "message"):
|
||||||
|
value = message.get(key)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_message(message: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
author = message.get("author") if isinstance(message.get("author"), dict) else {}
|
||||||
|
role = (
|
||||||
|
message.get("role")
|
||||||
|
or message.get("sender")
|
||||||
|
or message.get("speaker")
|
||||||
|
or author.get("role")
|
||||||
|
or author.get("name")
|
||||||
|
)
|
||||||
|
text = normalize_message_text(message).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
normalized: dict[str, Any] = {
|
||||||
|
"role": normalize_role(role),
|
||||||
|
"text": text,
|
||||||
|
}
|
||||||
|
timestamp = normalize_timestamp(message.get("created_at") or message.get("create_time") or message.get("timestamp"))
|
||||||
|
if timestamp:
|
||||||
|
normalized["created_at"] = timestamp
|
||||||
|
message_id = message.get("id")
|
||||||
|
if message_id is not None:
|
||||||
|
normalized["source_id"] = str(message_id)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def chatgpt_mapping_messages(conversation: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
mapping = conversation.get("mapping")
|
||||||
|
if not isinstance(mapping, dict):
|
||||||
|
return []
|
||||||
|
rows: list[tuple[float, int, dict[str, Any]]] = []
|
||||||
|
for index, node in enumerate(mapping.values()):
|
||||||
|
if not isinstance(node, dict) or not isinstance(node.get("message"), dict):
|
||||||
|
continue
|
||||||
|
message = node["message"]
|
||||||
|
sort_value = message.get("create_time")
|
||||||
|
try:
|
||||||
|
sort_key = float(sort_value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
sort_key = float(index)
|
||||||
|
normalized = normalize_message(message)
|
||||||
|
if normalized:
|
||||||
|
rows.append((sort_key, index, normalized))
|
||||||
|
return [row[2] for row in sorted(rows, key=lambda row: (row[0], row[1]))]
|
||||||
|
|
||||||
|
|
||||||
|
def conversation_messages(conversation: dict[str, Any]) -> tuple[list[dict[str, Any]], str]:
|
||||||
|
mapped = chatgpt_mapping_messages(conversation)
|
||||||
|
if mapped:
|
||||||
|
return mapped, "chatgpt_mapping"
|
||||||
|
for key in ("messages", "chat_messages", "turns"):
|
||||||
|
raw_messages = conversation.get(key)
|
||||||
|
if isinstance(raw_messages, list):
|
||||||
|
messages = [
|
||||||
|
normalized
|
||||||
|
for raw in raw_messages
|
||||||
|
if isinstance(raw, dict)
|
||||||
|
for normalized in [normalize_message(raw)]
|
||||||
|
if normalized
|
||||||
|
]
|
||||||
|
return messages, key
|
||||||
|
return [], "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def conversation_title(conversation: dict[str, Any], index: int) -> str:
|
||||||
|
for key in ("title", "name", "summary"):
|
||||||
|
value = conversation.get(key)
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
return f"Conversation {index + 1}"
|
||||||
|
|
||||||
|
|
||||||
|
def collect_conversation_json(
|
||||||
|
path: Path,
|
||||||
|
source_name: str,
|
||||||
|
*,
|
||||||
|
include_content: bool = False,
|
||||||
|
max_messages: int = 2000,
|
||||||
|
) -> tuple[list[dict[str, Any]], list[InputWarning]]:
|
||||||
|
warnings: list[InputWarning] = []
|
||||||
|
try:
|
||||||
|
payload = read_json(path)
|
||||||
|
except Exception as exc:
|
||||||
|
return [], [InputWarning(str(path), f"could not read JSON: {exc}")]
|
||||||
|
|
||||||
|
payload = payload_items(payload, ("conversations", "conversation", "items", "data"))
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
payload = [payload]
|
||||||
|
if not isinstance(payload, list):
|
||||||
|
return [], [InputWarning(str(path), "expected a JSON list or an object containing a conversation list")]
|
||||||
|
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for index, conversation in enumerate(payload):
|
||||||
|
if not isinstance(conversation, dict):
|
||||||
|
warnings.append(InputWarning(str(path), f"skipped conversation at index {index}: expected object"))
|
||||||
|
continue
|
||||||
|
messages, format_hint = conversation_messages(conversation)
|
||||||
|
if not messages:
|
||||||
|
warnings.append(InputWarning(str(path), f"skipped conversation at index {index}: no text messages found"))
|
||||||
|
continue
|
||||||
|
title = conversation_title(conversation, index)
|
||||||
|
source_id = conversation.get("id") or conversation.get("uuid") or conversation.get("conversation_id")
|
||||||
|
text_digest = sha256_text("\n".join(f"{msg['role']}:{msg['text']}" for msg in messages))
|
||||||
|
metadata: dict[str, Any] = {
|
||||||
|
"source_path": str(path),
|
||||||
|
"source_index": index,
|
||||||
|
"source_format": format_hint,
|
||||||
|
"message_count": len(messages),
|
||||||
|
"text_sha256": text_digest,
|
||||||
|
"content_included": False,
|
||||||
|
}
|
||||||
|
if source_id is not None:
|
||||||
|
metadata["source_id"] = str(source_id)
|
||||||
|
for key in ("create_time", "created_at", "update_time", "updated_at"):
|
||||||
|
timestamp = normalize_timestamp(conversation.get(key))
|
||||||
|
if timestamp:
|
||||||
|
metadata[f"source_{key}"] = timestamp
|
||||||
|
item: dict[str, Any] = {
|
||||||
|
"id": stable_id("conversation", source_name, path, source_id or index, text_digest),
|
||||||
|
"kind": "conversation_thread",
|
||||||
|
"title": title,
|
||||||
|
"source": source_name,
|
||||||
|
"metadata": metadata,
|
||||||
|
}
|
||||||
|
if include_content:
|
||||||
|
if len(messages) > max_messages:
|
||||||
|
warnings.append(
|
||||||
|
InputWarning(
|
||||||
|
str(path),
|
||||||
|
f"skipped conversation content at index {index}: over {max_messages} messages",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
item["messages"] = messages
|
||||||
|
item["metadata"]["content_included"] = True
|
||||||
|
items.append(item)
|
||||||
|
return items, warnings
|
||||||
|
|
||||||
|
|
||||||
|
def parse_skill_frontmatter(text: str) -> dict[str, Any]:
|
||||||
|
if not text.startswith("---"):
|
||||||
|
return {}
|
||||||
|
end = text.find("\n---", 3)
|
||||||
|
if end < 0:
|
||||||
|
return {}
|
||||||
|
frontmatter: dict[str, Any] = {}
|
||||||
|
for line in text[3:end].strip().splitlines():
|
||||||
|
if not line.strip() or line.lstrip().startswith("#") or ":" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip().strip('"').strip("'")
|
||||||
|
if key:
|
||||||
|
frontmatter[key] = value
|
||||||
|
return frontmatter
|
||||||
|
|
||||||
|
|
||||||
|
def collect_skill_dir(path: Path, source_name: str) -> tuple[list[dict[str, Any]], list[InputWarning]]:
|
||||||
|
warnings: list[InputWarning] = []
|
||||||
|
if path.is_symlink():
|
||||||
|
return [], [InputWarning(str(path), "skills path is a symlink; skipped")]
|
||||||
|
if not path.exists():
|
||||||
|
return [], [InputWarning(str(path), "skills directory does not exist")]
|
||||||
|
if not path.is_dir():
|
||||||
|
return [], [InputWarning(str(path), "skills path is not a directory")]
|
||||||
|
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for skill_path in sorted(path.rglob("SKILL.md")):
|
||||||
|
if skill_path.is_symlink():
|
||||||
|
warnings.append(InputWarning(str(skill_path), "skipped symlinked skill file"))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
text = skill_path.read_text(encoding="utf-8")
|
||||||
|
except Exception as exc:
|
||||||
|
warnings.append(InputWarning(str(skill_path), f"could not read skill: {exc}"))
|
||||||
|
continue
|
||||||
|
frontmatter = parse_skill_frontmatter(text)
|
||||||
|
name = str(frontmatter.get("name") or skill_path.parent.name).strip() or skill_path.parent.name
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": stable_id("skill", source_name, skill_path, sha256_text(text)),
|
||||||
|
"kind": "skill",
|
||||||
|
"name": name,
|
||||||
|
"category": str(frontmatter.get("category") or "general"),
|
||||||
|
"source": source_name,
|
||||||
|
"format": "SKILL.md",
|
||||||
|
"content": text,
|
||||||
|
"metadata": {
|
||||||
|
"source_path": str(skill_path),
|
||||||
|
"sha256": sha256_text(text),
|
||||||
|
"frontmatter": frontmatter,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items, warnings
|
||||||
|
|
||||||
|
|
||||||
|
def looks_textual(path: Path) -> bool:
|
||||||
|
if path.suffix.lower() in TEXT_EXTENSIONS:
|
||||||
|
return True
|
||||||
|
guessed, _ = mimetypes.guess_type(str(path))
|
||||||
|
return bool(guessed and (guessed.startswith("text/") or guessed in {"application/json"}))
|
||||||
|
|
||||||
|
|
||||||
|
def iter_archive_dir(path: Path) -> Iterable[Path | InputWarning]:
|
||||||
|
try:
|
||||||
|
children = sorted(path.iterdir())
|
||||||
|
except Exception as exc:
|
||||||
|
yield InputWarning(str(path), f"could not scan archive directory: {exc}")
|
||||||
|
return
|
||||||
|
for child in children:
|
||||||
|
if child.is_symlink():
|
||||||
|
yield InputWarning(str(child), "skipped symlinked archive path")
|
||||||
|
continue
|
||||||
|
if child.is_file():
|
||||||
|
yield child
|
||||||
|
elif child.is_dir():
|
||||||
|
yield from iter_archive_dir(child)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_archive_files(paths: Iterable[Path]) -> Iterable[Path | InputWarning]:
|
||||||
|
for path in paths:
|
||||||
|
if path.is_symlink():
|
||||||
|
yield InputWarning(str(path), "skipped symlinked archive path")
|
||||||
|
continue
|
||||||
|
if path.is_file():
|
||||||
|
yield path
|
||||||
|
elif path.is_dir():
|
||||||
|
yield from iter_archive_dir(path)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_archive_paths(
|
||||||
|
paths: list[Path],
|
||||||
|
source_name: str,
|
||||||
|
*,
|
||||||
|
include_content: bool = False,
|
||||||
|
max_bytes: int = 256_000,
|
||||||
|
) -> tuple[list[dict[str, Any]], list[InputWarning]]:
|
||||||
|
warnings: list[InputWarning] = []
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
existing_paths: list[Path] = []
|
||||||
|
for path in paths:
|
||||||
|
if path.is_symlink():
|
||||||
|
warnings.append(InputWarning(str(path), "archive path is a symlink; skipped"))
|
||||||
|
continue
|
||||||
|
if not path.exists():
|
||||||
|
warnings.append(InputWarning(str(path), "archive path does not exist"))
|
||||||
|
continue
|
||||||
|
if not path.is_file() and not path.is_dir():
|
||||||
|
warnings.append(InputWarning(str(path), "archive path is not a file or directory"))
|
||||||
|
continue
|
||||||
|
existing_paths.append(path)
|
||||||
|
|
||||||
|
for entry in iter_archive_files(existing_paths):
|
||||||
|
if isinstance(entry, InputWarning):
|
||||||
|
warnings.append(entry)
|
||||||
|
continue
|
||||||
|
path = entry
|
||||||
|
if not looks_textual(path):
|
||||||
|
warnings.append(InputWarning(str(path), "skipped non-text archive file"))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
st = path.stat()
|
||||||
|
except Exception as exc:
|
||||||
|
warnings.append(InputWarning(str(path), f"could not stat archive file: {exc}"))
|
||||||
|
continue
|
||||||
|
size = st.st_size
|
||||||
|
try:
|
||||||
|
file_hash = sha256_path(path)
|
||||||
|
except Exception as exc:
|
||||||
|
warnings.append(InputWarning(str(path), f"could not hash archive file: {exc}"))
|
||||||
|
continue
|
||||||
|
if include_content and size > max_bytes:
|
||||||
|
warnings.append(InputWarning(str(path), f"skipped archive content over {max_bytes} bytes"))
|
||||||
|
archive_item: dict[str, Any] = {
|
||||||
|
"id": stable_id("archive", source_name, path, file_hash),
|
||||||
|
"kind": "archive_document",
|
||||||
|
"title": path.name,
|
||||||
|
"source": source_name,
|
||||||
|
"metadata": {
|
||||||
|
"source_path": str(path),
|
||||||
|
"size_bytes": size,
|
||||||
|
"sha256": file_hash,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if include_content and size <= max_bytes:
|
||||||
|
try:
|
||||||
|
archive_item["content"] = path.read_text(encoding="utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
archive_item["content"] = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
archive_item["metadata"]["decoded_with_replacement"] = True
|
||||||
|
items.append(archive_item)
|
||||||
|
return items, warnings
|
||||||
|
|
||||||
|
|
||||||
|
def build_manifest(args) -> dict[str, Any]:
|
||||||
|
warnings: list[InputWarning] = []
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for path in args.memory_json:
|
||||||
|
collected, got_warnings = collect_memory_json(path, args.source_name)
|
||||||
|
items.extend(collected)
|
||||||
|
warnings.extend(got_warnings)
|
||||||
|
|
||||||
|
for path in args.skills_dir:
|
||||||
|
collected, got_warnings = collect_skill_dir(path, args.source_name)
|
||||||
|
items.extend(collected)
|
||||||
|
warnings.extend(got_warnings)
|
||||||
|
|
||||||
|
for path in args.conversation_json:
|
||||||
|
collected, got_warnings = collect_conversation_json(
|
||||||
|
path,
|
||||||
|
args.source_name,
|
||||||
|
include_content=args.include_conversation_content,
|
||||||
|
max_messages=args.max_conversation_messages,
|
||||||
|
)
|
||||||
|
items.extend(collected)
|
||||||
|
warnings.extend(got_warnings)
|
||||||
|
|
||||||
|
if args.archive:
|
||||||
|
collected, got_warnings = collect_archive_paths(
|
||||||
|
args.archive,
|
||||||
|
args.source_name,
|
||||||
|
include_content=args.include_archive_content,
|
||||||
|
max_bytes=args.max_archive_bytes,
|
||||||
|
)
|
||||||
|
items.extend(collected)
|
||||||
|
warnings.extend(got_warnings)
|
||||||
|
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for item in items:
|
||||||
|
counts[item["kind"]] = counts.get(item["kind"], 0) + 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema_version": SCHEMA_VERSION,
|
||||||
|
"generated_at": utc_now_iso(),
|
||||||
|
"source": {
|
||||||
|
"name": args.source_name,
|
||||||
|
"kind": args.source_kind,
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"item_count": len(items),
|
||||||
|
"counts_by_kind": counts,
|
||||||
|
"warning_count": len(warnings),
|
||||||
|
},
|
||||||
|
"items": items,
|
||||||
|
"warnings": [{"path": warning.path, "message": warning.message} for warning in warnings],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str] | None = None):
|
||||||
|
parser = argparse.ArgumentParser(description="Build a neutral Odysseus agent migration manifest.")
|
||||||
|
parser.add_argument("--source-name", default="agent-export", help="Human-readable source name.")
|
||||||
|
parser.add_argument("--source-kind", default="generic", help="Source adapter kind, e.g. generic, openclaw, hermes.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--memory-json",
|
||||||
|
action="append",
|
||||||
|
type=Path,
|
||||||
|
default=[],
|
||||||
|
help="JSON memory export. May be a list, or an object containing memories/items/data.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--skills-dir",
|
||||||
|
action="append",
|
||||||
|
type=Path,
|
||||||
|
default=[],
|
||||||
|
help="Directory containing SKILL.md files. Scanned recursively.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--archive",
|
||||||
|
action="append",
|
||||||
|
type=Path,
|
||||||
|
default=[],
|
||||||
|
help="Text/Markdown/JSON file or directory to preserve as archive documents.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--conversation-json",
|
||||||
|
action="append",
|
||||||
|
type=Path,
|
||||||
|
default=[],
|
||||||
|
help="Conversation export JSON. Supports generic message lists and ChatGPT-style conversations.json.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--include-archive-content",
|
||||||
|
action="store_true",
|
||||||
|
help="Embed archive document content in the manifest. By default only metadata is included.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-archive-bytes",
|
||||||
|
type=int,
|
||||||
|
default=256_000,
|
||||||
|
help="Maximum bytes to embed per archive file when --include-archive-content is used.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--include-conversation-content",
|
||||||
|
action="store_true",
|
||||||
|
help="Embed normalized conversation messages. By default only thread metadata is included.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-conversation-messages",
|
||||||
|
type=int,
|
||||||
|
default=2000,
|
||||||
|
help="Maximum messages to embed per conversation when --include-conversation-content is used.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--output", type=Path, help="Write manifest JSON to this path instead of stdout.")
|
||||||
|
parser.add_argument("--compact", action="store_true", help="Write compact JSON without indentation.")
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = parse_args(argv)
|
||||||
|
manifest = build_manifest(args)
|
||||||
|
text = json.dumps(manifest, ensure_ascii=False, sort_keys=True, separators=(",", ":")) if args.compact else (
|
||||||
|
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
||||||
|
)
|
||||||
|
if args.output:
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.output.write_text(text, encoding="utf-8")
|
||||||
|
else:
|
||||||
|
sys.stdout.write(text)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+64
-13
@@ -19,22 +19,32 @@ GPU_BANDWIDTH = {
|
|||||||
"6950 xt": 576, "6900 xt": 512, "6800 xt": 512, "6800": 512, "6700 xt": 384, "6600 xt": 256, "6600": 224,
|
"6950 xt": 576, "6900 xt": 512, "6800 xt": 512, "6800": 512, "6700 xt": 384, "6600 xt": 256, "6600": 224,
|
||||||
"mi300x": 5300, "mi300": 5300, "mi250x": 3277, "mi250": 3277, "mi210": 1638, "mi100": 1229,
|
"mi300x": 5300, "mi300": 5300, "mi250x": 3277, "mi250": 3277, "mi210": 1638, "mi100": 1229,
|
||||||
"9070 xt": 624, "9070": 488, "9060 xt": 322, "9060": 322,
|
"9070 xt": 624, "9070": 488, "9060 xt": 322, "9060": 322,
|
||||||
# Apple Silicon unified-memory bandwidth (GB/s). Keyed off the chip name
|
|
||||||
# reported by sysctl machdep.cpu.brand_string (e.g. "Apple M4 Max"). Listed
|
|
||||||
# before the bare "m_" keys matters less than length-sorting (done below),
|
|
||||||
# which guarantees "m4 max" is tried before "m4".
|
|
||||||
"m1 ultra": 800, "m1 max": 400, "m1 pro": 200, "m1": 68,
|
|
||||||
"m2 ultra": 800, "m2 max": 400, "m2 pro": 200, "m2": 100,
|
|
||||||
"m3 ultra": 800, "m3 max": 300, "m3 pro": 150, "m3": 100,
|
|
||||||
"m4 max": 546, "m4 pro": 273, "m4": 120,
|
|
||||||
"m5 max": 546, "m5 pro": 273, "m5": 150,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Pre-sort keys by length descending for correct substring matching
|
# Pre-sort keys by length descending for correct substring matching
|
||||||
_BW_KEYS_SORTED = sorted(GPU_BANDWIDTH.keys(), key=len, reverse=True)
|
_BW_KEYS_SORTED = sorted(GPU_BANDWIDTH.keys(), key=len, reverse=True)
|
||||||
|
|
||||||
# metal: backstop for Apple Silicon chips not in GPU_BANDWIDTH (e.g. a future
|
# Apple Silicon unified-memory bandwidth (GB/s). For chip families with both
|
||||||
# M5) — the named chips above take the accurate bandwidth path instead.
|
# binned and full variants under the same "Apple Mx Max" brand string, prefer
|
||||||
|
# GPU core count when hardware detection provides it; otherwise fall back to the
|
||||||
|
# conservative tier so speed estimates do not over-promise.
|
||||||
|
APPLE_BANDWIDTH_FIXED = {
|
||||||
|
"m1 ultra": 800, "m1 max": 400, "m1 pro": 200, "m1": 68,
|
||||||
|
"m2 ultra": 800, "m2 max": 400, "m2 pro": 200, "m2": 100,
|
||||||
|
"m3 ultra": 800, "m3 pro": 150, "m3": 100,
|
||||||
|
"m4 pro": 273, "m4": 120,
|
||||||
|
"m5 pro": 307, "m5": 153,
|
||||||
|
}
|
||||||
|
APPLE_BANDWIDTH_BY_CORES = {
|
||||||
|
"m3 max": {30: 300, 40: 400},
|
||||||
|
"m4 max": {32: 410, 40: 546},
|
||||||
|
"m5 max": {32: 460, 40: 614},
|
||||||
|
}
|
||||||
|
_APPLE_FIXED_KEYS_SORTED = sorted(APPLE_BANDWIDTH_FIXED.keys(), key=len, reverse=True)
|
||||||
|
_APPLE_VARIANT_KEYS_SORTED = sorted(APPLE_BANDWIDTH_BY_CORES.keys(), key=len, reverse=True)
|
||||||
|
|
||||||
|
# metal: backstop for Apple Silicon chips not in the explicit tables above
|
||||||
|
# (e.g. a future M6) — use a conservative generic estimate when unknown.
|
||||||
FALLBACK_K = {"cuda": 220, "rocm": 180, "metal": 150, "cpu_x86": 70, "cpu_arm": 90}
|
FALLBACK_K = {"cuda": 220, "rocm": 180, "metal": 150, "cpu_x86": 70, "cpu_arm": 90}
|
||||||
|
|
||||||
USE_CASE_WEIGHTS = {
|
USE_CASE_WEIGHTS = {
|
||||||
@@ -60,10 +70,51 @@ CONTEXT_TARGET = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _lookup_bandwidth(gpu_name):
|
def _lookup_apple_bandwidth(system):
|
||||||
|
gpu_name = system.get("gpu_name")
|
||||||
if not isinstance(gpu_name, str) or not gpu_name:
|
if not isinstance(gpu_name, str) or not gpu_name:
|
||||||
return None
|
return None
|
||||||
gn = gpu_name.lower()
|
gn = gpu_name.lower()
|
||||||
|
|
||||||
|
# Guard against false matches on non-Apple GPUs whose names contain
|
||||||
|
# "m3"/"m4"/"m5" (e.g. NVIDIA Quadro M4 000).
|
||||||
|
if "apple" not in gn:
|
||||||
|
return None
|
||||||
|
|
||||||
|
raw_cores = system.get("gpu_cores")
|
||||||
|
try:
|
||||||
|
gpu_cores = int(raw_cores) if raw_cores is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
gpu_cores = None
|
||||||
|
|
||||||
|
for key in _APPLE_VARIANT_KEYS_SORTED:
|
||||||
|
if key not in gn:
|
||||||
|
continue
|
||||||
|
if gpu_cores in APPLE_BANDWIDTH_BY_CORES[key]:
|
||||||
|
return APPLE_BANDWIDTH_BY_CORES[key][gpu_cores]
|
||||||
|
return min(APPLE_BANDWIDTH_BY_CORES[key].values())
|
||||||
|
|
||||||
|
for key in _APPLE_FIXED_KEYS_SORTED:
|
||||||
|
if key in gn:
|
||||||
|
return APPLE_BANDWIDTH_FIXED[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_bandwidth(system):
|
||||||
|
if isinstance(system, dict):
|
||||||
|
gpu_name = system.get("gpu_name")
|
||||||
|
else:
|
||||||
|
gpu_name = system
|
||||||
|
|
||||||
|
if not isinstance(gpu_name, str) or not gpu_name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(system, dict):
|
||||||
|
bw = _lookup_apple_bandwidth(system)
|
||||||
|
if bw is not None:
|
||||||
|
return bw
|
||||||
|
|
||||||
|
gn = gpu_name.lower()
|
||||||
for key in _BW_KEYS_SORTED:
|
for key in _BW_KEYS_SORTED:
|
||||||
if key in gn:
|
if key in gn:
|
||||||
return GPU_BANDWIDTH[key]
|
return GPU_BANDWIDTH[key]
|
||||||
@@ -84,7 +135,7 @@ def _estimate_speed(model, quant, run_mode, system, offload_frac=0.0):
|
|||||||
"""
|
"""
|
||||||
pb = _active_params_b(model)
|
pb = _active_params_b(model)
|
||||||
is_moe = model.get("is_moe", False)
|
is_moe = model.get("is_moe", False)
|
||||||
bw = _lookup_bandwidth(system.get("gpu_name"))
|
bw = _lookup_bandwidth(system)
|
||||||
backend = system.get("backend", "cpu_x86")
|
backend = system.get("backend", "cpu_x86")
|
||||||
|
|
||||||
if bw and run_mode in ("gpu", "cpu_offload"):
|
if bw and run_mode in ("gpu", "cpu_offload"):
|
||||||
|
|||||||
+127
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
import re
|
import re
|
||||||
@@ -335,6 +336,37 @@ def _detect_apple_silicon():
|
|||||||
if total_gb <= 0:
|
if total_gb <= 0:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _parse_apple_gpu_cores(text):
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError):
|
||||||
|
data = None
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for gpu in data.get("SPDisplaysDataType") or []:
|
||||||
|
if not isinstance(gpu, dict):
|
||||||
|
continue
|
||||||
|
model = str(gpu.get("sppci_model") or gpu.get("_name") or "")
|
||||||
|
if "apple" not in model.lower():
|
||||||
|
continue
|
||||||
|
cores = gpu.get("sppci_cores")
|
||||||
|
try:
|
||||||
|
return int(str(cores).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
m = re.search(r"Total Number of Cores:\s*(\d+)", text)
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
return int(m.group(1))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
gpu_cores = _parse_apple_gpu_cores(_run(["system_profiler", "SPDisplaysDataType", "-json"]))
|
||||||
|
if gpu_cores is None:
|
||||||
|
gpu_cores = _parse_apple_gpu_cores(_run(["system_profiler", "SPDisplaysDataType"]))
|
||||||
|
|
||||||
# Usable GPU budget. macOS lets Metal use most of unified memory, but the
|
# Usable GPU budget. macOS lets Metal use most of unified memory, but the
|
||||||
# default working-set limit scales with RAM: small machines have to keep
|
# default working-set limit scales with RAM: small machines have to keep
|
||||||
# more back for the OS + app. These fractions track Apple's
|
# more back for the OS + app. These fractions track Apple's
|
||||||
@@ -357,7 +389,7 @@ def _detect_apple_silicon():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
gpu = {"index": 0, "name": brand, "vram_gb": vram_gb}
|
gpu = {"index": 0, "name": brand, "vram_gb": vram_gb}
|
||||||
return {
|
info = {
|
||||||
"gpu_name": brand,
|
"gpu_name": brand,
|
||||||
"gpu_vram_gb": vram_gb,
|
"gpu_vram_gb": vram_gb,
|
||||||
"gpu_count": 1,
|
"gpu_count": 1,
|
||||||
@@ -369,6 +401,9 @@ def _detect_apple_silicon():
|
|||||||
# separate pool — downstream fit logic uses this to avoid double-budgeting.
|
# separate pool — downstream fit logic uses this to avoid double-budgeting.
|
||||||
"unified_memory": True,
|
"unified_memory": True,
|
||||||
}
|
}
|
||||||
|
if gpu_cores is not None:
|
||||||
|
info["gpu_cores"] = gpu_cores
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
def _read_file(path):
|
def _read_file(path):
|
||||||
@@ -611,6 +646,93 @@ def _cache_key(host: str, ssh_port: str, platform_name: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_containerized():
|
||||||
|
"""Best-effort check for whether the local Odysseus process is running in a container."""
|
||||||
|
if _remote_host:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if os.path.exists("/.dockerenv"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open("/proc/1/cgroup", encoding="utf-8", errors="replace") as f:
|
||||||
|
text = f.read().lower()
|
||||||
|
return any(marker in text for marker in ("docker", "containerd", "kubepods"))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _hardware_visibility_warning(result):
|
||||||
|
"""Return a non-blocking UX warning when detected hardware may only be container-visible."""
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if result.get("manual_hardware"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not result.get("containerized"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if result.get("gpu_error"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not result.get("has_gpu"):
|
||||||
|
return {
|
||||||
|
"code": "container_no_gpu_visible",
|
||||||
|
"severity": "warning",
|
||||||
|
"title": "No GPU visible inside Docker",
|
||||||
|
"message": (
|
||||||
|
"Cookbook is scanning hardware from inside the Odysseus container. "
|
||||||
|
"If your host has a GPU, Docker may not be exposing it to the container, "
|
||||||
|
"so model recommendations may be CPU-only or too conservative."
|
||||||
|
),
|
||||||
|
"actions": [
|
||||||
|
"manual_hardware",
|
||||||
|
"rescan",
|
||||||
|
"copy_diagnostics",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
total_ram = result.get("total_ram_gb") or 0
|
||||||
|
if total_ram and total_ram <= 8:
|
||||||
|
return {
|
||||||
|
"code": "container_low_ram_visible",
|
||||||
|
"severity": "info",
|
||||||
|
"title": "Container-visible RAM may be lower than host RAM",
|
||||||
|
"message": (
|
||||||
|
"Cookbook is seeing the RAM available inside the container. "
|
||||||
|
"If your host has more memory, validate host RAM separately or use Manual Hardware."
|
||||||
|
),
|
||||||
|
"actions": [
|
||||||
|
"manual_hardware",
|
||||||
|
"rescan",
|
||||||
|
"copy_diagnostics",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_probe_context(result, host=""):
|
||||||
|
"""Attach probe-scope metadata and optional hardware visibility warning."""
|
||||||
|
if not isinstance(result, dict) or result.get("error"):
|
||||||
|
return result
|
||||||
|
|
||||||
|
is_remote = bool(host)
|
||||||
|
containerized = False if is_remote else _is_containerized()
|
||||||
|
|
||||||
|
result["probe_scope"] = "remote" if is_remote else ("container" if containerized else "native")
|
||||||
|
result["containerized"] = containerized
|
||||||
|
|
||||||
|
warning = _hardware_visibility_warning(result)
|
||||||
|
if warning:
|
||||||
|
result["hardware_visibility_warning"] = warning
|
||||||
|
else:
|
||||||
|
result.pop("hardware_visibility_warning", None)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def detect_system(host="", ssh_port="", platform="", fresh=False):
|
def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||||
"""Detect system hardware: RAM, CPU, GPU. Cached per host (hardware rarely
|
"""Detect system hardware: RAM, CPU, GPU. Cached per host (hardware rarely
|
||||||
changes, and probing a remote host over SSH is slow). Pass fresh=True to
|
changes, and probing a remote host over SSH is slow). Pass fresh=True to
|
||||||
@@ -635,6 +757,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
|||||||
if _remote_platform == "windows" and _remote_host:
|
if _remote_platform == "windows" and _remote_host:
|
||||||
result = _detect_windows()
|
result = _detect_windows()
|
||||||
if result:
|
if result:
|
||||||
|
result = _attach_probe_context(result, host=host)
|
||||||
_remote_host = None
|
_remote_host = None
|
||||||
_remote_platform = None
|
_remote_platform = None
|
||||||
_cache_by_host[cache_key] = (now, result)
|
_cache_by_host[cache_key] = (now, result)
|
||||||
@@ -653,6 +776,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
|||||||
if not _remote_host and os.name == "nt":
|
if not _remote_host and os.name == "nt":
|
||||||
result = _detect_windows()
|
result = _detect_windows()
|
||||||
if result:
|
if result:
|
||||||
|
result = _attach_probe_context(result, host=host)
|
||||||
_cache_by_host[cache_key] = (now, result)
|
_cache_by_host[cache_key] = (now, result)
|
||||||
return result
|
return result
|
||||||
# PowerShell probe failed entirely — fall through to the generic path
|
# PowerShell probe failed entirely — fall through to the generic path
|
||||||
@@ -683,6 +807,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
|||||||
"gpu_name": gpu_info["gpu_name"],
|
"gpu_name": gpu_info["gpu_name"],
|
||||||
"gpu_vram_gb": gpu_info["gpu_vram_gb"],
|
"gpu_vram_gb": gpu_info["gpu_vram_gb"],
|
||||||
"gpu_count": gpu_info["gpu_count"],
|
"gpu_count": gpu_info["gpu_count"],
|
||||||
|
"gpu_cores": gpu_info.get("gpu_cores"),
|
||||||
"gpus": gpu_info.get("gpus", []),
|
"gpus": gpu_info.get("gpus", []),
|
||||||
"gpu_groups": gpu_info.get("gpu_groups", []),
|
"gpu_groups": gpu_info.get("gpu_groups", []),
|
||||||
"homogeneous": gpu_info.get("homogeneous", True),
|
"homogeneous": gpu_info.get("homogeneous", True),
|
||||||
@@ -714,6 +839,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
|||||||
"gpu_error": _last_gpu_error,
|
"gpu_error": _last_gpu_error,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result = _attach_probe_context(result, host=host)
|
||||||
_remote_host = None
|
_remote_host = None
|
||||||
_remote_platform = None
|
_remote_platform = None
|
||||||
_cache_by_host[cache_key] = (now, result)
|
_cache_by_host[cache_key] = (now, result)
|
||||||
|
|||||||
@@ -188,12 +188,18 @@ def compute_serve_profiles(system, model, serve_weights_gb=None, serve_quant=Non
|
|||||||
# Shrink context if even the chosen KV won't fit alongside weights.
|
# Shrink context if even the chosen KV won't fit alongside weights.
|
||||||
# Start from the smaller of the profile's target and the model's limit.
|
# Start from the smaller of the profile's target and the model's limit.
|
||||||
cur_ctx = min(ctx, model_ctx_max)
|
cur_ctx = min(ctx, model_ctx_max)
|
||||||
while cur_ctx >= 8192:
|
# Floor the context-shrink loop at 8192, but never above the model's own
|
||||||
|
# trained limit. A model with a sub-8192 context (e.g. a 2048-token
|
||||||
|
# SmolLM) starts below 8192, so a hard-coded 8192 guard skipped the loop
|
||||||
|
# entirely and produced NO profile — the serve UI then fell back to
|
||||||
|
# manual flags even though the model fits the GPU trivially.
|
||||||
|
ctx_floor = min(8192, model_ctx_max)
|
||||||
|
while cur_ctx >= ctx_floor:
|
||||||
kv = _kv_gb(model, cur_ctx, kv_type)
|
kv = _kv_gb(model, cur_ctx, kv_type)
|
||||||
n_cpu_moe, fits = _cpu_moe_for_budget(model, quant, kv, budget, fixed_gb=serve_weights_gb)
|
n_cpu_moe, fits = _cpu_moe_for_budget(model, quant, kv, budget, fixed_gb=serve_weights_gb)
|
||||||
est = _weights_gb(model, quant, serve_weights_gb) + kv + 0.6
|
est = _weights_gb(model, quant, serve_weights_gb) + kv + 0.6
|
||||||
# If a non-MoE model can't fit even fully offloaded, try less context.
|
# If a non-MoE model can't fit even fully offloaded, try less context.
|
||||||
if model.get("is_moe") or fits or cur_ctx <= 8192:
|
if model.get("is_moe") or fits or cur_ctx <= ctx_floor:
|
||||||
profiles.append({
|
profiles.append({
|
||||||
"key": key,
|
"key": key,
|
||||||
"label": label,
|
"label": label,
|
||||||
|
|||||||
@@ -66,42 +66,58 @@ def _has_duplicate_title(skills, title: str) -> bool:
|
|||||||
def _extract_json_object(text: str) -> Optional[dict]:
|
def _extract_json_object(text: str) -> Optional[dict]:
|
||||||
"""Best-effort extraction of a JSON object from an LLM response.
|
"""Best-effort extraction of a JSON object from an LLM response.
|
||||||
|
|
||||||
The response may be wrapped in code fences or surrounded by prose, and some
|
The response may be wrapped in code fences or surrounded by prose. Uses
|
||||||
models emit a stray brace in the prose before the real object
|
json.JSONDecoder().raw_decode() to locate the boundaries of complete JSON
|
||||||
(e.g. "uses {placeholder} then {...}"). Slicing first-'{' .. last-'}' then
|
objects starting at each '{' position. Nested objects are filtered out to
|
||||||
grabs an unparseable span and the skill is silently lost. Try the whole
|
keep only top-level candidates. If multiple non-overlapping valid JSON
|
||||||
string first, then each '{' start position in turn, returning the first
|
objects are found, it is treated as ambiguous and returns None. Otherwise,
|
||||||
candidate that parses to a JSON object (dict). Returns None if none do.
|
returns the single valid candidate dictionary.
|
||||||
"""
|
"""
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
s = text.strip()
|
s = text.strip()
|
||||||
if s.startswith("```"):
|
if s.startswith("```"):
|
||||||
s = s.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
s = s.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
||||||
end = s.rfind("}")
|
|
||||||
if end == -1:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _as_dict(candidate):
|
decoder = json.JSONDecoder()
|
||||||
try:
|
candidates = []
|
||||||
obj = json.loads(candidate)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
return None
|
|
||||||
return obj if isinstance(obj, dict) else None
|
|
||||||
|
|
||||||
# The clean, common case: the whole (de-fenced) string is the object.
|
|
||||||
obj = _as_dict(s)
|
|
||||||
if obj is not None:
|
|
||||||
return obj
|
|
||||||
# Otherwise scan each '{' candidate up to the last '}'.
|
|
||||||
start = s.find("{")
|
start = s.find("{")
|
||||||
while 0 <= start < end:
|
while start != -1:
|
||||||
obj = _as_dict(s[start : end + 1])
|
try:
|
||||||
if obj is not None:
|
obj, idx = decoder.raw_decode(s[start:])
|
||||||
return obj
|
end_pos = start + idx
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
candidates.append((start, end_pos, obj))
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
pass
|
||||||
start = s.find("{", start + 1)
|
start = s.find("{", start + 1)
|
||||||
|
|
||||||
|
# Filter out nested candidates to identify top-level dictionaries
|
||||||
|
top_level = []
|
||||||
|
for c in candidates:
|
||||||
|
is_nested = False
|
||||||
|
for other in candidates:
|
||||||
|
if other == c:
|
||||||
|
continue
|
||||||
|
if other[0] <= c[0] and c[1] <= other[1]:
|
||||||
|
is_nested = True
|
||||||
|
break
|
||||||
|
if not is_nested:
|
||||||
|
top_level.append(c)
|
||||||
|
|
||||||
|
if not top_level:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
if len(top_level) > 1:
|
||||||
|
logger.debug(
|
||||||
|
"[skill-extract] Found multiple non-overlapping JSON objects: %s",
|
||||||
|
[item[2].get("title") for item in top_level]
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return top_level[0][2]
|
||||||
|
|
||||||
|
|
||||||
async def maybe_extract_skill(
|
async def maybe_extract_skill(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -603,7 +603,6 @@ class SkillsManager:
|
|||||||
escalation) — those are work-in-progress and pollute the
|
escalation) — those are work-in-progress and pollute the
|
||||||
prompt with half-finished procedures.
|
prompt with half-finished procedures.
|
||||||
"""
|
"""
|
||||||
active_toolsets = active_toolsets or []
|
|
||||||
out = []
|
out = []
|
||||||
for s in self.load(owner=owner):
|
for s in self.load(owner=owner):
|
||||||
status = s.get("status")
|
status = s.get("status")
|
||||||
@@ -617,13 +616,16 @@ class SkillsManager:
|
|||||||
# Platform gating
|
# Platform gating
|
||||||
if platform and s.get("platforms") and platform not in s["platforms"]:
|
if platform and s.get("platforms") and platform not in s["platforms"]:
|
||||||
continue
|
continue
|
||||||
# requires_toolsets: hide unless every required toolset is active
|
# requires_toolsets: hide unless every required toolset is active.
|
||||||
|
# active_toolsets=None means the caller doesn't know the active
|
||||||
|
# set (API listings, chat preface) — don't gate in that case;
|
||||||
|
# only an explicit list filters.
|
||||||
req = s.get("requires_toolsets") or []
|
req = s.get("requires_toolsets") or []
|
||||||
if req and not all(t in active_toolsets for t in req):
|
if req and active_toolsets is not None and not all(t in active_toolsets for t in req):
|
||||||
continue
|
continue
|
||||||
# fallback_for_toolsets: hide when any of those toolsets is active
|
# fallback_for_toolsets: hide when any of those toolsets is active
|
||||||
fb = s.get("fallback_for_toolsets") or []
|
fb = s.get("fallback_for_toolsets") or []
|
||||||
if fb and any(t in active_toolsets for t in fb):
|
if fb and active_toolsets and any(t in active_toolsets for t in fb):
|
||||||
continue
|
continue
|
||||||
out.append({
|
out.append({
|
||||||
"name": s["name"],
|
"name": s["name"],
|
||||||
|
|||||||
@@ -64,20 +64,40 @@ def is_youtube_url(url: str) -> bool:
|
|||||||
return "youtube.com" in url or "youtu.be" in url
|
return "youtube.com" in url or "youtu.be" in url
|
||||||
|
|
||||||
|
|
||||||
|
# youtube.com-shaped hosts. music.youtube.com serves the same /watch and
|
||||||
|
# /shorts paths, so links shared from YouTube Music must resolve too.
|
||||||
|
_YT_HOSTS = ("www.youtube.com", "youtube.com", "m.youtube.com", "music.youtube.com")
|
||||||
|
# Path prefixes whose first following segment is the video id. Covers the
|
||||||
|
# /embed/ player, Shorts (/shorts/), live streams (/live/), and the legacy
|
||||||
|
# /v/ embed — all of which `is_youtube_url` already treats as YouTube, so
|
||||||
|
# they must be extractable or the link is silently dropped (neither web-fetched
|
||||||
|
# nor transcript-fetched) by the chat pipeline.
|
||||||
|
_YT_PATH_PREFIXES = ("/embed/", "/shorts/", "/live/", "/v/")
|
||||||
|
|
||||||
|
|
||||||
def extract_youtube_id(url: str) -> Optional[str]:
|
def extract_youtube_id(url: str) -> Optional[str]:
|
||||||
"""Extract YouTube video ID from various URL formats."""
|
"""Extract a YouTube video ID from the common URL shapes:
|
||||||
|
watch?v=, youtu.be/<id>, /embed/<id>, /shorts/<id>, /live/<id>, /v/<id>,
|
||||||
|
across youtube.com / m.youtube.com / music.youtube.com / youtu.be."""
|
||||||
if not isinstance(url, str):
|
if not isinstance(url, str):
|
||||||
return None
|
return None
|
||||||
parsed = urllib.parse.urlparse(url)
|
parsed = urllib.parse.urlparse(url)
|
||||||
if parsed.hostname in ("www.youtube.com", "youtube.com", "m.youtube.com"):
|
host = (parsed.hostname or "").lower()
|
||||||
|
if host in _YT_HOSTS:
|
||||||
if parsed.path == "/watch":
|
if parsed.path == "/watch":
|
||||||
params = urllib.parse.parse_qs(parsed.query)
|
params = urllib.parse.parse_qs(parsed.query)
|
||||||
if "v" in params:
|
if params.get("v"):
|
||||||
return params["v"][0]
|
return params["v"][0]
|
||||||
elif parsed.path.startswith("/embed/"):
|
else:
|
||||||
return parsed.path.split("/")[-1]
|
for prefix in _YT_PATH_PREFIXES:
|
||||||
elif parsed.hostname == "youtu.be":
|
if parsed.path.startswith(prefix):
|
||||||
return parsed.path[1:]
|
vid = parsed.path[len(prefix):].split("/")[0]
|
||||||
|
if vid:
|
||||||
|
return vid
|
||||||
|
elif host == "youtu.be":
|
||||||
|
vid = parsed.path.lstrip("/").split("/")[0]
|
||||||
|
if vid:
|
||||||
|
return vid
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -170,6 +190,8 @@ def format_transcript_for_context(
|
|||||||
if segments:
|
if segments:
|
||||||
ctx += "Timestamped Transcript:\n"
|
ctx += "Timestamped Transcript:\n"
|
||||||
for seg in segments:
|
for seg in segments:
|
||||||
|
if not isinstance(seg, dict):
|
||||||
|
continue
|
||||||
ctx += f"[{seg['timestamp']}] {seg['text']}\n"
|
ctx += f"[{seg['timestamp']}] {seg['text']}\n"
|
||||||
# Check length — fall back to plain text if too long
|
# Check length — fall back to plain text if too long
|
||||||
if len(ctx) > 12000:
|
if len(ctx) > 12000:
|
||||||
@@ -202,15 +224,24 @@ async def fetch_youtube_comments(
|
|||||||
f"https://www.youtube.com/watch?v={video_id}",
|
f"https://www.youtube.com/watch?v={video_id}",
|
||||||
]
|
]
|
||||||
|
|
||||||
proc = await asyncio.wait_for(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
asyncio.create_subprocess_exec(
|
|
||||||
*cmd,
|
*cmd,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
),
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
)
|
||||||
stdout, stderr = await proc.communicate()
|
# Bound the wait on the process actually finishing, not on spawning it.
|
||||||
|
# create_subprocess_exec returns as soon as the child starts, so wrapping
|
||||||
|
# it in wait_for never enforces the timeout — proc.communicate() is the
|
||||||
|
# blocking step. Kill and reap the child if it overruns so it does not
|
||||||
|
# linger after we return.
|
||||||
|
try:
|
||||||
|
stdout, stderr = await asyncio.wait_for(
|
||||||
|
proc.communicate(), timeout=timeout
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
proc.kill()
|
||||||
|
await proc.wait()
|
||||||
|
raise
|
||||||
|
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
return {"success": False, "error": f"yt-dlp failed: {stderr.decode()[:200]}", "comments": []}
|
return {"success": False, "error": f"yt-dlp failed: {stderr.decode()[:200]}", "comments": []}
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ _ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple(
|
|||||||
("ui", "tool or feature toggle request", r"\b(?:disable|enable|turn\s+(?:on|off))\s+(?:the\s+)?(?:shell|search|web|browser|documents?|memory|skills|images?|calendar|email|mail|research|incognito)\b"),
|
("ui", "tool or feature toggle request", r"\b(?:disable|enable|turn\s+(?:on|off))\s+(?:the\s+)?(?:shell|search|web|browser|documents?|memory|skills|images?|calendar|email|mail|research|incognito)\b"),
|
||||||
|
|
||||||
# Deep research jobs, not quick conceptual mentions of research.
|
# Deep research jobs, not quick conceptual mentions of research.
|
||||||
|
("web", "explicit web search request", rf"{_PLEASE}(?:do|run|use|perform|make)\s+(?:a\s+)?(?:web\s+search|search\s+the\s+web)\b.+"),
|
||||||
|
("web", "web lookup imperative request", rf"{_PLEASE}(?:web\s+search|search\s+the\s+web|search\s+online|look\s+up|google)\b.+"),
|
||||||
|
("web", "assistant web lookup request", rf"{_ACTION_QUESTION}(?:web\s+search|search\s+the\s+web|search\s+online|look\s+up|google)\b.+"),
|
||||||
("research", "deep research imperative request", rf"{_PLEASE}(?:research|deep\s+dive|look\s+into|investigate)\s+.+"),
|
("research", "deep research imperative request", rf"{_PLEASE}(?:research|deep\s+dive|look\s+into|investigate)\s+.+"),
|
||||||
("research", "assistant deep research request", rf"{_ACTION_QUESTION}(?:research|do\s+research|deep\s+dive|look\s+into|investigate)\s+.+"),
|
("research", "assistant deep research request", rf"{_ACTION_QUESTION}(?:research|do\s+research|deep\s+dive|look\s+into|investigate)\s+.+"),
|
||||||
|
|
||||||
|
|||||||
+130
-18
@@ -262,6 +262,11 @@ _DOMAIN_RULES = {
|
|||||||
- Use `manage_settings` for preferences and tool enable/disable.
|
- Use `manage_settings` for preferences and tool enable/disable.
|
||||||
- Use named tools over `app_api` when a named wrapper exists.
|
- Use named tools over `app_api` when a named wrapper exists.
|
||||||
- `app_api` is only for safe UI/API actions without a named tool; do not use it for shell, package installs, engine rebuilds, or sensitive auth/admin paths.""",
|
- `app_api` is only for safe UI/API actions without a named tool; do not use it for shell, package installs, engine rebuilds, or sensitive auth/admin paths.""",
|
||||||
|
"contacts": """\
|
||||||
|
## Contacts rules
|
||||||
|
- Use `resolve_contact` to look up a contact's email or phone number by name. Searches the CardDAV address book and sent email history.
|
||||||
|
- Use `manage_contact` to list, add, update, or delete contacts in the address book.
|
||||||
|
- Do NOT use `manage_memory` for contact lookups — contact details live in the address book, not memory.""",
|
||||||
}
|
}
|
||||||
|
|
||||||
_DOMAIN_TOOL_MAP = {
|
_DOMAIN_TOOL_MAP = {
|
||||||
@@ -274,6 +279,7 @@ _DOMAIN_TOOL_MAP = {
|
|||||||
"sessions": {"create_session", "list_sessions", "manage_session", "send_to_session", "search_chats"},
|
"sessions": {"create_session", "list_sessions", "manage_session", "send_to_session", "search_chats"},
|
||||||
"files": {"bash", "python", "read_file", "write_file", "edit_file", "grep", "glob", "ls", "get_workspace"},
|
"files": {"bash", "python", "read_file", "write_file", "edit_file", "grep", "glob", "ls", "get_workspace"},
|
||||||
"settings": {"manage_settings", "manage_endpoints", "manage_mcp", "manage_webhooks", "manage_tokens", "app_api"},
|
"settings": {"manage_settings", "manage_endpoints", "manage_mcp", "manage_webhooks", "manage_tokens", "app_api"},
|
||||||
|
"contacts": {"resolve_contact", "manage_contact"},
|
||||||
}
|
}
|
||||||
|
|
||||||
def _domain_rules_for_tools(tool_names: set) -> list[str]:
|
def _domain_rules_for_tools(tool_names: set) -> list[str]:
|
||||||
@@ -600,7 +606,7 @@ _API_HOSTS = frozenset([
|
|||||||
"api.deepseek.com", "deepseek.com",
|
"api.deepseek.com", "deepseek.com",
|
||||||
"api.together.xyz", "api.fireworks.ai",
|
"api.together.xyz", "api.fireworks.ai",
|
||||||
"api.perplexity.ai", "api.x.ai",
|
"api.perplexity.ai", "api.x.ai",
|
||||||
"ollama.com", "api.venice.ai",
|
"ollama.com", "api.venice.ai", "api.kimi.com",
|
||||||
"api.githubcopilot.com",
|
"api.githubcopilot.com",
|
||||||
# Local OpenAI-compatible endpoints (llama.cpp, vLLM, LM Studio, etc.).
|
# Local OpenAI-compatible endpoints (llama.cpp, vLLM, LM Studio, etc.).
|
||||||
# Without these, `_is_api_model` falls back to keyword sniffing on the
|
# Without these, `_is_api_model` falls back to keyword sniffing on the
|
||||||
@@ -787,6 +793,12 @@ def _classify_agent_request(messages: List[Dict], last_user: str) -> Dict[str, o
|
|||||||
domains.add("documents")
|
domains.add("documents")
|
||||||
if has(r"\b(search|web|google|look up|latest|news|current|weather|forecast|stock price|price of|website|url|https?://|www\.)\b"):
|
if has(r"\b(search|web|google|look up|latest|news|current|weather|forecast|stock price|price of|website|url|https?://|www\.)\b"):
|
||||||
domains.add("web")
|
domains.add("web")
|
||||||
|
if has(
|
||||||
|
r"\b(wyszukaj|wyszukać|wyszukac)\b.*\b(internet|internecie|online|web)\b",
|
||||||
|
r"\b(sprawd[zź]|znajd[zź])\b.*\b(internet|internecie|online|web)\b",
|
||||||
|
r"\b(aktualn\w*|bieżąc\w*|biezac\w*|dzisiaj|teraz)\b.*\b(pogod\w*|temperatur\w*)\b",
|
||||||
|
):
|
||||||
|
domains.add("web")
|
||||||
if has(r"\b(research|deep dive|investigate|look into)\b"):
|
if has(r"\b(research|deep dive|investigate|look into)\b"):
|
||||||
domains.add("web")
|
domains.add("web")
|
||||||
if has(r"\b(open|show|toggle|turn on|turn off|disable|enable|switch model|change model|settings|theme|panel)\b"):
|
if has(r"\b(open|show|toggle|turn on|turn off|disable|enable|switch model|change model|settings|theme|panel)\b"):
|
||||||
@@ -797,6 +809,8 @@ def _classify_agent_request(messages: List[Dict], last_user: str) -> Dict[str, o
|
|||||||
domains.add("files")
|
domains.add("files")
|
||||||
if has(r"\b(endpoint|api token|mcp|webhook|preference|configure|config|setting)\b"):
|
if has(r"\b(endpoint|api token|mcp|webhook|preference|configure|config|setting)\b"):
|
||||||
domains.add("settings")
|
domains.add("settings")
|
||||||
|
if has(r"\b(contact|contacts|phone|phone number|address book|vcard)\b"):
|
||||||
|
domains.add("contacts")
|
||||||
|
|
||||||
low_signal = not continuation and not domains
|
low_signal = not continuation and not domains
|
||||||
return {
|
return {
|
||||||
@@ -1801,18 +1815,21 @@ async def stream_agent_loop(
|
|||||||
logger.info(f"[tool-rag] Using caller-provided relevant_tools ({len(_relevant_tools)} tools)")
|
logger.info(f"[tool-rag] Using caller-provided relevant_tools ({len(_relevant_tools)} tools)")
|
||||||
if not guide_only and not _relevant_tools and bool(_intent.get("low_signal")):
|
if not guide_only and not _relevant_tools and bool(_intent.get("low_signal")):
|
||||||
from src.tool_index import ALWAYS_AVAILABLE
|
from src.tool_index import ALWAYS_AVAILABLE
|
||||||
_relevant_tools = set(ALWAYS_AVAILABLE)
|
|
||||||
if workspace:
|
if workspace:
|
||||||
# An active workspace IS the file-work signal: a vague "look at the
|
# An active workspace IS the file-work signal: a vague "look at the
|
||||||
# project" means explore this folder. Surface only the READ-ONLY file
|
# project" means explore this folder. Surface only the READ-ONLY file
|
||||||
# tools (intersection with the plan-mode read-only allowlist) so the
|
# tools (intersection with the plan-mode read-only allowlist) so the
|
||||||
# agent can investigate; write/shell tools stay out until the request
|
# agent can investigate; write/shell tools stay out until the request
|
||||||
# actually calls for them (RAG retrieval adds those on a real ask).
|
# actually calls for them (RAG retrieval adds those on a real ask).
|
||||||
|
_relevant_tools = set(ALWAYS_AVAILABLE)
|
||||||
from src.tool_security import PLAN_MODE_READONLY_TOOLS
|
from src.tool_security import PLAN_MODE_READONLY_TOOLS
|
||||||
_relevant_tools |= (_DOMAIN_TOOL_MAP["files"] & PLAN_MODE_READONLY_TOOLS)
|
_relevant_tools |= (_DOMAIN_TOOL_MAP["files"] & PLAN_MODE_READONLY_TOOLS)
|
||||||
logger.info("[tool-rag] Low-signal but workspace active; including read-only file tools")
|
logger.info("[tool-rag] Low-signal but workspace active; including read-only file tools")
|
||||||
else:
|
else:
|
||||||
logger.info("[tool-rag] Low-signal agent message; skipping retrieval and using always-available tools only")
|
# Don't short-circuit: fall through to RAG retrieval below.
|
||||||
|
# Non-English queries are flagged low_signal by the English-only
|
||||||
|
# intent classifier, but fastembed retrieval works across languages.
|
||||||
|
logger.info("[tool-rag] Low-signal query; will run RAG retrieval")
|
||||||
if not guide_only and not _relevant_tools:
|
if not guide_only and not _relevant_tools:
|
||||||
try:
|
try:
|
||||||
from src.tool_index import get_tool_index, ALWAYS_AVAILABLE
|
from src.tool_index import get_tool_index, ALWAYS_AVAILABLE
|
||||||
@@ -1887,6 +1904,44 @@ async def stream_agent_loop(
|
|||||||
if _relevant_tools is not None and active_document is not None:
|
if _relevant_tools is not None and active_document is not None:
|
||||||
_relevant_tools.update({"edit_document", "update_document", "suggest_document"})
|
_relevant_tools.update({"edit_document", "update_document", "suggest_document"})
|
||||||
|
|
||||||
|
# The skill index injected by _build_system_prompt tells the model to
|
||||||
|
# call `manage_skills action=view`, and Jaccard-matched skills are pasted
|
||||||
|
# into the prompt as procedures to follow — but neither path goes through
|
||||||
|
# tool selection, so the model can be handed a procedure naming tools
|
||||||
|
# (grep, read_file, ...) that aren't in its schema list. Keep the schemas
|
||||||
|
# in lockstep: manage_skills is callable whenever any skill is indexed,
|
||||||
|
# and a matched skill's declared requires_toolsets ride along with it.
|
||||||
|
if not guide_only and _relevant_tools is not None:
|
||||||
|
try:
|
||||||
|
from services.memory.skills import SkillsManager
|
||||||
|
from src.constants import DATA_DIR
|
||||||
|
_skills_on = True
|
||||||
|
try:
|
||||||
|
from routes.prefs_routes import _load_for_user as _load_prefs
|
||||||
|
_skills_on = (_load_prefs(owner) or {}).get("skills_enabled", True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_sm = SkillsManager(DATA_DIR)
|
||||||
|
_owner_skills = _sm.load(owner=owner) if _skills_on else []
|
||||||
|
if _owner_skills:
|
||||||
|
_relevant_tools.add("manage_skills")
|
||||||
|
if _retrieval_query:
|
||||||
|
# Validate against every known executable tool, not just
|
||||||
|
# TOOL_SECTIONS — code-nav tools (grep/glob/ls) ship as
|
||||||
|
# schemas without a prompt-prose section.
|
||||||
|
from src.tool_policy import known_tool_names
|
||||||
|
_known = known_tool_names()
|
||||||
|
for _sk in _sm.get_relevant_skills(
|
||||||
|
_retrieval_query, skills=_owner_skills,
|
||||||
|
threshold=0.25, max_items=3,
|
||||||
|
):
|
||||||
|
_relevant_tools.update(
|
||||||
|
t for t in (_sk.get("requires_toolsets") or [])
|
||||||
|
if t in _known
|
||||||
|
)
|
||||||
|
except Exception as _e:
|
||||||
|
logger.debug(f"[tool-rag] skill-aware tool include skipped: {_e}")
|
||||||
|
|
||||||
if _relevant_tools is not None:
|
if _relevant_tools is not None:
|
||||||
logger.info("[agent-intent] selected_tools=%s", sorted(_relevant_tools)[:50])
|
logger.info("[agent-intent] selected_tools=%s", sorted(_relevant_tools)[:50])
|
||||||
|
|
||||||
@@ -1937,6 +1992,10 @@ async def stream_agent_loop(
|
|||||||
# and can override this list for users who know their setup.
|
# and can override this list for users who know their setup.
|
||||||
_model_no_tools = any(kw in _model_lc for kw in (
|
_model_no_tools = any(kw in _model_lc for kw in (
|
||||||
"deepseek-r1",
|
"deepseek-r1",
|
||||||
|
# Open-weight GPT-OSS models are commonly served through llama.cpp /
|
||||||
|
# llama-cpp-python. Their names contain "gpt-o", but they do not use
|
||||||
|
# OpenAI's native tool-call channel unless the endpoint opts in.
|
||||||
|
"gpt-oss",
|
||||||
))
|
))
|
||||||
# Native Ollama endpoints (/api/chat) handle tool schemas differently from
|
# Native Ollama endpoints (/api/chat) handle tool schemas differently from
|
||||||
# the OpenAI-compat path. Models like gemma4, qwen3.5, ministral respond to
|
# the OpenAI-compat path. Models like gemma4, qwen3.5, ministral respond to
|
||||||
@@ -1998,30 +2057,34 @@ async def stream_agent_loop(
|
|||||||
_t3 = time.time()
|
_t3 = time.time()
|
||||||
try:
|
try:
|
||||||
from src.context_compactor import trim_for_context
|
from src.context_compactor import trim_for_context
|
||||||
from src.context_budget import compute_input_token_budget, DEFAULT_HARD_MAX
|
from src.context_budget import compute_input_token_budget, DEFAULT_HARD_MAX, DEFAULT_BUDGET, budget_is_explicit as _budget_is_explicit
|
||||||
from src.settings import is_setting_overridden
|
from src.model_context import budget_context_for_model
|
||||||
|
|
||||||
soft_budget = int(get_setting("agent_input_token_budget", 6000) or 0)
|
soft_budget = int(get_setting("agent_input_token_budget", DEFAULT_BUDGET) or 0)
|
||||||
if soft_budget > 0:
|
if soft_budget > 0:
|
||||||
before_trim_tokens = estimate_tokens(messages)
|
before_trim_tokens = estimate_tokens(messages)
|
||||||
reserve_tokens = min(max(max_tokens or 1024, 512), 2048)
|
reserve_tokens = min(max(max_tokens or 1024, 512), 2048)
|
||||||
# Honour the configurable ceiling for the auto-derived budget path.
|
# Ceiling for the auto-derived budget (no effect on an explicit budget;
|
||||||
# No-op when the user has an explicit `agent_input_token_budget`
|
# see #1230). Falls back to DEFAULT_HARD_MAX on missing/malformed values
|
||||||
# (that branch ignores hard_max). Falls back to DEFAULT_HARD_MAX
|
# so misconfig can't zero the budget.
|
||||||
# on missing/malformed values so misconfig can't zero the budget.
|
|
||||||
try:
|
try:
|
||||||
hard_max = int(get_setting("agent_input_token_hard_max", DEFAULT_HARD_MAX) or DEFAULT_HARD_MAX)
|
hard_max = int(get_setting("agent_input_token_hard_max", DEFAULT_HARD_MAX) or DEFAULT_HARD_MAX)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
hard_max = DEFAULT_HARD_MAX
|
hard_max = DEFAULT_HARD_MAX
|
||||||
if hard_max <= 0:
|
if hard_max <= 0:
|
||||||
hard_max = DEFAULT_HARD_MAX
|
hard_max = DEFAULT_HARD_MAX
|
||||||
# Scale the default budget to the model's context window so long-context
|
# Default value = auto sentinel (scale to the window); any other value =
|
||||||
# models aren't silently capped at 6000; an explicit user setting is
|
# explicit cap. Value-based, not presence-based, because the save path
|
||||||
# still honoured (clamped to the window). (#1170)
|
# materializes defaults so a persisted default must still read as auto (#4121).
|
||||||
|
budget_is_explicit = _budget_is_explicit(soft_budget)
|
||||||
|
# Scale only off a window we actually discovered, bound to the value it
|
||||||
|
# proves (else 0) — not the passed-in context_length, which can be stale
|
||||||
|
# or unset for some callers (#4122 review).
|
||||||
|
ctx_for_budget = budget_context_for_model(endpoint_url, model, fallback=context_length)
|
||||||
effective_budget = compute_input_token_budget(
|
effective_budget = compute_input_token_budget(
|
||||||
soft_budget,
|
soft_budget,
|
||||||
context_length,
|
ctx_for_budget,
|
||||||
is_setting_overridden("agent_input_token_budget"),
|
budget_is_explicit,
|
||||||
hard_max=hard_max,
|
hard_max=hard_max,
|
||||||
)
|
)
|
||||||
trimmed_messages = trim_for_context(
|
trimmed_messages = trim_for_context(
|
||||||
@@ -2096,11 +2159,12 @@ async def stream_agent_loop(
|
|||||||
# tool, so we don't nudge on harmless transitional text like "let me
|
# tool, so we don't nudge on harmless transitional text like "let me
|
||||||
# know what you think".
|
# know what you think".
|
||||||
_INTENT_RE = re.compile(
|
_INTENT_RE = re.compile(
|
||||||
r"(?:^|\n)\s*(?:let me|i'?ll|i will|going to|let's)\s+"
|
r"(?:^|\n)\s*(?:let me|i'?ll|i will|i need to|we need to|need to|"
|
||||||
|
r"i should|we should|i must|we must|going to|let's)\s+"
|
||||||
r"(?:tail|check|investigate|look at|see|tail|read|fetch|inspect|"
|
r"(?:tail|check|investigate|look at|see|tail|read|fetch|inspect|"
|
||||||
r"verify|diagnose|examine|debug|capture|grab|pull|view|run|call|"
|
r"verify|diagnose|examine|debug|capture|grab|pull|view|run|call|"
|
||||||
r"trigger|launch|start|kick off|stop|kill|restart|adopt|serve|"
|
r"trigger|launch|start|kick off|stop|kill|restart|adopt|serve|"
|
||||||
r"register|adopt|list|search|find|query|hit|ping|test)"
|
r"register|adopt|list|search|find|query|hit|ping|test|use|perform|do)"
|
||||||
r"\b[^.\n]{0,140}",
|
r"\b[^.\n]{0,140}",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
@@ -2141,9 +2205,17 @@ async def stream_agent_loop(
|
|||||||
elif _is_api_model:
|
elif _is_api_model:
|
||||||
# Filter schemas by RAG-selected tools (if available)
|
# Filter schemas by RAG-selected tools (if available)
|
||||||
if _relevant_tools:
|
if _relevant_tools:
|
||||||
|
# _build_base_prompt unions _ADMIN_TOOLS into the prompt
|
||||||
|
# sections when admin intent fires — the schema list must
|
||||||
|
# offer the same names, or the model reads prose describing
|
||||||
|
# tools it cannot call and substitutes the nearest schema
|
||||||
|
# it does have (e.g. manage_memory for manage_skills).
|
||||||
|
_schema_names = set(_relevant_tools)
|
||||||
|
if _needs_admin:
|
||||||
|
_schema_names |= _ADMIN_TOOLS
|
||||||
base_schemas = [
|
base_schemas = [
|
||||||
s for s in FUNCTION_TOOL_SCHEMAS
|
s for s in FUNCTION_TOOL_SCHEMAS
|
||||||
if s.get("function", {}).get("name") in _relevant_tools
|
if s.get("function", {}).get("name") in _schema_names
|
||||||
]
|
]
|
||||||
_mcp_filtered = [
|
_mcp_filtered = [
|
||||||
s for s in mcp_schemas
|
s for s in mcp_schemas
|
||||||
@@ -2679,6 +2751,46 @@ async def stream_agent_loop(
|
|||||||
)
|
)
|
||||||
desc, result = await _tool_task
|
desc, result = await _tool_task
|
||||||
|
|
||||||
|
# A skill the model just loaded can prescribe tools that weren't
|
||||||
|
# RAG-selected this turn (declared via requires_toolsets in its
|
||||||
|
# frontmatter). Union them into the selection so the NEXT round's
|
||||||
|
# schema list includes them — otherwise the model reads "use
|
||||||
|
# grep" from the skill it fetched but has no grep schema to call.
|
||||||
|
if (
|
||||||
|
block.tool_type == "manage_skills"
|
||||||
|
and _relevant_tools is not None
|
||||||
|
and not result.get("error")
|
||||||
|
):
|
||||||
|
_ms_args = {}
|
||||||
|
_ms_raw = (block.content or "").strip()
|
||||||
|
if _ms_raw.startswith("{"):
|
||||||
|
try:
|
||||||
|
_ms_args = json.loads(_ms_raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
_ms_args = {}
|
||||||
|
_ms_name = str(_ms_args.get("name", "") or "").strip()
|
||||||
|
if _ms_name and _ms_args.get("action") in ("view", "view_ref"):
|
||||||
|
try:
|
||||||
|
from services.memory.skills import SkillsManager as _SkM
|
||||||
|
from src.constants import DATA_DIR as _DD
|
||||||
|
from src.tool_policy import known_tool_names as _ktn
|
||||||
|
_known = _ktn()
|
||||||
|
for _sk in _SkM(_DD).load(owner=owner):
|
||||||
|
if _sk.get("name") == _ms_name:
|
||||||
|
_new = {
|
||||||
|
t for t in (_sk.get("requires_toolsets") or [])
|
||||||
|
if t in _known and t not in _relevant_tools
|
||||||
|
}
|
||||||
|
if _new:
|
||||||
|
_relevant_tools.update(_new)
|
||||||
|
logger.info(
|
||||||
|
"[tool-rag] skill '%s' unlocked tools for next round: %s",
|
||||||
|
_ms_name, sorted(_new),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except Exception as _e:
|
||||||
|
logger.debug(f"skill requires_toolsets unlock skipped: {_e}")
|
||||||
|
|
||||||
# Extract structured web sources from web_search tool output.
|
# Extract structured web sources from web_search tool output.
|
||||||
# web_search returns {"output": ..., "exit_code": 0}; check "output"
|
# web_search returns {"output": ..., "exit_code": 0}; check "output"
|
||||||
# first so the <!-- SOURCES:…--> marker is found and stripped even
|
# first so the <!-- SOURCES:…--> marker is found and stripped even
|
||||||
|
|||||||
@@ -972,16 +972,15 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
|
|||||||
memories = [m for m in memories if m.get("category", "").lower() == category_filter]
|
memories = [m for m in memories if m.get("category", "").lower() == category_filter]
|
||||||
if not memories:
|
if not memories:
|
||||||
return {"results": "No memories found" + (f" in category '{category_filter}'" if category_filter else "") + "."}
|
return {"results": "No memories found" + (f" in category '{category_filter}'" if category_filter else "") + "."}
|
||||||
|
|
||||||
result_lines = [f"Found {len(memories)} memory entries:\n"]
|
result_lines = [f"Found {len(memories)} memory entries:\n"]
|
||||||
for m in memories[:100]:
|
for m in memories:
|
||||||
cat = m.get("category", "fact")
|
cat = m.get("category", "fact")
|
||||||
mid = m.get("id", "?")[:8]
|
mid = m.get("id", "?")[:8]
|
||||||
text = m.get("text", "")
|
text = m.get("text", "")
|
||||||
if len(text) > 150:
|
if len(text) > 150:
|
||||||
text = text[:150] + "..."
|
text = text[:150] + "..."
|
||||||
result_lines.append(f"- [{cat}] `{mid}` — {text}")
|
result_lines.append(f"- [{cat}] `{mid}` — {text}")
|
||||||
if len(memories) > 100:
|
|
||||||
result_lines.append(f"... and {len(memories) - 100} more")
|
|
||||||
return {"results": "\n".join(result_lines)}
|
return {"results": "\n".join(result_lines)}
|
||||||
|
|
||||||
elif action == "add":
|
elif action == "add":
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import logging
|
|||||||
from typing import Dict
|
from typing import Dict
|
||||||
from cryptography.fernet import Fernet, InvalidToken
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
from core.platform_compat import safe_chmod
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class APIKeyManager:
|
class APIKeyManager:
|
||||||
@@ -15,12 +17,20 @@ class APIKeyManager:
|
|||||||
def get_or_create_key(self) -> bytes:
|
def get_or_create_key(self) -> bytes:
|
||||||
"""Get or create encryption key for API keys"""
|
"""Get or create encryption key for API keys"""
|
||||||
if os.path.exists(self.key_file):
|
if os.path.exists(self.key_file):
|
||||||
|
# Older versions wrote .key with the process umask (often 0o644,
|
||||||
|
# i.e. group/world-readable). Re-restrict on read so existing
|
||||||
|
# installs heal without needing the key to be regenerated.
|
||||||
|
safe_chmod(self.key_file, 0o600)
|
||||||
with open(self.key_file, 'rb') as f:
|
with open(self.key_file, 'rb') as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
else:
|
else:
|
||||||
key = Fernet.generate_key()
|
key = Fernet.generate_key()
|
||||||
with open(self.key_file, 'wb') as f:
|
with open(self.key_file, 'wb') as f:
|
||||||
f.write(key)
|
f.write(key)
|
||||||
|
# This key decrypts every stored provider credential, so restrict it
|
||||||
|
# to the owner (0o600) — it must not be group/world-readable. No-op
|
||||||
|
# on Windows (files there are ACL-restricted to the user already).
|
||||||
|
safe_chmod(self.key_file, 0o600)
|
||||||
return key
|
return key
|
||||||
|
|
||||||
def encrypt_api_key(self, api_key: str) -> str:
|
def encrypt_api_key(self, api_key: str) -> str:
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ async def _drain_agent(sess, messages):
|
|||||||
if "delta" in d:
|
if "delta" in d:
|
||||||
delta = d.get("delta")
|
delta = d.get("delta")
|
||||||
if isinstance(delta, str):
|
if isinstance(delta, str):
|
||||||
|
if d.get("thinking"):
|
||||||
|
continue
|
||||||
full += delta
|
full += delta
|
||||||
elif d.get("type") == "agent_step":
|
elif d.get("type") == "agent_step":
|
||||||
round_num = d.get("round", round_num)
|
round_num = d.get("round", round_num)
|
||||||
|
|||||||
+73
-6
@@ -5,12 +5,13 @@ Auto-registration of built-in MCP servers on startup.
|
|||||||
Each server runs as a stdio subprocess managed by McpManager.
|
Each server runs as a stdio subprocess managed by McpManager.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from core.platform_compat import IS_WINDOWS, which_tool
|
from core.platform_compat import IS_WINDOWS, which_tool
|
||||||
|
|
||||||
@@ -197,12 +198,13 @@ def _npx_package_from_args(args):
|
|||||||
async def _is_npx_package_cached(npx_path, package_spec, timeout_s=5):
|
async def _is_npx_package_cached(npx_path, package_spec, timeout_s=5):
|
||||||
"""Probe whether an npx package is already in the local cache.
|
"""Probe whether an npx package is already in the local cache.
|
||||||
|
|
||||||
Runs `npx --no-install <pkg> --version`. --no-install tells npx to
|
First checks the local `_npx` cache for an installed package. If the
|
||||||
fail instead of downloading, so a cache miss returns fast. We treat
|
package is not found there, falls back to `npx --no-install <pkg>
|
||||||
"exited 0 with non-empty stdout" as proof of a working cached copy.
|
--version` so older npm layouts still work without downloading.
|
||||||
Anything else (non-zero exit, empty stdout, timeout, missing npx,
|
|
||||||
network error) means we should skip the server.
|
|
||||||
"""
|
"""
|
||||||
|
if _is_package_in_npx_cache(package_spec):
|
||||||
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
npx_path, "--no-install", package_spec, "--version",
|
npx_path, "--no-install", package_spec, "--version",
|
||||||
@@ -231,3 +233,68 @@ async def _is_npx_package_cached(npx_path, package_spec, timeout_s=5):
|
|||||||
pass
|
pass
|
||||||
return False
|
return False
|
||||||
return proc.returncode == 0 and bool(stdout.strip())
|
return proc.returncode == 0 and bool(stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _is_package_in_npx_cache(package_spec):
|
||||||
|
"""Return True when npm's `_npx` cache already contains package_spec."""
|
||||||
|
package_name = _npx_package_name(package_spec)
|
||||||
|
if not package_name:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for cache_root in _npm_cache_roots():
|
||||||
|
npx_root = os.path.join(cache_root, "_npx")
|
||||||
|
if _npx_cache_contains_package(npx_root, package_name):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _npx_package_name(package_spec):
|
||||||
|
"""Strip a version/range suffix from an npm package spec."""
|
||||||
|
if not package_spec:
|
||||||
|
return ""
|
||||||
|
if package_spec.startswith("@"):
|
||||||
|
parts = package_spec.split("@", 2)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
return f"@{parts[1]}"
|
||||||
|
return package_spec
|
||||||
|
return package_spec.split("@", 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _npm_cache_roots():
|
||||||
|
roots = []
|
||||||
|
configured = os.environ.get("npm_config_cache")
|
||||||
|
if configured:
|
||||||
|
roots.append(os.path.expanduser(configured))
|
||||||
|
roots.append(os.path.join(os.path.expanduser("~"), ".npm"))
|
||||||
|
local_app_data = os.environ.get("LOCALAPPDATA")
|
||||||
|
if local_app_data:
|
||||||
|
roots.append(os.path.join(local_app_data, "npm-cache"))
|
||||||
|
return list(dict.fromkeys(roots))
|
||||||
|
|
||||||
|
|
||||||
|
def _npx_cache_contains_package(npx_root, package_name):
|
||||||
|
if not os.path.isdir(npx_root):
|
||||||
|
return False
|
||||||
|
package_path = os.path.join("node_modules", *package_name.split("/"), "package.json")
|
||||||
|
try:
|
||||||
|
entries = list(os.scandir(npx_root))
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
for entry in entries:
|
||||||
|
try:
|
||||||
|
is_dir = entry.is_dir()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
cached_name = _cached_package_name(os.path.join(entry.path, package_path))
|
||||||
|
if is_dir and cached_name == package_name:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _cached_package_name(package_json_path):
|
||||||
|
try:
|
||||||
|
with open(package_json_path, encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return ""
|
||||||
|
return str(data.get("name", "")).strip()
|
||||||
|
|||||||
+178
-1
@@ -128,6 +128,17 @@ def validate_caldav_url(raw_url: str) -> str:
|
|||||||
return urlunparse(parsed._replace(fragment="")).rstrip("/")
|
return urlunparse(parsed._replace(fragment="")).rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _event_etag(obj) -> str:
|
||||||
|
"""Best-effort ETag extraction from python-caldav resources."""
|
||||||
|
try:
|
||||||
|
etag = getattr(obj, "etag", None)
|
||||||
|
if callable(etag):
|
||||||
|
etag = etag()
|
||||||
|
return str(etag or "")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _stable_cal_id(remote_url: str, owner: str = "", account_id: str = "") -> str:
|
def _stable_cal_id(remote_url: str, owner: str = "", account_id: str = "") -> str:
|
||||||
"""Deterministic local id for a remote CalDAV calendar, scoped to owner
|
"""Deterministic local id for a remote CalDAV calendar, scoped to owner
|
||||||
and account so two users — or one user with two accounts — pointing at
|
and account so two users — or one user with two accounts — pointing at
|
||||||
@@ -316,11 +327,12 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
|||||||
color="#5b8abf",
|
color="#5b8abf",
|
||||||
source="caldav",
|
source="caldav",
|
||||||
account_id=account_id or None,
|
account_id=account_id or None,
|
||||||
|
caldav_base_url=remote_url,
|
||||||
)
|
)
|
||||||
db.add(local_cal)
|
db.add(local_cal)
|
||||||
db.commit()
|
db.commit()
|
||||||
else:
|
else:
|
||||||
# Refresh display name and stamp account_id if missing.
|
# Refresh display name and stamp CalDAV metadata if missing.
|
||||||
changed = False
|
changed = False
|
||||||
if local_cal.name != display_name:
|
if local_cal.name != display_name:
|
||||||
local_cal.name = display_name
|
local_cal.name = display_name
|
||||||
@@ -328,6 +340,9 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
|||||||
if account_id and not local_cal.account_id:
|
if account_id and not local_cal.account_id:
|
||||||
local_cal.account_id = account_id
|
local_cal.account_id = account_id
|
||||||
changed = True
|
changed = True
|
||||||
|
if local_cal.caldav_base_url != remote_url:
|
||||||
|
local_cal.caldav_base_url = remote_url
|
||||||
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
db.commit()
|
db.commit()
|
||||||
result["calendars"] += 1
|
result["calendars"] += 1
|
||||||
@@ -395,6 +410,9 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
|||||||
|
|
||||||
existing = _find_existing_event(db, pending, uid_val, local_cal.id)
|
existing = _find_existing_event(db, pending, uid_val, local_cal.id)
|
||||||
if existing:
|
if existing:
|
||||||
|
if existing.caldav_sync_pending in {"create", "update"}:
|
||||||
|
result["events"] += 1
|
||||||
|
continue
|
||||||
existing.calendar_id = local_cal.id
|
existing.calendar_id = local_cal.id
|
||||||
existing.summary = summary
|
existing.summary = summary
|
||||||
existing.description = description
|
existing.description = description
|
||||||
@@ -405,6 +423,9 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
|||||||
existing.is_utc = row_is_utc
|
existing.is_utc = row_is_utc
|
||||||
existing.rrule = rrule
|
existing.rrule = rrule
|
||||||
existing.origin = "caldav"
|
existing.origin = "caldav"
|
||||||
|
existing.remote_href = str(getattr(obj, "url", "") or "") or None
|
||||||
|
existing.remote_etag = _event_etag(obj) or None
|
||||||
|
existing.caldav_sync_pending = None
|
||||||
else:
|
else:
|
||||||
new_ev = CalendarEvent(
|
new_ev = CalendarEvent(
|
||||||
uid=uid_val,
|
uid=uid_val,
|
||||||
@@ -418,6 +439,8 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
|||||||
is_utc=row_is_utc,
|
is_utc=row_is_utc,
|
||||||
rrule=rrule,
|
rrule=rrule,
|
||||||
origin="caldav",
|
origin="caldav",
|
||||||
|
remote_href=str(getattr(obj, "url", "") or "") or None,
|
||||||
|
remote_etag=_event_etag(obj) or None,
|
||||||
)
|
)
|
||||||
db.add(new_ev)
|
db.add(new_ev)
|
||||||
pending[uid_val] = new_ev
|
pending[uid_val] = new_ev
|
||||||
@@ -442,6 +465,8 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
|||||||
CalendarEvent.origin == "caldav",
|
CalendarEvent.origin == "caldav",
|
||||||
CalendarEvent.dtstart >= start,
|
CalendarEvent.dtstart >= start,
|
||||||
CalendarEvent.dtstart <= end,
|
CalendarEvent.dtstart <= end,
|
||||||
|
CalendarEvent.remote_href.isnot(None),
|
||||||
|
CalendarEvent.caldav_sync_pending.is_(None),
|
||||||
~CalendarEvent.uid.in_(seen_uids) if seen_uids else CalendarEvent.uid.isnot(None),
|
~CalendarEvent.uid.in_(seen_uids) if seen_uids else CalendarEvent.uid.isnot(None),
|
||||||
).all()
|
).all()
|
||||||
for ev in stale:
|
for ev in stale:
|
||||||
@@ -458,6 +483,92 @@ def _sync_blocking(owner: str, url: str, username: str, password: str, account_i
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _event_payload(ev) -> dict:
|
||||||
|
return {
|
||||||
|
"uid": ev.uid,
|
||||||
|
"summary": ev.summary,
|
||||||
|
"description": ev.description,
|
||||||
|
"location": ev.location,
|
||||||
|
"dtstart": ev.dtstart,
|
||||||
|
"dtend": ev.dtend,
|
||||||
|
"all_day": ev.all_day,
|
||||||
|
"is_utc": ev.is_utc,
|
||||||
|
"rrule": ev.rrule or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_event_for_writeback(owner: str, uid: str) -> tuple[str, str, dict] | None:
|
||||||
|
from core.database import CalendarCal, CalendarEvent, SessionLocal
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
ev = (
|
||||||
|
db.query(CalendarEvent)
|
||||||
|
.join(CalendarCal)
|
||||||
|
.filter(CalendarEvent.uid == uid, CalendarCal.owner == owner)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not ev or not ev.calendar or ev.calendar.source != "caldav":
|
||||||
|
return None
|
||||||
|
return ev.calendar.source, ev.calendar.id, _event_payload(ev)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_delete_for_writeback(owner: str, uid: str) -> tuple[str, str, dict] | None:
|
||||||
|
from core.database import CalendarCal, CalendarDeletedEvent, CalendarEvent, SessionLocal
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
tombstone = db.query(CalendarDeletedEvent).filter(
|
||||||
|
CalendarDeletedEvent.uid == uid,
|
||||||
|
CalendarDeletedEvent.owner == owner,
|
||||||
|
).first()
|
||||||
|
if tombstone:
|
||||||
|
return "caldav", tombstone.calendar_id, {"uid": uid}
|
||||||
|
|
||||||
|
ev = (
|
||||||
|
db.query(CalendarEvent)
|
||||||
|
.join(CalendarCal)
|
||||||
|
.filter(CalendarEvent.uid == uid, CalendarCal.owner == owner)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not ev or not ev.calendar or ev.calendar.source != "caldav":
|
||||||
|
return None
|
||||||
|
return ev.calendar.source, ev.calendar.id, {"uid": uid}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_writeback_uids(owner: str) -> tuple[list[str], list[str]]:
|
||||||
|
from core.database import CalendarCal, CalendarDeletedEvent, CalendarEvent, SessionLocal
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
rows = (
|
||||||
|
db.query(CalendarEvent.uid)
|
||||||
|
.join(CalendarCal)
|
||||||
|
.filter(
|
||||||
|
CalendarCal.owner == owner,
|
||||||
|
CalendarCal.source == "caldav",
|
||||||
|
CalendarEvent.status != "cancelled",
|
||||||
|
(
|
||||||
|
(CalendarEvent.caldav_sync_pending.isnot(None))
|
||||||
|
| (CalendarEvent.remote_href.is_(None))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
delete_rows = (
|
||||||
|
db.query(CalendarDeletedEvent.uid)
|
||||||
|
.filter(CalendarDeletedEvent.owner == owner)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [row[0] for row in rows], [row[0] for row in delete_rows]
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def _load_caldav_accounts(owner: str) -> list:
|
def _load_caldav_accounts(owner: str) -> list:
|
||||||
"""Return the list of CalDAV accounts for *owner*, auto-migrating the legacy
|
"""Return the list of CalDAV accounts for *owner*, auto-migrating the legacy
|
||||||
single-account ``caldav`` key to the new ``caldav_accounts`` list on first call.
|
single-account ``caldav`` key to the new ``caldav_accounts`` list on first call.
|
||||||
@@ -533,3 +644,69 @@ async def sync_caldav(owner: str) -> dict:
|
|||||||
for err in result.get("errors", []):
|
for err in result.get("errors", []):
|
||||||
totals["errors"].append(f"{label}: {err}")
|
totals["errors"].append(f"{label}: {err}")
|
||||||
return totals
|
return totals
|
||||||
|
|
||||||
|
|
||||||
|
async def push_event_create(owner: str, uid: str) -> dict:
|
||||||
|
loaded = _load_event_for_writeback(owner, uid)
|
||||||
|
if not loaded:
|
||||||
|
return {"ok": True, "skipped": True}
|
||||||
|
source, calendar_id, payload = loaded
|
||||||
|
from src.caldav_writeback import writeback_event
|
||||||
|
return await writeback_event(owner, source, calendar_id, payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def push_event_update(owner: str, uid: str) -> dict:
|
||||||
|
return await push_event_create(owner, uid)
|
||||||
|
|
||||||
|
|
||||||
|
async def push_event_delete(owner: str, uid: str) -> dict:
|
||||||
|
loaded = _load_delete_for_writeback(owner, uid)
|
||||||
|
if not loaded:
|
||||||
|
return {"ok": True, "skipped": True}
|
||||||
|
source, calendar_id, payload = loaded
|
||||||
|
from src.caldav_writeback import writeback_event
|
||||||
|
return await writeback_event(owner, source, calendar_id, payload, delete=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def push_pending_events(owner: str) -> dict:
|
||||||
|
result = {"events": 0, "errors": []}
|
||||||
|
uids, delete_uids = _pending_writeback_uids(owner)
|
||||||
|
for event_uid in uids:
|
||||||
|
try:
|
||||||
|
out = await push_event_update(owner, event_uid)
|
||||||
|
if out.get("ok"):
|
||||||
|
result["events"] += 1
|
||||||
|
elif not out.get("skipped"):
|
||||||
|
result["errors"].append(f"{event_uid}: {str(out.get('error') or out)[:160]}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("CalDAV pending push failed for uid=%s: %s", event_uid, e)
|
||||||
|
result["errors"].append(f"{event_uid}: {str(e)[:160]}")
|
||||||
|
for event_uid in delete_uids:
|
||||||
|
try:
|
||||||
|
out = await push_event_delete(owner, event_uid)
|
||||||
|
if out.get("ok"):
|
||||||
|
result["events"] += 1
|
||||||
|
elif not out.get("skipped"):
|
||||||
|
result["errors"].append(f"{event_uid}: {str(out.get('error') or out)[:160]}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("CalDAV pending delete failed for uid=%s: %s", event_uid, e)
|
||||||
|
result["errors"].append(f"{event_uid}: {str(e)[:160]}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_caldav_direction(owner: str, direction: str = "pull") -> dict:
|
||||||
|
direction = (direction or "pull").strip().lower()
|
||||||
|
if direction == "pull":
|
||||||
|
return await sync_caldav(owner)
|
||||||
|
if direction == "push":
|
||||||
|
return await push_pending_events(owner)
|
||||||
|
if direction == "both":
|
||||||
|
pushed = await push_pending_events(owner)
|
||||||
|
pulled = await sync_caldav(owner)
|
||||||
|
return {"push": pushed, "pull": pulled}
|
||||||
|
return {
|
||||||
|
"calendars": 0,
|
||||||
|
"events": 0,
|
||||||
|
"deleted": 0,
|
||||||
|
"errors": [f"Unsupported CalDAV sync direction: {direction}"],
|
||||||
|
}
|
||||||
|
|||||||
+92
-6
@@ -89,6 +89,23 @@ def find_remote_calendar(calendars, local_cal_id: str, owner: str = "", account_
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resource_href(obj) -> str:
|
||||||
|
try:
|
||||||
|
return str(getattr(obj, "url", "") or "")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _resource_etag(obj) -> str:
|
||||||
|
try:
|
||||||
|
etag = getattr(obj, "etag", None)
|
||||||
|
if callable(etag):
|
||||||
|
etag = etag()
|
||||||
|
return str(etag or "")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False,
|
def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False,
|
||||||
owner: str = "", account_id: str = "") -> dict:
|
owner: str = "", account_id: str = "") -> dict:
|
||||||
"""Create/update (or delete) ``ev`` on the matching remote calendar.
|
"""Create/update (or delete) ``ev`` on the matching remote calendar.
|
||||||
@@ -105,6 +122,7 @@ def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False,
|
|||||||
remote = find_remote_calendar(calendars, local_cal_id, owner=owner, account_id=account_id)
|
remote = find_remote_calendar(calendars, local_cal_id, owner=owner, account_id=account_id)
|
||||||
if remote is None:
|
if remote is None:
|
||||||
return {"ok": False, "error": "remote calendar not found"}
|
return {"ok": False, "error": "remote calendar not found"}
|
||||||
|
remote_url = str(getattr(remote, "url", "") or "")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
existing = remote.event_by_uid(uid)
|
existing = remote.event_by_uid(uid)
|
||||||
@@ -113,17 +131,34 @@ def push_event(calendars, local_cal_id: str, ev: dict, *, delete: bool = False,
|
|||||||
|
|
||||||
if delete:
|
if delete:
|
||||||
if existing is None:
|
if existing is None:
|
||||||
return {"ok": True, "note": "already absent on remote"}
|
return {"ok": True, "note": "already absent on remote", "calendar_url": remote_url}
|
||||||
existing.delete()
|
existing.delete()
|
||||||
return {"ok": True}
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"calendar_url": remote_url,
|
||||||
|
"remote_href": _resource_href(existing),
|
||||||
|
"remote_etag": _resource_etag(existing),
|
||||||
|
}
|
||||||
|
|
||||||
ical = build_event_ical(ev)
|
ical = build_event_ical(ev)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
existing.data = ical
|
existing.data = ical
|
||||||
existing.save()
|
existing.save()
|
||||||
return {"ok": True, "updated": True}
|
return {
|
||||||
remote.save_event(ical)
|
"ok": True,
|
||||||
return {"ok": True, "created": True}
|
"updated": True,
|
||||||
|
"calendar_url": remote_url,
|
||||||
|
"remote_href": _resource_href(existing),
|
||||||
|
"remote_etag": _resource_etag(existing),
|
||||||
|
}
|
||||||
|
created = remote.save_event(ical)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"created": True,
|
||||||
|
"calendar_url": remote_url,
|
||||||
|
"remote_href": _resource_href(created),
|
||||||
|
"remote_etag": _resource_etag(created),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _discover_calendars(client):
|
def _discover_calendars(client):
|
||||||
@@ -154,6 +189,54 @@ def _writeback_blocking(local_cal_id, ev, delete, url, username, password,
|
|||||||
owner=owner, account_id=account_id)
|
owner=owner, account_id=account_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_writeback_result(owner: str, calendar_id: str, uid: str, result: dict, *, delete: bool) -> None:
|
||||||
|
from core.database import CalendarCal, CalendarDeletedEvent, CalendarEvent, SessionLocal
|
||||||
|
|
||||||
|
if not uid or not isinstance(result, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
calendar = db.query(CalendarCal).filter(
|
||||||
|
CalendarCal.id == calendar_id,
|
||||||
|
CalendarCal.owner == owner,
|
||||||
|
).first()
|
||||||
|
if calendar and result.get("calendar_url"):
|
||||||
|
calendar.caldav_base_url = result.get("calendar_url")
|
||||||
|
|
||||||
|
if delete:
|
||||||
|
tombstone = db.query(CalendarDeletedEvent).filter(
|
||||||
|
CalendarDeletedEvent.uid == uid,
|
||||||
|
CalendarDeletedEvent.owner == owner,
|
||||||
|
).first()
|
||||||
|
if result.get("ok"):
|
||||||
|
if tombstone:
|
||||||
|
db.delete(tombstone)
|
||||||
|
elif tombstone:
|
||||||
|
tombstone.last_error = str(result.get("error") or result)[:500]
|
||||||
|
db.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
event = (
|
||||||
|
db.query(CalendarEvent)
|
||||||
|
.join(CalendarCal)
|
||||||
|
.filter(CalendarEvent.uid == uid, CalendarCal.owner == owner)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if event and result.get("ok"):
|
||||||
|
if result.get("remote_href"):
|
||||||
|
event.remote_href = result.get("remote_href")
|
||||||
|
if result.get("remote_etag"):
|
||||||
|
event.remote_etag = result.get("remote_etag")
|
||||||
|
event.caldav_sync_pending = None
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("CalDAV write-back metadata persistence failed")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
async def writeback_event(owner: str, calendar_source: str, calendar_id: str,
|
async def writeback_event(owner: str, calendar_source: str, calendar_id: str,
|
||||||
ev: dict, *, delete: bool = False) -> dict:
|
ev: dict, *, delete: bool = False) -> dict:
|
||||||
"""Best-effort push of a local change to the remote CalDAV server.
|
"""Best-effort push of a local change to the remote CalDAV server.
|
||||||
@@ -204,9 +287,12 @@ async def writeback_event(owner: str, calendar_source: str, calendar_id: str,
|
|||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
_writeback_blocking, calendar_id, ev, delete, url, user, pw, owner, acc_id
|
_writeback_blocking, calendar_id, ev, delete, url, user, pw, owner, acc_id
|
||||||
)
|
)
|
||||||
|
_persist_writeback_result(owner, calendar_id, (ev or {}).get("uid", ""), result, delete=delete)
|
||||||
if not result.get("ok"):
|
if not result.get("ok"):
|
||||||
logger.warning("CalDAV write-back did not apply: %s", result.get("error") or result)
|
logger.warning("CalDAV write-back did not apply: %s", result.get("error") or result)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("CalDAV write-back raised")
|
logger.exception("CalDAV write-back raised")
|
||||||
return {"ok": False, "error": str(e)[:200]}
|
result = {"ok": False, "error": str(e)[:200]}
|
||||||
|
_persist_writeback_result(owner, calendar_id, (ev or {}).get("uid", ""), result, delete=delete)
|
||||||
|
return result
|
||||||
|
|||||||
+27
-7
@@ -31,16 +31,22 @@ def compute_input_token_budget(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
configured: the value read from settings (may be the default).
|
configured: the value read from settings (may be the default).
|
||||||
context_length: the model's discovered context window (0/unknown if none).
|
context_length: the model's discovered context window. Pass 0 when the
|
||||||
explicit: True if the user explicitly set ``agent_input_token_budget``.
|
window is unknown / only a bare fallback — auto-scaling then stays
|
||||||
|
conservative instead of trusting an unproven window (review on #4122).
|
||||||
|
explicit: True if the user set a NON-default budget. The default value is
|
||||||
|
the "auto" sentinel (scale to the window); any other value is an
|
||||||
|
explicit cap. (A deliberately-chosen default can't be distinguished
|
||||||
|
from a materialized default by value, so the default reads as auto.)
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Explicit user budget is honoured exactly, only clamped to the model's
|
- Explicit user budget is honoured exactly, only clamped to the model's
|
||||||
window when that window is known (never send more than the model holds).
|
window when that window is known (the user's deliberate choice wins;
|
||||||
- Otherwise (default), scale to ``headroom`` of the context window, capped
|
``hard_max`` is an auto-budget ceiling only — see #1230).
|
||||||
at ``hard_max`` — so long-context models use their capacity.
|
- Otherwise (auto), scale to ``headroom`` of the context window, capped at
|
||||||
- When the window is unknown, fall back to the configured/default value
|
``hard_max`` — so long-context models use their capacity.
|
||||||
(preserving the previous behaviour).
|
- When the window is unknown (context_length <= 0), use the conservative
|
||||||
|
``default`` budget and do NOT scale off the fallback.
|
||||||
"""
|
"""
|
||||||
configured = int(configured or 0)
|
configured = int(configured or 0)
|
||||||
context_length = int(context_length or 0)
|
context_length = int(context_length or 0)
|
||||||
@@ -53,3 +59,17 @@ def compute_input_token_budget(
|
|||||||
return max(1, min(scaled, hard_max))
|
return max(1, min(scaled, hard_max))
|
||||||
|
|
||||||
return configured if configured > 0 else default
|
return configured if configured > 0 else default
|
||||||
|
|
||||||
|
|
||||||
|
def budget_is_explicit(configured: int, *, default: int = DEFAULT_BUDGET) -> bool:
|
||||||
|
"""Whether a configured agent_input_token_budget is a deliberate explicit cap.
|
||||||
|
|
||||||
|
The default value is the "auto" sentinel (scale to the model's window), so only
|
||||||
|
a NON-default positive value counts as explicit. This keys off the VALUE, not
|
||||||
|
settings *presence* — the settings-save path materializes every default into
|
||||||
|
settings.json, so a persisted default must still read as auto (the regression
|
||||||
|
#4121 / #1230 are about). Centralised here so the materialized-default contract
|
||||||
|
is unit-testable and can't silently regress to a presence check.
|
||||||
|
"""
|
||||||
|
configured = int(configured or 0)
|
||||||
|
return configured > 0 and configured != default
|
||||||
|
|||||||
@@ -244,9 +244,17 @@ def trim_for_context(messages: List[Dict], context_length: int, reserve_tokens:
|
|||||||
protected_tokens = estimate_tokens(protected_msgs)
|
protected_tokens = estimate_tokens(protected_msgs)
|
||||||
budget -= protected_tokens
|
budget -= protected_tokens
|
||||||
|
|
||||||
# Priority: keep first system msg (preset prompt), drop others (memory, RAG, memo)
|
# Priority: keep first system msg (preset prompt), drop others (memory, RAG, memo).
|
||||||
essential_system = system_msgs[:1] if system_msgs else []
|
# Exception: a research-spinoff primer (the seeded report that grounds a
|
||||||
extra_system = system_msgs[1:]
|
# "Discuss" chat) must never be dropped — it is the conversation's whole
|
||||||
|
# knowledge base. Treat any system message carrying research_spinoff_from
|
||||||
|
# metadata as essential alongside the leading system prompt.
|
||||||
|
def _is_research_primer(m):
|
||||||
|
return bool((m.get("metadata") or {}).get("research_spinoff_from"))
|
||||||
|
_primers = [m for m in system_msgs if _is_research_primer(m)]
|
||||||
|
_non_primer = [m for m in system_msgs if not _is_research_primer(m)]
|
||||||
|
essential_system = (_non_primer[:1] if _non_primer else []) + _primers
|
||||||
|
extra_system = _non_primer[1:]
|
||||||
|
|
||||||
# Try dropping extra system messages one by one (from the end)
|
# Try dropping extra system messages one by one (from the end)
|
||||||
trimmed = essential_system + convo_msgs
|
trimmed = essential_system + convo_msgs
|
||||||
|
|||||||
@@ -136,7 +136,8 @@ async def _tick() -> None:
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.warning("cookbook_serve_lifecycle: state file unreadable (%s), skipping tick", e)
|
||||||
return
|
return
|
||||||
tasks = state.get("tasks") or []
|
tasks = state.get("tasks") or []
|
||||||
now_ms = int(time.time() * 1000)
|
now_ms = int(time.time() * 1000)
|
||||||
@@ -178,8 +179,26 @@ async def _tick() -> None:
|
|||||||
if stopped_any:
|
if stopped_any:
|
||||||
try:
|
try:
|
||||||
from core.atomic_io import atomic_write_json
|
from core.atomic_io import atomic_write_json
|
||||||
state["tasks"] = tasks
|
# Re-read the state file so concurrent UI writes (task adds,
|
||||||
atomic_write_json(state_path, state)
|
# status flips, config edits) are not silently overwritten.
|
||||||
|
# Apply only our stop mutations to the fresh snapshot.
|
||||||
|
try:
|
||||||
|
fresh = json.loads(state_path.read_text(encoding="utf-8"))
|
||||||
|
fresh_tasks = fresh.get("tasks") or []
|
||||||
|
except Exception:
|
||||||
|
fresh = state
|
||||||
|
fresh_tasks = tasks
|
||||||
|
stopped_sids = {sid for sid, _, _ in to_stop}
|
||||||
|
for ft in fresh_tasks:
|
||||||
|
if not isinstance(ft, dict):
|
||||||
|
continue
|
||||||
|
ft_sid = ft.get("sessionId") or ft.get("id")
|
||||||
|
if ft_sid in stopped_sids:
|
||||||
|
ft["status"] = "stopped"
|
||||||
|
ft["_scheduledStopAtMs"] = None
|
||||||
|
ft["_lastStatusFlipAt"] = now_ms
|
||||||
|
fresh["tasks"] = fresh_tasks
|
||||||
|
atomic_write_json(state_path, fresh)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"cookbook_serve_lifecycle: state write failed: {e}")
|
logger.warning(f"cookbook_serve_lifecycle: state write failed: {e}")
|
||||||
|
|
||||||
|
|||||||
+26
-13
@@ -12,7 +12,7 @@ from typing import Optional, Tuple, Dict
|
|||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
from core.database import SessionLocal, ModelEndpoint
|
from core.database import SessionLocal, ModelEndpoint
|
||||||
from src.llm_core import _detect_provider, _host_match, _ollama_api_root
|
from src.llm_core import _detect_provider, _host_match, _is_kimi_code_url, KIMI_CODE_USER_AGENT, _ollama_api_root
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -183,7 +183,16 @@ def build_chat_url(base: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def build_models_url(base: str) -> Optional[str]:
|
def build_models_url(base: str) -> Optional[str]:
|
||||||
"""Return the provider-specific model-list endpoint URL for a base."""
|
"""Return the provider-specific model-list endpoint URL for a base.
|
||||||
|
|
||||||
|
For OpenAI-compatible servers (LM Studio, llama.cpp, vLLM,
|
||||||
|
text-generation-webui, etc.) the model list is exposed at ``/v1/models``.
|
||||||
|
When the user-supplied base has no path — e.g. ``http://localhost:1234`` —
|
||||||
|
we still need to land on ``/v1/models`` (issue #25); insert the ``/v1``
|
||||||
|
segment only when the path is empty, leaving any explicit non-empty path
|
||||||
|
untouched (so custom prefixes like ``/openai`` or ``/api/openai/v1`` keep
|
||||||
|
their semantics).
|
||||||
|
"""
|
||||||
base = normalize_base(resolve_url(base))
|
base = normalize_base(resolve_url(base))
|
||||||
provider = _detect_provider(base)
|
provider = _detect_provider(base)
|
||||||
if provider == "anthropic":
|
if provider == "anthropic":
|
||||||
@@ -192,6 +201,12 @@ def build_models_url(base: str) -> Optional[str]:
|
|||||||
return _ollama_api_root(base) + "/tags"
|
return _ollama_api_root(base) + "/tags"
|
||||||
if provider == "chatgpt-subscription":
|
if provider == "chatgpt-subscription":
|
||||||
return None
|
return None
|
||||||
|
# Generic OpenAI-compatible fallback: ensure the path lands on /v1/models
|
||||||
|
# when the user omitted a path entirely. If a non-empty path is already
|
||||||
|
# present (e.g. /openai, /api/openai/v1, /v1), trust the caller — the
|
||||||
|
# /models suffix is appended as-is and the caller's prefix is preserved.
|
||||||
|
if not urlparse(base).path:
|
||||||
|
base = base + "/v1"
|
||||||
return base + "/models"
|
return base + "/models"
|
||||||
|
|
||||||
|
|
||||||
@@ -215,6 +230,8 @@ def build_headers(api_key: Optional[str], base: str) -> Dict[str, str]:
|
|||||||
if provider == "openrouter":
|
if provider == "openrouter":
|
||||||
headers.setdefault("HTTP-Referer", "https://github.com/pewdiepie-archdaemon/odysseus")
|
headers.setdefault("HTTP-Referer", "https://github.com/pewdiepie-archdaemon/odysseus")
|
||||||
headers.setdefault("X-OpenRouter-Title", "Odysseus")
|
headers.setdefault("X-OpenRouter-Title", "Odysseus")
|
||||||
|
if _is_kimi_code_url(base):
|
||||||
|
headers.setdefault("User-Agent", KIMI_CODE_USER_AGENT)
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
|
|
||||||
@@ -250,23 +267,19 @@ def resolve_endpoint(
|
|||||||
ep_id = _stg(f"{setting_prefix}_endpoint_id")
|
ep_id = _stg(f"{setting_prefix}_endpoint_id")
|
||||||
model = _stg(f"{setting_prefix}_model")
|
model = _stg(f"{setting_prefix}_model")
|
||||||
|
|
||||||
# If the specific endpoint is not configured, but the caller provided a
|
# Fall back to utility model for task/research/auto-naming if not specifically configured.
|
||||||
|
if not ep_id and setting_prefix not in ("utility", "default"):
|
||||||
|
ep_id = _stg("utility_endpoint_id")
|
||||||
|
model = _stg("utility_model")
|
||||||
|
|
||||||
|
# If the endpoint is STILL not configured, but the caller provided a
|
||||||
# valid fallback (e.g. the active session model), use that immediately.
|
# valid fallback (e.g. the active session model), use that immediately.
|
||||||
# This prevents background tasks from jumping to the global default_model
|
# This prevents background tasks from jumping to the global default_model
|
||||||
# when the user is mid-conversation with a different model.
|
# when the user is mid-conversation with a different model.
|
||||||
if not ep_id and fallback_url and fallback_model:
|
if not ep_id and fallback_url and fallback_model:
|
||||||
return fallback_url, fallback_model, fallback_headers
|
return fallback_url, fallback_model, fallback_headers
|
||||||
|
|
||||||
# Unset Utility means "same as Default Chat Model".
|
# Unset Utility (or anything else that didn't have a fallback) means "same as Default Chat Model".
|
||||||
if setting_prefix == "utility" and not ep_id:
|
|
||||||
ep_id = _stg("default_endpoint_id")
|
|
||||||
model = _stg("default_model")
|
|
||||||
|
|
||||||
# Fall back to utility model for task/research/auto-naming if not specifically configured.
|
|
||||||
# If Utility itself is unset, the block above makes that resolve to Default Chat.
|
|
||||||
if not ep_id and setting_prefix != "utility":
|
|
||||||
ep_id = _stg("utility_endpoint_id")
|
|
||||||
model = _stg("utility_model")
|
|
||||||
if not ep_id:
|
if not ep_id:
|
||||||
ep_id = _stg("default_endpoint_id")
|
ep_id = _stg("default_endpoint_id")
|
||||||
model = _stg("default_model")
|
model = _stg("default_model")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import re
|
|||||||
from typing import Dict, List, Optional, Any
|
from typing import Dict, List, Optional, Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from core.atomic_io import atomic_write_json
|
from core.atomic_io import atomic_write_json
|
||||||
from core.platform_compat import safe_chmod
|
from core.platform_compat import safe_chmod
|
||||||
@@ -258,6 +259,11 @@ def add_integration(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
integration.setdefault("name", "")
|
integration.setdefault("name", "")
|
||||||
integration.setdefault("base_url", "")
|
integration.setdefault("base_url", "")
|
||||||
|
|
||||||
|
if not isinstance(integration.get("name"), str) or not integration["name"].strip():
|
||||||
|
raise HTTPException(400, "Integration name is required")
|
||||||
|
if not isinstance(integration.get("base_url"), str) or not integration["base_url"].strip():
|
||||||
|
raise HTTPException(400, "Integration base URL is required")
|
||||||
|
|
||||||
integrations = load_integrations()
|
integrations = load_integrations()
|
||||||
integrations.append(integration)
|
integrations.append(integration)
|
||||||
save_integrations(integrations)
|
save_integrations(integrations)
|
||||||
@@ -266,6 +272,11 @@ def add_integration(data: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
|
|
||||||
def update_integration(integration_id: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
def update_integration(integration_id: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
"""Update fields on an existing integration. Returns updated integration or None."""
|
"""Update fields on an existing integration. Returns updated integration or None."""
|
||||||
|
if "name" in data and (not isinstance(data["name"], str) or not data["name"].strip()):
|
||||||
|
raise HTTPException(400, "Integration name is required")
|
||||||
|
if "base_url" in data and (not isinstance(data["base_url"], str) or not data["base_url"].strip()):
|
||||||
|
raise HTTPException(400, "Integration base URL is required")
|
||||||
|
|
||||||
integrations = load_integrations()
|
integrations = load_integrations()
|
||||||
for item in integrations:
|
for item in integrations:
|
||||||
if item.get("id") == integration_id:
|
if item.get("id") == integration_id:
|
||||||
|
|||||||
+204
-11
@@ -7,6 +7,7 @@ import logging
|
|||||||
import hashlib
|
import hashlib
|
||||||
import threading
|
import threading
|
||||||
import re
|
import re
|
||||||
|
import os
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from typing import Optional, Dict, List, Tuple
|
from typing import Optional, Dict, List, Tuple
|
||||||
from src.model_context import get_context_length, DEFAULT_CONTEXT
|
from src.model_context import get_context_length, DEFAULT_CONTEXT
|
||||||
@@ -22,6 +23,24 @@ class LLMConfig:
|
|||||||
MAX_RETRIES = 3
|
MAX_RETRIES = 3
|
||||||
RETRY_DELAY = 0.5
|
RETRY_DELAY = 0.5
|
||||||
STREAM_TIMEOUT = 300
|
STREAM_TIMEOUT = 300
|
||||||
|
# TCP+TLS connect budget for a SINGLE attempt. The old hard-coded 3.0s
|
||||||
|
# assumed LAN/Tailscale peers ('SYN in <100ms'); it is too tight for public
|
||||||
|
# cloud endpoints (offshore APIs take ~0.5-1.5s cold, with jitter), so a
|
||||||
|
# brief blip on the first connect of an idle chat surfaced as a 503 on the
|
||||||
|
# streaming path (which, unlike llm_call, does not retry the connect). A
|
||||||
|
# genuinely dead upstream stays bounded by the dead-host cooldown. Override
|
||||||
|
# with env LLM_CONNECT_TIMEOUT (seconds).
|
||||||
|
CONNECT_TIMEOUT = float(os.getenv('LLM_CONNECT_TIMEOUT', '10') or '10')
|
||||||
|
|
||||||
|
|
||||||
|
def _call_timeout(read_timeout) -> httpx.Timeout:
|
||||||
|
"""Per-request timeout for non-streaming LLM calls (connect from config)."""
|
||||||
|
return httpx.Timeout(connect=LLMConfig.CONNECT_TIMEOUT, read=float(read_timeout), write=10.0, pool=5.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _stream_timeout(read_timeout) -> httpx.Timeout:
|
||||||
|
"""Per-request timeout for streaming LLM calls (connect from config)."""
|
||||||
|
return httpx.Timeout(connect=LLMConfig.CONNECT_TIMEOUT, read=float(read_timeout), write=30.0, pool=5.0)
|
||||||
|
|
||||||
|
|
||||||
# Cache for LLM responses
|
# Cache for LLM responses
|
||||||
@@ -423,6 +442,146 @@ def _host_match(url: str, *domains: str) -> bool:
|
|||||||
return any(host == d or host.endswith("." + d) for d in domains)
|
return any(host == d or host.endswith("." + d) for d in domains)
|
||||||
|
|
||||||
|
|
||||||
|
# Kimi Code subscription keys (api.kimi.com/coding/v1) require a whitelisted
|
||||||
|
# coding-agent User-Agent; otherwise the API returns 403 access_terminated_error.
|
||||||
|
# Tried in order; first success is cached per base URL for later requests.
|
||||||
|
KIMI_CODE_USER_AGENTS: tuple[str, ...] = (
|
||||||
|
"claude-code/0.1.0",
|
||||||
|
"claude-code/1.0.0",
|
||||||
|
"KimiCLI/1.0",
|
||||||
|
"Kilo-Code/1.0",
|
||||||
|
"Roo-Code/1.0",
|
||||||
|
"Cursor/1.0",
|
||||||
|
)
|
||||||
|
KIMI_CODE_USER_AGENT = KIMI_CODE_USER_AGENTS[0]
|
||||||
|
_kimi_code_ua_cache: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_kimi_code_url(url: str) -> bool:
|
||||||
|
if not url or not _host_match(url, "kimi.com"):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return "/coding" in (urlparse(url).path or "")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _kimi_code_base_key(url: str) -> str:
|
||||||
|
"""Normalize a Kimi Code chat/models URL to its OpenAI base (.../coding/v1)."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
path = (parsed.path or "").rstrip("/")
|
||||||
|
for suffix in ("/chat/completions", "/models", "/completions"):
|
||||||
|
if path.endswith(suffix):
|
||||||
|
path = path[: -len(suffix)]
|
||||||
|
path = path.rstrip("/") or "/coding/v1"
|
||||||
|
return f"{parsed.scheme}://{parsed.netloc}{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_kimi_code_access_denied(status: int, body: bytes | str) -> bool:
|
||||||
|
if status != 403:
|
||||||
|
return False
|
||||||
|
text = body.decode("utf-8", errors="replace") if isinstance(body, bytes) else (body or "")
|
||||||
|
lower = text.lower()
|
||||||
|
return (
|
||||||
|
"access_terminated_error" in lower
|
||||||
|
or "coding agents" in lower
|
||||||
|
or "only available for coding" in lower
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _kimi_code_ua_candidates(url: str) -> list[str]:
|
||||||
|
if not _is_kimi_code_url(url):
|
||||||
|
return []
|
||||||
|
base_key = _kimi_code_base_key(url)
|
||||||
|
cached = _kimi_code_ua_cache.get(base_key)
|
||||||
|
if cached:
|
||||||
|
return [cached] + [ua for ua in KIMI_CODE_USER_AGENTS if ua != cached]
|
||||||
|
return list(KIMI_CODE_USER_AGENTS)
|
||||||
|
|
||||||
|
|
||||||
|
def _remember_kimi_code_user_agent(url: str, user_agent: str) -> None:
|
||||||
|
_kimi_code_ua_cache[_kimi_code_base_key(url)] = user_agent
|
||||||
|
|
||||||
|
|
||||||
|
def apply_kimi_code_headers(headers: Optional[Dict], url: str) -> Dict[str, str]:
|
||||||
|
"""Pick a Kimi Code User-Agent (cached probe when possible)."""
|
||||||
|
h = dict(headers or {})
|
||||||
|
if not _is_kimi_code_url(url):
|
||||||
|
return h
|
||||||
|
base_key = _kimi_code_base_key(url)
|
||||||
|
cached = _kimi_code_ua_cache.get(base_key)
|
||||||
|
if cached:
|
||||||
|
h["User-Agent"] = cached
|
||||||
|
return h
|
||||||
|
models_url = base_key.rstrip("/") + "/models"
|
||||||
|
from src.tls_overrides import llm_verify
|
||||||
|
for ua in KIMI_CODE_USER_AGENTS:
|
||||||
|
trial = dict(h)
|
||||||
|
trial["User-Agent"] = ua
|
||||||
|
try:
|
||||||
|
r = httpx.get(models_url, headers=trial, timeout=8, verify=llm_verify())
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if _is_kimi_code_access_denied(r.status_code, r.content):
|
||||||
|
logger.debug("Kimi Code rejected User-Agent %s (403), trying next", ua)
|
||||||
|
continue
|
||||||
|
if r.status_code < 400:
|
||||||
|
_remember_kimi_code_user_agent(url, ua)
|
||||||
|
h["User-Agent"] = ua
|
||||||
|
return h
|
||||||
|
break
|
||||||
|
h.setdefault("User-Agent", KIMI_CODE_USER_AGENT)
|
||||||
|
return h
|
||||||
|
|
||||||
|
|
||||||
|
def httpx_get_kimi_aware(url: str, headers: Optional[Dict], **kwargs):
|
||||||
|
h = apply_kimi_code_headers(headers, url)
|
||||||
|
if not _is_kimi_code_url(url):
|
||||||
|
return httpx.get(url, headers=h, **kwargs)
|
||||||
|
last = None
|
||||||
|
for ua in _kimi_code_ua_candidates(url):
|
||||||
|
trial = dict(h)
|
||||||
|
trial["User-Agent"] = ua
|
||||||
|
last = httpx.get(url, headers=trial, **kwargs)
|
||||||
|
if not _is_kimi_code_access_denied(last.status_code, last.content):
|
||||||
|
if last.status_code < 400:
|
||||||
|
_remember_kimi_code_user_agent(url, ua)
|
||||||
|
return last
|
||||||
|
return last
|
||||||
|
|
||||||
|
|
||||||
|
def httpx_post_kimi_aware(url: str, headers: Optional[Dict], **kwargs):
|
||||||
|
h = apply_kimi_code_headers(headers, url)
|
||||||
|
if not _is_kimi_code_url(url):
|
||||||
|
return httpx.post(url, headers=h, **kwargs)
|
||||||
|
last = None
|
||||||
|
for ua in _kimi_code_ua_candidates(url):
|
||||||
|
trial = dict(h)
|
||||||
|
trial["User-Agent"] = ua
|
||||||
|
last = httpx.post(url, headers=trial, **kwargs)
|
||||||
|
if not _is_kimi_code_access_denied(last.status_code, last.content):
|
||||||
|
if last.status_code < 400:
|
||||||
|
_remember_kimi_code_user_agent(url, ua)
|
||||||
|
return last
|
||||||
|
return last
|
||||||
|
|
||||||
|
|
||||||
|
async def httpx_post_kimi_aware_async(client, url: str, headers: Optional[Dict], **kwargs):
|
||||||
|
h = apply_kimi_code_headers(headers, url)
|
||||||
|
if not _is_kimi_code_url(url):
|
||||||
|
return await client.post(url, headers=h, **kwargs)
|
||||||
|
last = None
|
||||||
|
for ua in _kimi_code_ua_candidates(url):
|
||||||
|
trial = dict(h)
|
||||||
|
trial["User-Agent"] = ua
|
||||||
|
last = await client.post(url, headers=trial, **kwargs)
|
||||||
|
if not _is_kimi_code_access_denied(last.status_code, last.content):
|
||||||
|
if last.status_code < 400:
|
||||||
|
_remember_kimi_code_user_agent(url, ua)
|
||||||
|
return last
|
||||||
|
return last
|
||||||
|
|
||||||
|
|
||||||
def _detect_provider(url: str) -> str:
|
def _detect_provider(url: str) -> str:
|
||||||
"""Detect the API provider from a configured endpoint URL.
|
"""Detect the API provider from a configured endpoint URL.
|
||||||
|
|
||||||
@@ -446,6 +605,8 @@ def _detect_provider(url: str) -> str:
|
|||||||
return "groq"
|
return "groq"
|
||||||
if _host_match(url, "nvidia.com"):
|
if _host_match(url, "nvidia.com"):
|
||||||
return "nvidia"
|
return "nvidia"
|
||||||
|
if _host_match(url, "moonshot.ai") or _host_match(url, "moonshot.cn"):
|
||||||
|
return "moonshot"
|
||||||
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
from src.chatgpt_subscription import is_chatgpt_subscription_base
|
||||||
if is_chatgpt_subscription_base(url):
|
if is_chatgpt_subscription_base(url):
|
||||||
return "chatgpt-subscription"
|
return "chatgpt-subscription"
|
||||||
@@ -542,6 +703,12 @@ def _provider_label(url: str) -> str:
|
|||||||
if _host_match(url, "googleapis.com"): return "Google"
|
if _host_match(url, "googleapis.com"): return "Google"
|
||||||
if _host_match(url, "together.xyz", "together.ai"): return "Together"
|
if _host_match(url, "together.xyz", "together.ai"): return "Together"
|
||||||
if _host_match(url, "fireworks.ai"): return "Fireworks"
|
if _host_match(url, "fireworks.ai"): return "Fireworks"
|
||||||
|
if _host_match(url, "kimi.com"):
|
||||||
|
try:
|
||||||
|
if "/coding" in (urlparse(url).path or ""):
|
||||||
|
return "Kimi Code"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if _is_ollama_native_url(url): return "Ollama"
|
if _is_ollama_native_url(url): return "Ollama"
|
||||||
try:
|
try:
|
||||||
host = (urlparse(url).hostname or "").lower()
|
host = (urlparse(url).hostname or "").lower()
|
||||||
@@ -682,7 +849,7 @@ def _uses_max_completion_tokens(model: str) -> bool:
|
|||||||
# perfectly good model as failing. For these models we omit the field and let
|
# perfectly good model as failing. For these models we omit the field and let
|
||||||
# the API use its required default. (gpt-4.5 is intentionally excluded — it is
|
# the API use its required default. (gpt-4.5 is intentionally excluded — it is
|
||||||
# not a reasoning model and accepts temperature normally.)
|
# not a reasoning model and accepts temperature normally.)
|
||||||
_FIXED_TEMPERATURE_MODELS = ("o1", "o3", "o4", "gpt-5")
|
_FIXED_TEMPERATURE_MODELS = ("o1", "o3", "o4", "gpt-5", "kimi-for-coding")
|
||||||
|
|
||||||
def _restricts_temperature(model: str) -> bool:
|
def _restricts_temperature(model: str) -> bool:
|
||||||
"""Check if a model rejects any non-default temperature."""
|
"""Check if a model rejects any non-default temperature."""
|
||||||
@@ -691,6 +858,28 @@ def _restricts_temperature(model: str) -> bool:
|
|||||||
m = model.lower()
|
m = model.lower()
|
||||||
return any(m.startswith(p) or f"/{p}" in m for p in _FIXED_TEMPERATURE_MODELS)
|
return any(m.startswith(p) or f"/{p}" in m for p in _FIXED_TEMPERATURE_MODELS)
|
||||||
|
|
||||||
|
|
||||||
|
# The official Moonshot API fixes temperature at 1.0 in thinking mode and 0.6
|
||||||
|
# when thinking is explicitly disabled for Kimi K2.5/K2.6. Any other explicit
|
||||||
|
# value returns HTTP 400. Odysseus does not currently send the `thinking` mode
|
||||||
|
# control, so omit temperature and let Moonshot use its default thinking mode.
|
||||||
|
# Keep the gate provider-specific: self-hosted Kimi deployments may accept
|
||||||
|
# custom sampling values, and older Moonshot models have different defaults.
|
||||||
|
def _moonshot_rejects_custom_temperature(provider: str, model: str) -> bool:
|
||||||
|
"""Check if the official Moonshot API fixes temperature for this model."""
|
||||||
|
if provider != "moonshot" or not isinstance(model, str):
|
||||||
|
return False
|
||||||
|
model_id = model.lower().rsplit("/", 1)[-1]
|
||||||
|
return bool(re.match(r"^kimi-k2\.(?:5|6)(?:$|[-_:])", model_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _omit_temperature(provider: str, model: str) -> bool:
|
||||||
|
"""Check if a request should use the provider's default temperature."""
|
||||||
|
return _restricts_temperature(model) or _moonshot_rejects_custom_temperature(
|
||||||
|
provider, model
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Anthropic removed the sampling parameters (temperature, top_p, top_k) starting
|
# Anthropic removed the sampling parameters (temperature, top_p, top_k) starting
|
||||||
# with Claude Opus 4.7. On Opus 4.7 and later, sending `temperature` at all —
|
# with Claude Opus 4.7. On Opus 4.7 and later, sending `temperature` at all —
|
||||||
# even 0.0 — returns HTTP 400. Earlier Claude models (Opus 4.6 and below, every
|
# even 0.0 — returns HTTP 400. Earlier Claude models (Opus 4.6 and below, every
|
||||||
@@ -1138,7 +1327,7 @@ def list_model_ids(
|
|||||||
from src.endpoint_resolver import build_models_url
|
from src.endpoint_resolver import build_models_url
|
||||||
|
|
||||||
models_url = build_models_url(base_chat_url)
|
models_url = build_models_url(base_chat_url)
|
||||||
r = httpx.get(models_url, headers=h, timeout=timeout)
|
r = httpx_get_kimi_aware(models_url, h, timeout=timeout)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
model_ids = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||||
@@ -1239,14 +1428,14 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
|||||||
"messages": messages_copy,
|
"messages": messages_copy,
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
}
|
}
|
||||||
if _restricts_temperature(model):
|
if _omit_temperature(provider, model):
|
||||||
payload.pop("temperature", None)
|
payload.pop("temperature", None)
|
||||||
if max_tokens and max_tokens > 0:
|
if max_tokens and max_tokens > 0:
|
||||||
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
||||||
payload[tok_key] = max_tokens
|
payload[tok_key] = max_tokens
|
||||||
try:
|
try:
|
||||||
note_model_activity(target_url, model)
|
note_model_activity(target_url, model)
|
||||||
r = httpx.post(target_url, headers=h, json=payload, timeout=timeout)
|
r = httpx_post_kimi_aware(target_url, h, json=payload, timeout=timeout)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(502, f"POST {target_url} failed: {e}")
|
raise HTTPException(502, f"POST {target_url} failed: {e}")
|
||||||
if not r.is_success:
|
if not r.is_success:
|
||||||
@@ -1433,7 +1622,7 @@ async def llm_call_async(
|
|||||||
"messages": messages_copy,
|
"messages": messages_copy,
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
}
|
}
|
||||||
if _restricts_temperature(model):
|
if _omit_temperature(provider, model):
|
||||||
payload.pop("temperature", None)
|
payload.pop("temperature", None)
|
||||||
if max_tokens and max_tokens > 0:
|
if max_tokens and max_tokens > 0:
|
||||||
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
tok_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens"
|
||||||
@@ -1446,7 +1635,7 @@ async def llm_call_async(
|
|||||||
if _is_host_dead(target_url):
|
if _is_host_dead(target_url):
|
||||||
raise HTTPException(503, f"Upstream {_host_key(target_url)} marked unreachable (cooldown active)")
|
raise HTTPException(503, f"Upstream {_host_key(target_url)} marked unreachable (cooldown active)")
|
||||||
|
|
||||||
call_timeout = httpx.Timeout(connect=3.0, read=float(timeout), write=10.0, pool=5.0)
|
call_timeout = _call_timeout(timeout)
|
||||||
attempt = 0
|
attempt = 0
|
||||||
while attempt < max_retries:
|
while attempt < max_retries:
|
||||||
attempt += 1
|
attempt += 1
|
||||||
@@ -1454,7 +1643,7 @@ async def llm_call_async(
|
|||||||
try:
|
try:
|
||||||
note_model_activity(target_url, model)
|
note_model_activity(target_url, model)
|
||||||
client = _get_http_client()
|
client = _get_http_client()
|
||||||
r = await client.post(target_url, headers=h, json=payload, timeout=call_timeout)
|
r = await httpx_post_kimi_aware_async(client, target_url, h, json=payload, timeout=call_timeout)
|
||||||
duration = time.time() - start
|
duration = time.time() - start
|
||||||
if not r.is_success:
|
if not r.is_success:
|
||||||
friendly = _format_upstream_error(r.status_code, r.text, target_url)
|
friendly = _format_upstream_error(r.status_code, r.text, target_url)
|
||||||
@@ -1550,7 +1739,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
|||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
"stream": True,
|
"stream": True,
|
||||||
}
|
}
|
||||||
if _restricts_temperature(model):
|
if _omit_temperature(provider, model):
|
||||||
payload.pop("temperature", None)
|
payload.pop("temperature", None)
|
||||||
if provider not in {"openrouter", "groq"}:
|
if provider not in {"openrouter", "groq"}:
|
||||||
payload["stream_options"] = {"include_usage": True}
|
payload["stream_options"] = {"include_usage": True}
|
||||||
@@ -1570,9 +1759,12 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
|||||||
from src.copilot import apply_request_headers
|
from src.copilot import apply_request_headers
|
||||||
apply_request_headers(h, messages_copy)
|
apply_request_headers(h, messages_copy)
|
||||||
|
|
||||||
# Short connect timeout: a reachable peer answers SYN in <100ms even on
|
# Connect budget from LLMConfig.CONNECT_TIMEOUT (env LLM_CONNECT_TIMEOUT).
|
||||||
# Tailscale. 3s is plenty; 30s let one dead upstream wedge the UI.
|
# The dead-host cooldown still bounds a genuinely unreachable upstream, so a
|
||||||
stream_timeout = httpx.Timeout(connect=3.0, read=float(timeout), write=30.0, pool=5.0)
|
# wider connect budget only affects first contact and stops a brief cold
|
||||||
|
# connect blip (offshore/public endpoints) surfacing as a 503 on this stream
|
||||||
|
# path, which -- unlike llm_call -- does not retry the connect.
|
||||||
|
stream_timeout = _stream_timeout(timeout)
|
||||||
|
|
||||||
if _is_host_dead(target_url):
|
if _is_host_dead(target_url):
|
||||||
yield f'event: error\ndata: {json.dumps({"error": f"Upstream {_host_key(target_url)} unreachable (cooldown active)", "status": 503})}\n\n'
|
yield f'event: error\ndata: {json.dumps({"error": f"Upstream {_host_key(target_url)} unreachable (cooldown active)", "status": 503})}\n\n'
|
||||||
@@ -1848,6 +2040,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
|||||||
events.append(_stream_delta_event(part))
|
events.append(_stream_delta_event(part))
|
||||||
return events
|
return events
|
||||||
|
|
||||||
|
h = apply_kimi_code_headers(h, target_url)
|
||||||
try:
|
try:
|
||||||
client = _get_http_client()
|
client = _get_http_client()
|
||||||
async with client.stream('POST', target_url, json=payload, headers=h, timeout=stream_timeout) as r:
|
async with client.stream('POST', target_url, json=payload, headers=h, timeout=stream_timeout) as r:
|
||||||
|
|||||||
+56
-22
@@ -222,16 +222,12 @@ KNOWN_CONTEXT_WINDOWS = {
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Cache
|
# Cache
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
_context_cache: Dict[Tuple[str, str], int] = {}
|
_context_cache: Dict[Tuple[str, str], Tuple[int, bool]] = {}
|
||||||
|
|
||||||
|
|
||||||
def get_context_length(endpoint_url: str, model: str) -> int:
|
def _get_context_length_cached(endpoint_url: str, model: str) -> Tuple[int, bool]:
|
||||||
"""Get the context window size for a model.
|
"""Return (context_length, known). ``known`` is False only when the value is a
|
||||||
|
bare DEFAULT_CONTEXT fallback (no endpoint report and not in the known table)."""
|
||||||
Queries /v1/models on the endpoint and looks for context_length
|
|
||||||
or context_window fields. Caches result per (endpoint, model).
|
|
||||||
Falls back to DEFAULT_CONTEXT if unavailable.
|
|
||||||
"""
|
|
||||||
configured_kind = _configured_endpoint_kind(endpoint_url)
|
configured_kind = _configured_endpoint_kind(endpoint_url)
|
||||||
is_local = is_local_endpoint(endpoint_url)
|
is_local = is_local_endpoint(endpoint_url)
|
||||||
# Key on (endpoint_url, model): the same model id can be served by two
|
# Key on (endpoint_url, model): the same model id can be served by two
|
||||||
@@ -242,14 +238,50 @@ def get_context_length(endpoint_url: str, model: str) -> int:
|
|||||||
if not is_local and cache_key in _context_cache:
|
if not is_local and cache_key in _context_cache:
|
||||||
return _context_cache[cache_key]
|
return _context_cache[cache_key]
|
||||||
|
|
||||||
ctx = _query_context_length(endpoint_url, model)
|
ctx, known = _query_context_length(endpoint_url, model)
|
||||||
# Only cache non-default values to allow retry on next request.
|
# Only cache non-default values to allow retry on next request.
|
||||||
# Local endpoints can restart with a different --max-model-len while keeping
|
# Local endpoints can restart with a different --max-model-len while keeping
|
||||||
# the same model id, so always re-query them instead of serving stale cache.
|
# the same model id, so always re-query them instead of serving stale cache.
|
||||||
if not is_local and (ctx != DEFAULT_CONTEXT or configured_kind in ("api", "proxy")):
|
if not is_local and (ctx != DEFAULT_CONTEXT or configured_kind in ("api", "proxy")):
|
||||||
_context_cache[cache_key] = ctx
|
_context_cache[cache_key] = (ctx, known)
|
||||||
logger.info(f"Context length for {model}: {ctx}")
|
logger.info(f"Context length for {model}: {ctx}")
|
||||||
return ctx
|
return ctx, known
|
||||||
|
|
||||||
|
|
||||||
|
def get_context_length(endpoint_url: str, model: str) -> int:
|
||||||
|
"""Get the context window size for a model.
|
||||||
|
|
||||||
|
Queries /v1/models on the endpoint and looks for context_length
|
||||||
|
or context_window fields. Caches result per (endpoint, model).
|
||||||
|
Falls back to DEFAULT_CONTEXT if unavailable.
|
||||||
|
"""
|
||||||
|
return _get_context_length_cached(endpoint_url, model)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def get_context_length_known(endpoint_url: str, model: str) -> Tuple[int, bool]:
|
||||||
|
"""Like ``get_context_length`` but also returns whether the window was actually
|
||||||
|
discovered (endpoint-reported or in the known-models table) rather than the bare
|
||||||
|
DEFAULT_CONTEXT fallback. Callers that *scale* a budget off the window must not
|
||||||
|
trust an unknown value — a fallback 128K isn't proof the model holds 128K
|
||||||
|
(review on #4122)."""
|
||||||
|
return _get_context_length_cached(endpoint_url, model)
|
||||||
|
|
||||||
|
|
||||||
|
def budget_context_for_model(endpoint_url: str, model: str, *, fallback: int = 0) -> int:
|
||||||
|
"""Context window to scale the agent input budget against.
|
||||||
|
|
||||||
|
Returns the *freshly discovered* window when it was actually proven
|
||||||
|
(endpoint-reported / known table), else 0 so auto-scaling stays conservative.
|
||||||
|
Crucially this binds the ``known`` flag to the value it proves — callers must
|
||||||
|
not pair this flag with a context length from a *different* lookup (a stale
|
||||||
|
local re-query, or a caller that didn't pass one), which would budget off an
|
||||||
|
unproven number (review on #4122). On probe error, returns ``fallback`` (the
|
||||||
|
caller's best-known value) to preserve prior behaviour."""
|
||||||
|
try:
|
||||||
|
ctx, known = get_context_length_known(endpoint_url, model)
|
||||||
|
return ctx if known else 0
|
||||||
|
except Exception:
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
def _lookup_known(model: str) -> Optional[int]:
|
def _lookup_known(model: str) -> Optional[int]:
|
||||||
@@ -271,8 +303,9 @@ def _lookup_known(model: str) -> Optional[int]:
|
|||||||
return best_ctx
|
return best_ctx
|
||||||
|
|
||||||
|
|
||||||
def _query_context_length(endpoint_url: str, model: str) -> int:
|
def _query_context_length(endpoint_url: str, model: str) -> Tuple[int, bool]:
|
||||||
"""Query the model API for context length."""
|
"""Query the model API for context length. Returns (context_length, known) where
|
||||||
|
``known`` is False only for the bare DEFAULT_CONTEXT fallback."""
|
||||||
known = _lookup_known(model)
|
known = _lookup_known(model)
|
||||||
api_ctx = None
|
api_ctx = None
|
||||||
configured_kind = _configured_endpoint_kind(endpoint_url)
|
configured_kind = _configured_endpoint_kind(endpoint_url)
|
||||||
@@ -283,8 +316,8 @@ def _query_context_length(endpoint_url: str, model: str) -> int:
|
|||||||
if configured_kind in ("api", "proxy"):
|
if configured_kind in ("api", "proxy"):
|
||||||
if known:
|
if known:
|
||||||
logger.info(f"Using known context window for {model}: {known}")
|
logger.info(f"Using known context window for {model}: {known}")
|
||||||
return known
|
return known, True
|
||||||
return DEFAULT_CONTEXT
|
return DEFAULT_CONTEXT, False
|
||||||
|
|
||||||
# Try llama.cpp /slots endpoint first — reports actual serving context
|
# Try llama.cpp /slots endpoint first — reports actual serving context
|
||||||
if is_local_endpoint(endpoint_url):
|
if is_local_endpoint(endpoint_url):
|
||||||
@@ -297,7 +330,7 @@ def _query_context_length(endpoint_url: str, model: str) -> int:
|
|||||||
n_ctx = slots[0].get("n_ctx")
|
n_ctx = slots[0].get("n_ctx")
|
||||||
if n_ctx and isinstance(n_ctx, int) and n_ctx > 0:
|
if n_ctx and isinstance(n_ctx, int) and n_ctx > 0:
|
||||||
logger.info(f"llama.cpp /slots reports n_ctx={n_ctx} for {model}")
|
logger.info(f"llama.cpp /slots reports n_ctx={n_ctx} for {model}")
|
||||||
return n_ctx
|
return n_ctx, True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -309,7 +342,8 @@ def _query_context_length(endpoint_url: str, model: str) -> int:
|
|||||||
if is_copilot_base(endpoint_url):
|
if is_copilot_base(endpoint_url):
|
||||||
if known:
|
if known:
|
||||||
logger.info(f"Using known context window for {model}: {known}")
|
logger.info(f"Using known context window for {model}: {known}")
|
||||||
return known or DEFAULT_CONTEXT
|
return known, True
|
||||||
|
return DEFAULT_CONTEXT, False
|
||||||
|
|
||||||
from src.endpoint_resolver import build_models_url
|
from src.endpoint_resolver import build_models_url
|
||||||
|
|
||||||
@@ -354,18 +388,18 @@ def _query_context_length(endpoint_url: str, model: str) -> int:
|
|||||||
_is_local = is_local_endpoint(endpoint_url)
|
_is_local = is_local_endpoint(endpoint_url)
|
||||||
if _is_local and api_ctx < known:
|
if _is_local and api_ctx < known:
|
||||||
logger.info(f"Local endpoint reports {api_ctx} for {model} (known max: {known}) — using API value")
|
logger.info(f"Local endpoint reports {api_ctx} for {model} (known max: {known}) — using API value")
|
||||||
return api_ctx
|
return api_ctx, True
|
||||||
result = max(api_ctx, known)
|
result = max(api_ctx, known)
|
||||||
if api_ctx < known:
|
if api_ctx < known:
|
||||||
logger.info(f"API reported {api_ctx} for {model}, using known {known} instead")
|
logger.info(f"API reported {api_ctx} for {model}, using known {known} instead")
|
||||||
return result
|
return result, True
|
||||||
if api_ctx:
|
if api_ctx:
|
||||||
return api_ctx
|
return api_ctx, True
|
||||||
if known:
|
if known:
|
||||||
logger.info(f"Using known context window for {model}: {known}")
|
logger.info(f"Using known context window for {model}: {known}")
|
||||||
return known
|
return known, True
|
||||||
|
|
||||||
return DEFAULT_CONTEXT
|
return DEFAULT_CONTEXT, False
|
||||||
|
|
||||||
|
|
||||||
def estimate_tokens(messages: List[Dict]) -> int:
|
def estimate_tokens(messages: List[Dict]) -> int:
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Compatibility helpers for optional third-party dependencies."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
|
||||||
|
def patch_realesrgan_torchvision_compat() -> None:
|
||||||
|
"""Restore the torchvision import path expected by BasicSR/Real-ESRGAN."""
|
||||||
|
module_name = "torchvision.transforms.functional_tensor"
|
||||||
|
if module_name in sys.modules:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from torchvision.transforms import functional
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
rgb_to_grayscale = getattr(functional, "rgb_to_grayscale", None)
|
||||||
|
if rgb_to_grayscale is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
shim = types.ModuleType(module_name)
|
||||||
|
shim.rgb_to_grayscale = rgb_to_grayscale
|
||||||
|
shim.__getattr__ = lambda name: getattr(functional, name)
|
||||||
|
sys.modules[module_name] = shim
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_optional_dependency_import(name: str) -> None:
|
||||||
|
"""Apply known import-time compatibility shims before probing a package."""
|
||||||
|
if name == "realesrgan":
|
||||||
|
patch_realesrgan_torchvision_compat()
|
||||||
+18
-8
@@ -101,14 +101,22 @@ DEFAULT_SETTINGS = {
|
|||||||
"research_run_timeout_seconds": 1800,
|
"research_run_timeout_seconds": 1800,
|
||||||
"agent_max_tool_calls": 0,
|
"agent_max_tool_calls": 0,
|
||||||
"agent_max_rounds": 20, # per-message agent step cap (clamped 1..200)
|
"agent_max_rounds": 20, # per-message agent step cap (clamped 1..200)
|
||||||
|
# Soft input-token budget for the agent loop. The DEFAULT value (6000) is the
|
||||||
|
# "auto" sentinel: it means "scale the budget to the model's context window"
|
||||||
|
# (#1230) — so long-context models aren't capped at 6000. Set ANY OTHER value
|
||||||
|
# to enforce an explicit cap (clamped to the window only — hard_max does not
|
||||||
|
# apply to explicit budgets, #1230); set 0 to disable soft-trimming. The
|
||||||
|
# default is treated as auto because the settings-save path materializes
|
||||||
|
# defaults, so a persisted 6000 can't be told apart from a deliberate 6000 —
|
||||||
|
# to pin a budget near the default, use a nearby value (e.g. 5999).
|
||||||
"agent_input_token_budget": 6000,
|
"agent_input_token_budget": 6000,
|
||||||
# Ceiling on the *auto-derived* input budget that #1230 introduced. Has
|
# Ceiling on the *auto-derived* input budget; a configurable setting since #1273
|
||||||
# no effect when `agent_input_token_budget` is explicitly set (the user's
|
# (the merged #1230 left it a module constant). No effect on an explicit budget
|
||||||
# value is honoured regardless). Default matches
|
# — a deliberate value is honoured (#1230). Default matches
|
||||||
# `src.context_budget.DEFAULT_HARD_MAX`; lower this for cost-paranoid
|
# `src.context_budget.DEFAULT_HARD_MAX`; lower this for
|
||||||
# setups, raise it on premium APIs with very large windows that you
|
# cost-paranoid setups, raise it on premium APIs with very large windows you
|
||||||
# want to actually use (e.g. 900_000 to fill a 1M-context model). See
|
# want to actually use (e.g. 900_000 to fill a 1M-context model). See
|
||||||
# `compute_input_token_budget` in src/context_budget.py.
|
# `compute_input_token_budget`.
|
||||||
"agent_input_token_hard_max": 200_000,
|
"agent_input_token_hard_max": 200_000,
|
||||||
"agent_stream_timeout_seconds": 300,
|
"agent_stream_timeout_seconds": 300,
|
||||||
# Extra directory roots that read_file / write_file may access, in
|
# Extra directory roots that read_file / write_file may access, in
|
||||||
@@ -223,8 +231,10 @@ def is_setting_overridden(key: str) -> bool:
|
|||||||
|
|
||||||
``load_settings`` merges DEFAULT_SETTINGS with the saved file, so a value
|
``load_settings`` merges DEFAULT_SETTINGS with the saved file, so a value
|
||||||
equal to its default is indistinguishable from "never set" via get_setting.
|
equal to its default is indistinguishable from "never set" via get_setting.
|
||||||
Callers that need to treat an explicit user choice differently from the
|
Callers that must distinguish an explicit user choice from a default read
|
||||||
default (e.g. adaptive budgets) use this to read the raw saved file.
|
the raw saved file via this. (Note: a materialized default is also "present",
|
||||||
|
so value-sensitive callers should compare against the default — see
|
||||||
|
``context_budget.budget_is_explicit``.)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
|
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
|
||||||
|
|||||||
@@ -1649,6 +1649,8 @@ class TaskScheduler:
|
|||||||
data = json.loads(event_str[6:])
|
data = json.loads(event_str[6:])
|
||||||
# Capture text from all event types, not just delta
|
# Capture text from all event types, not just delta
|
||||||
if "delta" in data:
|
if "delta" in data:
|
||||||
|
if data.get("thinking"):
|
||||||
|
continue
|
||||||
full_text += data["delta"]
|
full_text += data["delta"]
|
||||||
elif data.get("type") == "tool_output":
|
elif data.get("type") == "tool_output":
|
||||||
# Tool results — capture summary so we have SOMETHING even
|
# Tool results — capture summary so we have SOMETHING even
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ _SOTA_HOSTS = frozenset({
|
|||||||
"api.together.xyz", "api.fireworks.ai",
|
"api.together.xyz", "api.fireworks.ai",
|
||||||
"api.perplexity.ai", "api.x.ai",
|
"api.perplexity.ai", "api.x.ai",
|
||||||
"generativelanguage.googleapis.com", "api.groq.com",
|
"generativelanguage.googleapis.com", "api.groq.com",
|
||||||
"openrouter.ai", "ollama.com", "api.venice.ai",
|
"openrouter.ai", "ollama.com", "api.venice.ai", "api.kimi.com",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -594,6 +594,8 @@ async def run_teacher_inline(
|
|||||||
"exit_code": payload.get("exit_code"),
|
"exit_code": payload.get("exit_code"),
|
||||||
})
|
})
|
||||||
if "delta" in payload and isinstance(payload["delta"], str):
|
if "delta" in payload and isinstance(payload["delta"], str):
|
||||||
|
if payload.get("thinking"):
|
||||||
|
continue
|
||||||
captured_text_parts.append(payload["delta"])
|
captured_text_parts.append(payload["delta"])
|
||||||
yield 'data: ' + json.dumps(payload) + '\n\n'
|
yield 'data: ' + json.dumps(payload) + '\n\n'
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1445,7 +1445,15 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
"""Handle manage_calendar tool calls: list/create/update/delete calendar events (local SQLite)."""
|
"""Handle manage_calendar tool calls: list/create/update/delete calendar events (local SQLite)."""
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from core.database import SessionLocal, CalendarCal, CalendarEvent, Note
|
from core.database import SessionLocal, CalendarCal, CalendarEvent, Note
|
||||||
from routes.calendar_routes import _ensure_default_calendar, _parse_dt, _parse_dt_pair, parse_due_for_user, _resolve_base_uid
|
from routes.calendar_routes import (
|
||||||
|
_ensure_default_calendar,
|
||||||
|
_parse_dt,
|
||||||
|
_parse_dt_pair,
|
||||||
|
parse_due_for_user,
|
||||||
|
_resolve_base_uid,
|
||||||
|
_push_caldav_event_after_commit,
|
||||||
|
_record_caldav_delete_tombstone,
|
||||||
|
)
|
||||||
import uuid as _uuid
|
import uuid as _uuid
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -1643,6 +1651,9 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return {"error": f"Invalid date format: {e}", "exit_code": 1}
|
return {"error": f"Invalid date format: {e}", "exit_code": 1}
|
||||||
|
|
||||||
|
if end_dt <= start_dt:
|
||||||
|
end_dt = start_dt + timedelta(days=1)
|
||||||
|
|
||||||
q = _event_query().filter(
|
q = _event_query().filter(
|
||||||
CalendarEvent.dtstart < end_dt,
|
CalendarEvent.dtstart < end_dt,
|
||||||
CalendarEvent.dtend > start_dt,
|
CalendarEvent.dtend > start_dt,
|
||||||
@@ -1822,6 +1833,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
rrule=args.get("rrule", "") or "",
|
rrule=args.get("rrule", "") or "",
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
importance=importance,
|
importance=importance,
|
||||||
|
caldav_sync_pending="create" if cal.source == "caldav" else None,
|
||||||
)
|
)
|
||||||
db.add(ev)
|
db.add(ev)
|
||||||
reminder_note_id = None
|
reminder_note_id = None
|
||||||
@@ -1836,6 +1848,8 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
dtstart_is_utc and not all_day,
|
dtstart_is_utc and not all_day,
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
if cal.source == "caldav":
|
||||||
|
await _push_caldav_event_after_commit(owner, uid, "create")
|
||||||
tag_blurb = f" [{event_type}]" if event_type else ""
|
tag_blurb = f" [{event_type}]" if event_type else ""
|
||||||
if minutes_before is None:
|
if minutes_before is None:
|
||||||
reminder_blurb = ""
|
reminder_blurb = ""
|
||||||
@@ -1893,7 +1907,12 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
ev.event_type = _tag or None
|
ev.event_type = _tag or None
|
||||||
if args.get("importance") is not None:
|
if args.get("importance") is not None:
|
||||||
ev.importance = args["importance"]
|
ev.importance = args["importance"]
|
||||||
|
is_caldav = ev.calendar and ev.calendar.source == "caldav"
|
||||||
|
if is_caldav:
|
||||||
|
ev.caldav_sync_pending = "update"
|
||||||
db.commit()
|
db.commit()
|
||||||
|
if is_caldav:
|
||||||
|
await _push_caldav_event_after_commit(owner, base_uid, "update")
|
||||||
return {"response": f"Updated event {uid}", "exit_code": 0}
|
return {"response": f"Updated event {uid}", "exit_code": 0}
|
||||||
|
|
||||||
elif action == "delete_event":
|
elif action == "delete_event":
|
||||||
@@ -1907,8 +1926,13 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None) -> Dict:
|
|||||||
ev = _event_query().filter(CalendarEvent.uid == base_uid).first()
|
ev = _event_query().filter(CalendarEvent.uid == base_uid).first()
|
||||||
if not ev:
|
if not ev:
|
||||||
return {"error": f"Event {uid} not found", "exit_code": 1}
|
return {"error": f"Event {uid} not found", "exit_code": 1}
|
||||||
|
is_caldav = ev.calendar and ev.calendar.source == "caldav" and ev.remote_href
|
||||||
|
if is_caldav:
|
||||||
|
_record_caldav_delete_tombstone(db, ev, owner)
|
||||||
db.delete(ev)
|
db.delete(ev)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
if is_caldav:
|
||||||
|
await _push_caldav_event_after_commit(owner, base_uid, "delete")
|
||||||
return {"response": f"Deleted event {uid}", "exit_code": 0}
|
return {"response": f"Deleted event {uid}", "exit_code": 0}
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -384,6 +384,10 @@ class ToolIndex:
|
|||||||
"delegate to", "have model"}):
|
"delegate to", "have model"}):
|
||||||
{"chat_with_model", "ask_teacher", "list_models"},
|
{"chat_with_model", "ask_teacher", "list_models"},
|
||||||
# Deep research intent (incl. common typo "reserach")
|
# Deep research intent (incl. common typo "reserach")
|
||||||
|
frozenset({"web search", "search the web", "search online", "look up",
|
||||||
|
"google", "latest", "current", "news", "weather",
|
||||||
|
"forecast", "stock price", "price of"}):
|
||||||
|
{"web_search", "web_fetch"},
|
||||||
frozenset({"research", "reserach", "reasearch", "look into", "investigate",
|
frozenset({"research", "reserach", "reasearch", "look into", "investigate",
|
||||||
"deep dive", "deep research", "find out about", "study up on",
|
"deep dive", "deep research", "find out about", "study up on",
|
||||||
"report on", "do research", "look up everything"}):
|
"report on", "do research", "look up everything"}):
|
||||||
|
|||||||
@@ -188,6 +188,12 @@ _MISFENCED_WEB_TOOL_NAMES = {
|
|||||||
"fetch_url": "web_fetch",
|
"fetch_url": "web_fetch",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_RAW_WEB_JSON_TOOL_RE = re.compile(
|
||||||
|
r"\b(?:web_search|websearch|google_search|google_search_retrieval|google_search_grounding)\b",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_RAW_WEB_JSON_ALLOWED_KEYS = {"query", "queries", "time_filter", "freshness", "max_pages"}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Parsing functions
|
# Parsing functions
|
||||||
@@ -279,6 +285,73 @@ def _parse_misfenced_web_lookup(content: str) -> Optional[ToolBlock]:
|
|||||||
return None
|
return None
|
||||||
return ToolBlock("web_fetch", url)
|
return ToolBlock("web_fetch", url)
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_raw_web_query(value) -> Optional[str]:
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
if isinstance(value, list):
|
||||||
|
for item in value:
|
||||||
|
if isinstance(item, str) and item.strip():
|
||||||
|
return item.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_web_json_to_tool_block(payload) -> Optional[ToolBlock]:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
if set(payload) - _RAW_WEB_JSON_ALLOWED_KEYS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
query = _coerce_raw_web_query(payload.get("query"))
|
||||||
|
if not query:
|
||||||
|
query = _coerce_raw_web_query(payload.get("queries"))
|
||||||
|
if not query:
|
||||||
|
return None
|
||||||
|
|
||||||
|
content = {"query": query}
|
||||||
|
for key in ("time_filter", "freshness"):
|
||||||
|
value = payload.get(key)
|
||||||
|
if isinstance(value, str) and value.strip().lower() in ("day", "week", "month", "year"):
|
||||||
|
content[key] = value.strip().lower()
|
||||||
|
|
||||||
|
max_pages = payload.get("max_pages")
|
||||||
|
if isinstance(max_pages, int) and 1 <= max_pages <= 10:
|
||||||
|
content["max_pages"] = max_pages
|
||||||
|
|
||||||
|
if len(content) == 1:
|
||||||
|
return ToolBlock("web_search", query)
|
||||||
|
return ToolBlock("web_search", json.dumps(content))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_raw_web_json_lookup(text: str) -> Optional[tuple[ToolBlock, tuple[int, int]]]:
|
||||||
|
"""Recover local text-model web_search calls emitted as prose + bare JSON.
|
||||||
|
|
||||||
|
Some non-native tool models leak the intended call as:
|
||||||
|
|
||||||
|
Need to do web_search for ...
|
||||||
|
{"query": "...", "time_filter": "week"}
|
||||||
|
|
||||||
|
Keep this narrower than fenced/tool markup: it only runs when a known web
|
||||||
|
tool name appears shortly before a JSON object shaped like web_search args.
|
||||||
|
"""
|
||||||
|
if not isinstance(text, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
decoder = json.JSONDecoder()
|
||||||
|
for mention in _RAW_WEB_JSON_TOOL_RE.finditer(text):
|
||||||
|
search_start = mention.end()
|
||||||
|
search_end = min(len(text), search_start + 1200)
|
||||||
|
for brace in re.finditer(r"\{", text[search_start:search_end]):
|
||||||
|
start = search_start + brace.start()
|
||||||
|
try:
|
||||||
|
parsed, end = decoder.raw_decode(text[start:])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
block = _raw_web_json_to_tool_block(parsed)
|
||||||
|
if block:
|
||||||
|
return block, (start, start + end)
|
||||||
|
return None
|
||||||
|
|
||||||
def _parse_tool_call_block(raw: str) -> Optional[ToolBlock]:
|
def _parse_tool_call_block(raw: str) -> Optional[ToolBlock]:
|
||||||
"""Parse a [TOOL_CALL] block into a ToolBlock.
|
"""Parse a [TOOL_CALL] block into a ToolBlock.
|
||||||
|
|
||||||
@@ -436,6 +509,8 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
|||||||
3. XML-style <tool_call>/<invoke> blocks
|
3. XML-style <tool_call>/<invoke> blocks
|
||||||
4. <tool_code> blocks (MiniMax-M2.5 style)
|
4. <tool_code> blocks (MiniMax-M2.5 style)
|
||||||
5. DeepSeek DSML markup (normalized to <invoke> first)
|
5. DeepSeek DSML markup (normalized to <invoke> first)
|
||||||
|
6. Non-native local model fallback: prose mentioning web_search followed by
|
||||||
|
bare JSON args, e.g. {"query":"...", "time_filter":"week"}
|
||||||
|
|
||||||
`skip_fenced`: when True, Pattern 1 (fenced ```bash/```python/```json code
|
`skip_fenced`: when True, Pattern 1 (fenced ```bash/```python/```json code
|
||||||
blocks) is not matched at all. Native function-calling models (GPT/Claude/
|
blocks) is not matched at all. Native function-calling models (GPT/Claude/
|
||||||
@@ -509,6 +584,12 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
|||||||
if block:
|
if block:
|
||||||
blocks.append(block)
|
blocks.append(block)
|
||||||
|
|
||||||
|
# Pattern 6: local text-model web_search call leaked as prose + bare JSON.
|
||||||
|
if not blocks and not skip_fenced:
|
||||||
|
raw_web_json = _parse_raw_web_json_lookup(text)
|
||||||
|
if raw_web_json:
|
||||||
|
blocks.append(raw_web_json[0])
|
||||||
|
|
||||||
return blocks
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
@@ -532,6 +613,11 @@ def strip_tool_blocks(text: str, skip_fenced: bool = False) -> str:
|
|||||||
cleaned = _TOOL_CALL_RE.sub('', cleaned)
|
cleaned = _TOOL_CALL_RE.sub('', cleaned)
|
||||||
cleaned = _XML_TOOL_CALL_RE.sub('', cleaned)
|
cleaned = _XML_TOOL_CALL_RE.sub('', cleaned)
|
||||||
cleaned = _TOOL_CODE_RE.sub('', cleaned)
|
cleaned = _TOOL_CODE_RE.sub('', cleaned)
|
||||||
|
if not skip_fenced:
|
||||||
|
raw_web_json = _parse_raw_web_json_lookup(cleaned)
|
||||||
|
if raw_web_json:
|
||||||
|
_, (start, end) = raw_web_json
|
||||||
|
cleaned = cleaned[:start] + cleaned[end:]
|
||||||
# Strip bare <invoke> blocks not wrapped in <tool_call>
|
# Strip bare <invoke> blocks not wrapped in <tool_call>
|
||||||
cleaned = re.sub(r'<invoke\s+name=["\'].*?</invoke>', '', cleaned, flags=re.DOTALL | re.IGNORECASE)
|
cleaned = re.sub(r'<invoke\s+name=["\'].*?</invoke>', '', cleaned, flags=re.DOTALL | re.IGNORECASE)
|
||||||
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned)
|
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned)
|
||||||
|
|||||||
@@ -177,13 +177,16 @@ def owner_is_admin_or_single_user(owner: Optional[str]) -> bool:
|
|||||||
defense-in-depth for callers that bypass it (e.g. trusted loopback).
|
defense-in-depth for callers that bypass it (e.g. trusted loopback).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
from src.auth_helpers import _auth_disabled
|
||||||
|
|
||||||
|
if _auth_disabled():
|
||||||
|
return True
|
||||||
|
|
||||||
from core.auth import AuthManager
|
from core.auth import AuthManager
|
||||||
|
|
||||||
auth = AuthManager()
|
auth = AuthManager()
|
||||||
if not auth.is_configured:
|
if not auth.is_configured:
|
||||||
from src.auth_helpers import _auth_disabled
|
return False
|
||||||
|
|
||||||
return _auth_disabled()
|
|
||||||
return bool(owner and auth.is_admin(owner))
|
return bool(owner and auth.is_admin(owner))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Unable to evaluate owner admin status: %s", exc)
|
logger.warning("Unable to evaluate owner admin status: %s", exc)
|
||||||
|
|||||||
+18
-273
@@ -1,278 +1,23 @@
|
|||||||
"""
|
"""Compatibility wrapper for the canonical services.youtube.youtube_handler module.
|
||||||
YouTube handling — transcript extraction, comment fetching (yt-dlp),
|
|
||||||
and context formatting for LLM injection. Used by chat_handler.py.
|
Odysseus historically carried two independent copies of the YouTube handler —
|
||||||
|
one here under ``src`` and one under ``services.youtube``. They drifted: the
|
||||||
|
comment-fetch timeout fix landed only in the ``src`` copy, while ``app.py``
|
||||||
|
calls ``services.youtube.init_youtube()`` at startup. Because the chat flow
|
||||||
|
imported ``extract_transcript_async`` from ``src.youtube_handler`` (a different
|
||||||
|
module object), the ``YOUTUBE_AVAILABLE`` / ``YouTubeTranscriptApi`` globals set
|
||||||
|
by ``init_youtube`` never reached it and transcript extraction always reported
|
||||||
|
"YouTube transcript API not available".
|
||||||
|
|
||||||
|
Keep the old ``src.youtube_handler`` import path working, but make it resolve to
|
||||||
|
the single source of truth so module state and behavior can't diverge again.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import importlib
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import shutil
|
|
||||||
import sys
|
import sys
|
||||||
import urllib.parse
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
# Import the canonical module directly (services.youtube.youtube_handler)
|
||||||
|
# without triggering the heavy services/__init__.py top-level imports.
|
||||||
|
_youtube_handler = importlib.import_module("services.youtube.youtube_handler")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
sys.modules[__name__] = _youtube_handler
|
||||||
# Constants
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
YOUTUBE_INSTRUCTION_PROMPT = """When the user shares a YouTube video, respond with a structured breakdown:
|
|
||||||
|
|
||||||
1. **Summary** — Concise overview of the video's content and main thesis (2-4 sentences)
|
|
||||||
2. **Key Points** — Bullet list of the most important topics, arguments, or moments
|
|
||||||
3. **Notable Timestamps** — If timestamps are available from the transcript, highlight 3-5 interesting moments with their approximate timestamps (e.g. "03:45 — discusses X")
|
|
||||||
4. **Audience Reception** — If comments are available, summarize what viewers think: general sentiment, top reactions, any debate or controversy
|
|
||||||
|
|
||||||
Keep it conversational and concise. Do NOT web search for this video — use only the transcript and comments provided."""
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Init / helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Will be set at startup by init_youtube()
|
|
||||||
YouTubeTranscriptApi = None
|
|
||||||
YOUTUBE_AVAILABLE = False
|
|
||||||
|
|
||||||
|
|
||||||
def _find_ytdlp() -> str:
|
|
||||||
"""Find the yt-dlp binary: venv bin first, then system PATH."""
|
|
||||||
venv_bin = Path(sys.executable).parent / "yt-dlp"
|
|
||||||
if venv_bin.exists():
|
|
||||||
return str(venv_bin)
|
|
||||||
found = shutil.which("yt-dlp")
|
|
||||||
return found or "yt-dlp"
|
|
||||||
|
|
||||||
|
|
||||||
def init_youtube():
|
|
||||||
"""Import and cache the YouTube transcript API."""
|
|
||||||
global YouTubeTranscriptApi, YOUTUBE_AVAILABLE
|
|
||||||
try:
|
|
||||||
from youtube_transcript_api import YouTubeTranscriptApi as _Api
|
|
||||||
YouTubeTranscriptApi = _Api
|
|
||||||
YOUTUBE_AVAILABLE = True
|
|
||||||
logger.info("YouTube transcript API available")
|
|
||||||
except ImportError as e:
|
|
||||||
logger.warning(f"youtube-transcript-api not installed: {e}")
|
|
||||||
YOUTUBE_AVAILABLE = False
|
|
||||||
|
|
||||||
|
|
||||||
def is_youtube_url(url: str) -> bool:
|
|
||||||
if not isinstance(url, str):
|
|
||||||
return False
|
|
||||||
return "youtube.com" in url or "youtu.be" in url
|
|
||||||
|
|
||||||
|
|
||||||
def extract_youtube_id(url: str) -> Optional[str]:
|
|
||||||
"""Extract YouTube video ID from various URL formats."""
|
|
||||||
parsed = urllib.parse.urlparse(url)
|
|
||||||
if parsed.hostname in ("www.youtube.com", "youtube.com", "m.youtube.com"):
|
|
||||||
if parsed.path == "/watch":
|
|
||||||
params = urllib.parse.parse_qs(parsed.query)
|
|
||||||
if "v" in params:
|
|
||||||
return params["v"][0]
|
|
||||||
elif parsed.path.startswith("/embed/"):
|
|
||||||
return parsed.path.split("/")[-1]
|
|
||||||
elif parsed.hostname == "youtu.be":
|
|
||||||
return parsed.path[1:]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def extract_transcript_async(
|
|
||||||
url: str, video_id: str, max_retries: int = 3
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Async YouTube transcript extraction with retries.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: Full YouTube URL
|
|
||||||
video_id: Extracted video ID
|
|
||||||
max_retries: Number of attempts
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with success/error/transcript keys
|
|
||||||
"""
|
|
||||||
if not YOUTUBE_AVAILABLE or YouTubeTranscriptApi is None:
|
|
||||||
return {"success": False, "error": "YouTube transcript API not available", "transcript": None}
|
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
|
||||||
try:
|
|
||||||
api = YouTubeTranscriptApi()
|
|
||||||
transcript = api.fetch(video_id)
|
|
||||||
transcript_list = list(transcript)
|
|
||||||
|
|
||||||
formatted = []
|
|
||||||
for snippet in transcript_list:
|
|
||||||
text = snippet.text.strip()
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
start = snippet.start
|
|
||||||
formatted.append({
|
|
||||||
"text": text,
|
|
||||||
"start": start,
|
|
||||||
"duration": snippet.duration,
|
|
||||||
"timestamp": f"{int(start // 60):02d}:{int(start % 60):02d}",
|
|
||||||
})
|
|
||||||
|
|
||||||
full_text = " ".join(e["text"] for e in formatted)
|
|
||||||
max_len = 8000
|
|
||||||
if len(full_text) > max_len:
|
|
||||||
full_text = full_text[:max_len] + "... [transcript truncated]"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"transcript": full_text,
|
|
||||||
"video_id": video_id,
|
|
||||||
"language": "en",
|
|
||||||
"is_generated": False,
|
|
||||||
"segments": formatted,
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Transcript attempt {attempt + 1} failed: {e}")
|
|
||||||
if attempt < max_retries - 1:
|
|
||||||
await asyncio.sleep(1 * (attempt + 1))
|
|
||||||
|
|
||||||
return {"success": False, "error": f"Failed after {max_retries} attempts", "transcript": None}
|
|
||||||
|
|
||||||
|
|
||||||
def format_transcript_for_context(
|
|
||||||
transcript_data: Dict[str, Any], url: str,
|
|
||||||
title: str = "", channel: str = ""
|
|
||||||
) -> str:
|
|
||||||
"""Format transcript data for inclusion in LLM context."""
|
|
||||||
if not transcript_data.get("success"):
|
|
||||||
header = ""
|
|
||||||
if title:
|
|
||||||
header = f" \"{title}\""
|
|
||||||
if channel:
|
|
||||||
header += f" by {channel}"
|
|
||||||
return f"\n[YouTube Video{header}: Transcript unavailable ({transcript_data.get('error', 'Unknown error')}). Use the comments below if available, do NOT web search for this video.]"
|
|
||||||
|
|
||||||
transcript = transcript_data.get("transcript", "")
|
|
||||||
video_id = transcript_data.get("video_id", "")
|
|
||||||
language = transcript_data.get("language", "unknown")
|
|
||||||
is_generated = transcript_data.get("is_generated", False)
|
|
||||||
segments = transcript_data.get("segments", [])
|
|
||||||
|
|
||||||
ctx = "\n[YOUTUBE VIDEO TRANSCRIPT]\n"
|
|
||||||
if title:
|
|
||||||
ctx += f"Title: {title}\n"
|
|
||||||
if channel:
|
|
||||||
ctx += f"Channel: {channel}\n"
|
|
||||||
ctx += f"Video ID: {video_id}\n"
|
|
||||||
ctx += f"Language: {language}\n"
|
|
||||||
ctx += f"Source: {'Auto-generated' if is_generated else 'Manual'}\n"
|
|
||||||
ctx += f"URL: {url}\n\n"
|
|
||||||
# Include timestamped segments for the LLM to reference
|
|
||||||
if segments:
|
|
||||||
ctx += "Timestamped Transcript:\n"
|
|
||||||
for seg in segments:
|
|
||||||
if not isinstance(seg, dict):
|
|
||||||
continue
|
|
||||||
ctx += f"[{seg['timestamp']}] {seg['text']}\n"
|
|
||||||
# Check length — fall back to plain text if too long
|
|
||||||
if len(ctx) > 12000:
|
|
||||||
ctx = ctx[:ctx.index("Timestamped Transcript:\n")]
|
|
||||||
ctx += "Transcript:\n"
|
|
||||||
ctx += transcript
|
|
||||||
else:
|
|
||||||
ctx += "Transcript:\n"
|
|
||||||
ctx += transcript
|
|
||||||
ctx += "\n[END TRANSCRIPT]\n"
|
|
||||||
return ctx
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_youtube_comments(
|
|
||||||
video_id: str, max_comments: int = 25, timeout: int = 30
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Fetch top comments for a YouTube video using yt-dlp.
|
|
||||||
|
|
||||||
Returns dict with 'success', 'comments' list, 'error'.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
cmd = [
|
|
||||||
_find_ytdlp(),
|
|
||||||
"--skip-download",
|
|
||||||
"--write-comments",
|
|
||||||
"--extractor-args", f"youtube:max_comments={max_comments},all,100,0",
|
|
||||||
"--dump-json",
|
|
||||||
"--js-runtimes", "node",
|
|
||||||
"--remote-components", "ejs:github",
|
|
||||||
f"https://www.youtube.com/watch?v={video_id}",
|
|
||||||
]
|
|
||||||
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
*cmd,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
# Bound the wait on the process actually finishing, not on spawning it.
|
|
||||||
# create_subprocess_exec returns as soon as the child starts, so wrapping
|
|
||||||
# it in wait_for never enforces the timeout — proc.communicate() is the
|
|
||||||
# blocking step. Kill and reap the child if it overruns so it does not
|
|
||||||
# linger after we return.
|
|
||||||
try:
|
|
||||||
stdout, stderr = await asyncio.wait_for(
|
|
||||||
proc.communicate(), timeout=timeout
|
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
proc.kill()
|
|
||||||
await proc.wait()
|
|
||||||
raise
|
|
||||||
|
|
||||||
if proc.returncode != 0:
|
|
||||||
return {"success": False, "error": f"yt-dlp failed: {stderr.decode()[:200]}", "comments": []}
|
|
||||||
|
|
||||||
data = json.loads(stdout.decode())
|
|
||||||
title = data.get("title", "")
|
|
||||||
channel = data.get("channel", "") or data.get("uploader", "")
|
|
||||||
raw_comments = data.get("comments", [])
|
|
||||||
|
|
||||||
comments = []
|
|
||||||
for c in raw_comments[:max_comments]:
|
|
||||||
text = (c.get("text") or "").strip()
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
comments.append({
|
|
||||||
"author": c.get("author", "Unknown"),
|
|
||||||
"text": text,
|
|
||||||
"likes": c.get("like_count", 0),
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sort by likes descending — most popular comments first
|
|
||||||
comments.sort(key=lambda x: x.get("likes", 0), reverse=True)
|
|
||||||
|
|
||||||
return {"success": True, "comments": comments, "count": len(comments),
|
|
||||||
"title": title, "channel": channel}
|
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning(f"Comment fetch timed out for {video_id}")
|
|
||||||
return {"success": False, "error": "Comment fetch timed out", "comments": []}
|
|
||||||
except FileNotFoundError:
|
|
||||||
logger.warning("yt-dlp not installed — cannot fetch comments")
|
|
||||||
return {"success": False, "error": "yt-dlp not installed", "comments": []}
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to fetch comments for {video_id}: {e}")
|
|
||||||
return {"success": False, "error": str(e), "comments": []}
|
|
||||||
|
|
||||||
|
|
||||||
def format_comments_for_context(comments_data: Dict[str, Any], url: str) -> str:
|
|
||||||
"""Format YouTube comments for inclusion in LLM context."""
|
|
||||||
if not comments_data.get("success") or not comments_data.get("comments"):
|
|
||||||
return ""
|
|
||||||
|
|
||||||
comments = comments_data["comments"]
|
|
||||||
ctx = f"\n[YOUTUBE VIDEO COMMENTS — Top {len(comments)} by popularity]\n"
|
|
||||||
ctx += f"URL: {url}\n\n"
|
|
||||||
|
|
||||||
for i, c in enumerate(comments, 1):
|
|
||||||
likes = c.get("likes", 0)
|
|
||||||
likes_str = f" [{likes} likes]" if likes else ""
|
|
||||||
ctx += f"{i}. @{c['author']}{likes_str}: {c['text']}\n\n"
|
|
||||||
|
|
||||||
if len(ctx) > 4000:
|
|
||||||
ctx = ctx[:4000] + "\n[Comments truncated]\n"
|
|
||||||
|
|
||||||
ctx += "[END COMMENTS]\n"
|
|
||||||
return ctx
|
|
||||||
|
|||||||
+3
-2
@@ -130,11 +130,12 @@ fi
|
|||||||
# 3. Python environment + dependencies (kept inside the repo, in venv/).
|
# 3. Python environment + dependencies (kept inside the repo, in venv/).
|
||||||
# Named `venv` to match the manual steps and build-macos-app.sh, so the
|
# Named `venv` to match the manual steps and build-macos-app.sh, so the
|
||||||
# clickable .app reuses this same environment.
|
# clickable .app reuses this same environment.
|
||||||
if [ ! -d venv ]; then
|
VENV_PY="./venv/bin/python3"
|
||||||
|
if [ ! -x "$VENV_PY" ] || ! "$VENV_PY" -m pip --version >/dev/null 2>&1; then
|
||||||
|
[ -d venv ] && { echo "▶ Existing venv is incomplete (no working pip) — rebuilding…"; rm -rf venv; }
|
||||||
echo "▶ Creating Python environment…"
|
echo "▶ Creating Python environment…"
|
||||||
"$PY" -m venv venv
|
"$PY" -m venv venv
|
||||||
fi
|
fi
|
||||||
VENV_PY="./venv/bin/python3"
|
|
||||||
REQ_HASH="$(md5 -q requirements.txt 2>/dev/null || md5sum requirements.txt | cut -d' ' -f1)"
|
REQ_HASH="$(md5 -q requirements.txt 2>/dev/null || md5sum requirements.txt | cut -d' ' -f1)"
|
||||||
REQ_HASH_FILE="venv/.requirements_hash"
|
REQ_HASH_FILE="venv/.requirements_hash"
|
||||||
if [ ! -f "$REQ_HASH_FILE" ] || [ "$REQ_HASH" != "$(cat "$REQ_HASH_FILE" 2>/dev/null)" ]; then
|
if [ ! -f "$REQ_HASH_FILE" ] || [ "$REQ_HASH" != "$(cat "$REQ_HASH_FILE" 2>/dev/null)" ]; then
|
||||||
|
|||||||
+6
-2
@@ -3135,7 +3135,9 @@ function initializeEventListeners() {
|
|||||||
setTimeout(() => uiModule.autoResize(textarea), 1);
|
setTimeout(() => uiModule.autoResize(textarea), 1);
|
||||||
});
|
});
|
||||||
textarea.addEventListener('keydown', (e) => {
|
textarea.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
const isMobile = window.innerWidth <= 768
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile) {
|
||||||
// If ghost autocomplete is active, accept the suggestion instead of submitting
|
// If ghost autocomplete is active, accept the suggestion instead of submitting
|
||||||
if (window._ghostAutocomplete && window._ghostAutocomplete.isActive()) {
|
if (window._ghostAutocomplete && window._ghostAutocomplete.isActive()) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -3708,7 +3710,9 @@ function startOdysseusApp() {
|
|||||||
// Enter to send (shift+enter for newline), or new chat when empty
|
// Enter to send (shift+enter for newline), or new chat when empty
|
||||||
if (messageInput) {
|
if (messageInput) {
|
||||||
messageInput.addEventListener('keydown', (e) => {
|
messageInput.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
const isMobile = window.innerWidth <= 768
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Flush the debounced icon update so dataset.mode reflects the current
|
// Flush the debounced icon update so dataset.mode reflects the current
|
||||||
// text state. Without this, a fast type-and-Enter would still see the
|
// text state. Without this, a fast type-and-Enter would still see the
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
+56
-1
@@ -12,7 +12,7 @@
|
|||||||
in email bodies — was wrapping random digits in <a href="tel:..."> with
|
in email bodies — was wrapping random digits in <a href="tel:..."> with
|
||||||
browser-default styling that didn't match the Odysseus theme. -->
|
browser-default styling that didn't match the Odysseus theme. -->
|
||||||
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
|
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
|
||||||
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||||
<script nonce="{{CSP_NONCE}}">
|
<script nonce="{{CSP_NONCE}}">
|
||||||
window._odysseusLoadTime = Date.now();
|
window._odysseusLoadTime = Date.now();
|
||||||
(function(){
|
(function(){
|
||||||
@@ -2232,6 +2232,61 @@
|
|||||||
<!-- ═══ SYSTEM TAB ═══ -->
|
<!-- ═══ SYSTEM TAB ═══ -->
|
||||||
<div data-settings-panel="system" class="hidden">
|
<div data-settings-panel="system" class="hidden">
|
||||||
|
|
||||||
|
<div class="admin-card" id="settings-system-logs-card">
|
||||||
|
<h2>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="settings-system-logs-svg">
|
||||||
|
<polyline points="4 17 10 11 4 5"></polyline>
|
||||||
|
<line x1="12" y1="19" x2="20" y2="19"></line>
|
||||||
|
</svg>
|
||||||
|
Terminal Logs
|
||||||
|
</h2>
|
||||||
|
<div class="admin-toggle-sub settings-system-logs-toggle-sub">Live diagnostic logs and system output from the Odysseus process.</div>
|
||||||
|
|
||||||
|
<div class="settings-col settings-system-logs-col">
|
||||||
|
<!-- Controls row -->
|
||||||
|
<div class="settings-system-logs-controls">
|
||||||
|
<!-- Search input -->
|
||||||
|
<input type="text" id="log-search-input" placeholder="Search logs..." class="settings-system-logs-search">
|
||||||
|
|
||||||
|
<!-- Level select -->
|
||||||
|
<select id="log-level-select" class="settings-system-logs-select">
|
||||||
|
<option value="ALL">All Levels</option>
|
||||||
|
<option value="INFO">INFO</option>
|
||||||
|
<option value="WARNING">WARNING</option>
|
||||||
|
<option value="ERROR">ERROR</option>
|
||||||
|
<option value="DEBUG">DEBUG</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Limit select -->
|
||||||
|
<select id="log-limit-select" class="settings-system-logs-select">
|
||||||
|
<option value="100">100 lines</option>
|
||||||
|
<option value="200" selected>200 lines</option>
|
||||||
|
<option value="500">500 lines</option>
|
||||||
|
<option value="1000">1000 lines</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Refresh Button -->
|
||||||
|
<button type="button" class="admin-btn-sm" id="log-refresh-btn">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="settings-system-logs-refresh-svg"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/></svg>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Auto-refresh switch -->
|
||||||
|
<div class="settings-system-logs-autopoll-container">
|
||||||
|
<label class="admin-switch" title="Auto-polling every 3 seconds">
|
||||||
|
<input type="checkbox" id="log-auto-refresh-toggle">
|
||||||
|
<span class="admin-slider"></span>
|
||||||
|
</label>
|
||||||
|
<span>Auto-poll</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Console container -->
|
||||||
|
<div id="log-console-container">
|
||||||
|
<div class="settings-system-logs-placeholder">Initializing logs terminal viewer...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>Data Backup</h2>
|
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>Data Backup</h2>
|
||||||
<div class="admin-toggle-sub" style="margin-bottom:8px">Export or import your user data (memories, presets, settings, skills, preferences) as a JSON file.</div>
|
<div class="admin-toggle-sub" style="margin-bottom:8px">Export or import your user data (memories, presets, settings, skills, preferences) as a JSON file.</div>
|
||||||
|
|||||||
+235
-2
@@ -55,6 +55,7 @@ async function loadUsers() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;align-items:center;">
|
<div style="display:flex;gap:8px;align-items:center;">
|
||||||
|
<button class="admin-btn-sm" data-adm-toggle-admin="${esc(u.username)}" data-make-admin="${u.is_admin ? '0' : '1'}" style="font-size:11px;">${u.is_admin ? 'Revoke admin' : 'Make admin'}</button>
|
||||||
<button class="admin-btn-sm" data-adm-rename-user="${esc(u.username)}" style="font-size:11px;">Rename</button>
|
<button class="admin-btn-sm" data-adm-rename-user="${esc(u.username)}" style="font-size:11px;">Rename</button>
|
||||||
${u.is_admin ? '' : `<button class="admin-btn-delete" data-adm-del-user="${esc(u.username)}" style="font-size:11px;">Remove</button>`}
|
${u.is_admin ? '' : `<button class="admin-btn-delete" data-adm-del-user="${esc(u.username)}" style="font-size:11px;">Remove</button>`}
|
||||||
${u.is_admin ? '' : '<svg class="admin-user-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3;transition:transform 0.2s,opacity 0.2s;"><polyline points="6 9 12 15 18 9"/></svg>'}
|
${u.is_admin ? '' : '<svg class="admin-user-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3;transition:transform 0.2s,opacity 0.2s;"><polyline points="6 9 12 15 18 9"/></svg>'}
|
||||||
@@ -113,7 +114,7 @@ async function loadUsers() {
|
|||||||
// Toggle panel visibility + rotate chevron + load models
|
// Toggle panel visibility + rotate chevron + load models
|
||||||
let _modelsLoaded = false;
|
let _modelsLoaded = false;
|
||||||
header.addEventListener('click', (e) => {
|
header.addEventListener('click', (e) => {
|
||||||
if (e.target.closest('.admin-btn-delete, [data-adm-rename-user]')) return;
|
if (e.target.closest('.admin-btn-delete, [data-adm-rename-user], [data-adm-toggle-admin]')) return;
|
||||||
privPanel.classList.toggle('hidden');
|
privPanel.classList.toggle('hidden');
|
||||||
const chevron = header.querySelector('.admin-user-chevron');
|
const chevron = header.querySelector('.admin-user-chevron');
|
||||||
if (chevron) {
|
if (chevron) {
|
||||||
@@ -199,6 +200,42 @@ async function loadUsers() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Promote / demote (admin toggle) — present on every row
|
||||||
|
const adminToggleBtn = row.querySelector('[data-adm-toggle-admin]');
|
||||||
|
if (adminToggleBtn) {
|
||||||
|
adminToggleBtn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const username = adminToggleBtn.dataset.admToggleAdmin;
|
||||||
|
const makeAdmin = adminToggleBtn.dataset.makeAdmin === '1';
|
||||||
|
const confirmMsg = makeAdmin
|
||||||
|
? `Grant admin rights to "${username}"? They'll get full access to all settings and users — including the power to demote or remove other admins (you included).`
|
||||||
|
: `Revoke admin rights from "${username}"? They'll lose access to the admin panel.`;
|
||||||
|
if (!await uiModule.styledConfirm(confirmMsg, { confirmText: makeAdmin ? 'Make admin' : 'Revoke admin', danger: !makeAdmin })) return;
|
||||||
|
adminToggleBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/auth/users/${encodeURIComponent(username)}/admin`, {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ is_admin: makeAdmin }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
uiModule.showError(data.detail || 'Failed to change admin status');
|
||||||
|
adminToggleBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Demoting yourself drops your own admin access — reload into the
|
||||||
|
// normal-user view (mirrors the rename-self reload above).
|
||||||
|
if (data.self) { window.location.reload(); return; }
|
||||||
|
loadUsers();
|
||||||
|
} catch (err) {
|
||||||
|
uiModule.showError('Failed to change admin status');
|
||||||
|
adminToggleBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
});
|
});
|
||||||
} catch (e) { list.innerHTML = '<div class="admin-error">Failed to load users</div>'; }
|
} catch (e) { list.innerHTML = '<div class="admin-error">Failed to load users</div>'; }
|
||||||
@@ -2488,12 +2525,206 @@ function initDangerZone() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════
|
||||||
|
TERMINAL LOGS VIEWER
|
||||||
|
═══════════════════════════════════════════ */
|
||||||
|
let logsPollInterval = null;
|
||||||
|
let isLogsPolling = false;
|
||||||
|
let cachedLogs = [];
|
||||||
|
let logsAbortController = null;
|
||||||
|
|
||||||
|
function renderLogs(isAutoPoll = false) {
|
||||||
|
const consoleContainer = el('log-console-container');
|
||||||
|
const levelSelect = el('log-level-select');
|
||||||
|
const searchInput = el('log-search-input');
|
||||||
|
|
||||||
|
if (!consoleContainer) return;
|
||||||
|
|
||||||
|
const levelFilter = levelSelect ? levelSelect.value : 'ALL';
|
||||||
|
const searchQuery = searchInput ? searchInput.value.trim().toLowerCase() : '';
|
||||||
|
|
||||||
|
let logs = cachedLogs;
|
||||||
|
|
||||||
|
// Filter by level locally
|
||||||
|
if (levelFilter !== 'ALL') {
|
||||||
|
logs = logs.filter(line => line.includes(` - ${levelFilter} - `));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search query locally
|
||||||
|
if (searchQuery) {
|
||||||
|
logs = logs.filter(line => line.toLowerCase().includes(searchQuery));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logs.length === 0) {
|
||||||
|
consoleContainer.innerHTML = '<div class="settings-system-logs-placeholder">No logs found matching current filters.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve scroll position if user is reading previous logs
|
||||||
|
const atBottom = consoleContainer.scrollHeight - consoleContainer.scrollTop - consoleContainer.clientHeight < 40;
|
||||||
|
|
||||||
|
consoleContainer.innerHTML = logs.map(line => {
|
||||||
|
let levelClass = 'log-line-default';
|
||||||
|
|
||||||
|
if (line.includes(' - INFO - ')) {
|
||||||
|
levelClass = 'log-line-info';
|
||||||
|
} else if (line.includes(' - WARNING - ')) {
|
||||||
|
levelClass = 'log-line-warning';
|
||||||
|
} else if (line.includes(' - ERROR - ') || line.includes(' - CRITICAL - ')) {
|
||||||
|
levelClass = 'log-line-error';
|
||||||
|
} else if (line.includes(' - DEBUG - ')) {
|
||||||
|
levelClass = 'log-line-debug';
|
||||||
|
}
|
||||||
|
|
||||||
|
// XSS safe escape
|
||||||
|
const escaped = line
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
|
||||||
|
return `<div class="log-line ${levelClass}">${escaped}</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
if (!isAutoPoll || atBottom) {
|
||||||
|
consoleContainer.scrollTop = consoleContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLogs(isAutoPoll = false) {
|
||||||
|
const consoleContainer = el('log-console-container');
|
||||||
|
const limitSelect = el('log-limit-select');
|
||||||
|
|
||||||
|
if (!consoleContainer) return;
|
||||||
|
|
||||||
|
const limit = limitSelect ? limitSelect.value : 200;
|
||||||
|
|
||||||
|
if (logsAbortController) {
|
||||||
|
logsAbortController.abort();
|
||||||
|
}
|
||||||
|
logsAbortController = new AbortController();
|
||||||
|
const { signal } = logsAbortController;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/diagnostics/logs?limit=${limit}`, {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (!isAutoPoll) {
|
||||||
|
consoleContainer.innerHTML = '';
|
||||||
|
const errDiv = document.createElement('div');
|
||||||
|
errDiv.style.color = 'var(--red)';
|
||||||
|
errDiv.style.fontWeight = '600';
|
||||||
|
errDiv.textContent = `Failed to load logs: HTTP ${res.status}`;
|
||||||
|
consoleContainer.appendChild(errDiv);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status !== 'success' || !data.logs) {
|
||||||
|
if (!isAutoPoll) {
|
||||||
|
consoleContainer.innerHTML = '';
|
||||||
|
const errDiv = document.createElement('div');
|
||||||
|
errDiv.style.color = 'var(--red)';
|
||||||
|
errDiv.style.fontWeight = '600';
|
||||||
|
errDiv.textContent = 'Failed to parse logs data';
|
||||||
|
consoleContainer.appendChild(errDiv);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedLogs = data.logs;
|
||||||
|
renderLogs(isAutoPoll);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
return; // Silently ignore deliberate abort
|
||||||
|
}
|
||||||
|
if (!isAutoPoll) {
|
||||||
|
consoleContainer.innerHTML = '';
|
||||||
|
const errDiv = document.createElement('div');
|
||||||
|
errDiv.style.color = 'var(--red)';
|
||||||
|
errDiv.style.fontWeight = '600';
|
||||||
|
errDiv.textContent = `Error retrieving logs: ${err.message}`;
|
||||||
|
consoleContainer.appendChild(errDiv);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (logsAbortController?.signal === signal) {
|
||||||
|
logsAbortController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLogsPolling() {
|
||||||
|
if (isLogsPolling) return;
|
||||||
|
isLogsPolling = true;
|
||||||
|
const toggle = el('log-auto-refresh-toggle');
|
||||||
|
if (toggle) toggle.checked = true;
|
||||||
|
|
||||||
|
logsPollInterval = setInterval(() => {
|
||||||
|
const modal = el('settings-modal');
|
||||||
|
const systemPanel = el('settings-modal')?.querySelector('[data-settings-panel="system"]');
|
||||||
|
|
||||||
|
// Safe self-cleanup if modal or panel is hidden/closed
|
||||||
|
if (!modal || modal.classList.contains('hidden') || !systemPanel || systemPanel.classList.contains('hidden')) {
|
||||||
|
stopLogsPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadLogs(true);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopLogsPolling() {
|
||||||
|
if (!isLogsPolling) return;
|
||||||
|
isLogsPolling = false;
|
||||||
|
if (logsPollInterval) {
|
||||||
|
clearInterval(logsPollInterval);
|
||||||
|
logsPollInterval = null;
|
||||||
|
}
|
||||||
|
const toggle = el('log-auto-refresh-toggle');
|
||||||
|
if (toggle) toggle.checked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initLogsView() {
|
||||||
|
const refreshBtn = el('log-refresh-btn');
|
||||||
|
const levelSelect = el('log-level-select');
|
||||||
|
const limitSelect = el('log-limit-select');
|
||||||
|
const searchInput = el('log-search-input');
|
||||||
|
const autoRefreshToggle = el('log-auto-refresh-toggle');
|
||||||
|
|
||||||
|
if (refreshBtn) refreshBtn.addEventListener('click', () => loadLogs(false));
|
||||||
|
if (levelSelect) levelSelect.addEventListener('change', () => renderLogs(false));
|
||||||
|
if (limitSelect) limitSelect.addEventListener('change', () => loadLogs(false));
|
||||||
|
if (searchInput) searchInput.addEventListener('input', () => renderLogs(false));
|
||||||
|
|
||||||
|
if (autoRefreshToggle) {
|
||||||
|
autoRefreshToggle.addEventListener('change', (e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
startLogsPolling();
|
||||||
|
} else {
|
||||||
|
stopLogsPolling();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial fetch on view loading
|
||||||
|
loadLogs(false);
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════
|
/* ═══════════════════════════════════════════
|
||||||
INIT & REFRESH
|
INIT & REFRESH
|
||||||
═══════════════════════════════════════════ */
|
═══════════════════════════════════════════ */
|
||||||
function initAll() {
|
function initAll() {
|
||||||
modalEl = el('settings-modal');
|
modalEl = el('settings-modal');
|
||||||
const inits = [initSignupToggle, initAddUser, initEndpointForm, initMcpForm, initCalDAV, initBackup, initDangerZone, initTokenForm, () => settingsModule.initIntegrations()];
|
const inits = [
|
||||||
|
initSignupToggle, initAddUser, initEndpointForm, initMcpForm,
|
||||||
|
initCalDAV, initBackup, initDangerZone, initTokenForm, initLogsView,
|
||||||
|
() => settingsModule.initIntegrations()
|
||||||
|
];
|
||||||
for (const fn of inits) {
|
for (const fn of inits) {
|
||||||
try { fn(); } catch (e) { console.error('Admin init error in', fn.name || 'anonymous', e); }
|
try { fn(); } catch (e) { console.error('Admin init error in', fn.name || 'anonymous', e); }
|
||||||
}
|
}
|
||||||
@@ -2507,6 +2738,7 @@ function refreshAll() {
|
|||||||
loadBuiltinTools();
|
loadBuiltinTools();
|
||||||
loadMcpServers();
|
loadMcpServers();
|
||||||
loadTokens();
|
loadTokens();
|
||||||
|
loadLogs(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════
|
/* ═══════════════════════════════════════════
|
||||||
@@ -2523,6 +2755,7 @@ export function open(tab) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function close() {
|
export function close() {
|
||||||
|
stopLogsPolling();
|
||||||
settingsModule.close();
|
settingsModule.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+153
-44
@@ -9,7 +9,7 @@ import { makeWindowDraggable } from './windowDrag.js';
|
|||||||
import { attachColorPicker } from './colorPicker.js';
|
import { attachColorPicker } from './colorPicker.js';
|
||||||
import { bindMenuDismiss } from './escMenuStack.js';
|
import { bindMenuDismiss } from './escMenuStack.js';
|
||||||
import {
|
import {
|
||||||
WEEKDAYS, MONTHS, MON_SHORT,
|
WEEKDAYS, WEEKDAYS_SUN, MONTHS, MON_SHORT,
|
||||||
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
|
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
|
||||||
_trashIcon, _moreIcon, _bellIcon,
|
_trashIcon, _moreIcon, _bellIcon,
|
||||||
_isCalBgImage, _calBgImageUrl, _calBgCss,
|
_isCalBgImage, _calBgImageUrl, _calBgCss,
|
||||||
@@ -64,6 +64,8 @@ let _hiddenTypes = new Set(); // event_type values to hide
|
|||||||
let _onlyImportant = false;
|
let _onlyImportant = false;
|
||||||
|
|
||||||
let _filtersCollapsed = localStorage.getItem('cal-filters-collapsed') === '1';
|
let _filtersCollapsed = localStorage.getItem('cal-filters-collapsed') === '1';
|
||||||
|
// Week-start preference: 'mon' (default, Mon=first col) or 'sun' (Sun=first col).
|
||||||
|
let _weekStartSun = localStorage.getItem('cal-week-start') === 'sun';
|
||||||
let _selectedDay = null;
|
let _selectedDay = null;
|
||||||
let _view = 'month';
|
let _view = 'month';
|
||||||
let _searchQuery = '';
|
let _searchQuery = '';
|
||||||
@@ -360,14 +362,14 @@ function _today() { return _ds(new Date()); }
|
|||||||
function _monthRange(d) {
|
function _monthRange(d) {
|
||||||
const y = d.getFullYear(), m = d.getMonth();
|
const y = d.getFullYear(), m = d.getMonth();
|
||||||
const first = new Date(y, m, 1);
|
const first = new Date(y, m, 1);
|
||||||
const dow = (first.getDay() + 6) % 7;
|
const dow = _weekStartSun ? first.getDay() : (first.getDay() + 6) % 7;
|
||||||
const gs = new Date(y, m, 1 - dow);
|
const gs = new Date(y, m, 1 - dow);
|
||||||
const ge = new Date(gs); ge.setDate(gs.getDate() + 42);
|
const ge = new Date(gs); ge.setDate(gs.getDate() + 42);
|
||||||
return [_ds(gs), _ds(ge)];
|
return [_ds(gs), _ds(ge)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function _weekRange(d) {
|
function _weekRange(d) {
|
||||||
const dow = (d.getDay() + 6) % 7;
|
const dow = _weekStartSun ? d.getDay() : (d.getDay() + 6) % 7;
|
||||||
const s = new Date(d); s.setDate(d.getDate() - dow);
|
const s = new Date(d); s.setDate(d.getDate() - dow);
|
||||||
const e = new Date(s); e.setDate(s.getDate() + 7);
|
const e = new Date(s); e.setDate(s.getDate() + 7);
|
||||||
return [_ds(s), _ds(e)];
|
return [_ds(s), _ds(e)];
|
||||||
@@ -928,11 +930,11 @@ async function _renderMonth() {
|
|||||||
_slideDir = 0;
|
_slideDir = 0;
|
||||||
let h = _headerHTML() + _filtersRowHTML() + `<div class="cal-grid${slideClass}">`;
|
let h = _headerHTML() + _filtersRowHTML() + `<div class="cal-grid${slideClass}">`;
|
||||||
h += '<div class="cal-week-headers">';
|
h += '<div class="cal-week-headers">';
|
||||||
for (const wd of WEEKDAYS) h += `<div class="cal-weekday">${wd}</div>`;
|
for (const wd of (_weekStartSun ? WEEKDAYS_SUN : WEEKDAYS)) h += `<div class="cal-weekday">${wd}</div>`;
|
||||||
h += '</div>';
|
h += '</div>';
|
||||||
|
|
||||||
const first = new Date(y, m, 1);
|
const first = new Date(y, m, 1);
|
||||||
const dow = (first.getDay() + 6) % 7;
|
const dow = _weekStartSun ? first.getDay() : (first.getDay() + 6) % 7;
|
||||||
const gs = new Date(y, m, 1 - dow);
|
const gs = new Date(y, m, 1 - dow);
|
||||||
|
|
||||||
const multiDay = _events.filter(e => {
|
const multiDay = _events.filter(e => {
|
||||||
@@ -1141,13 +1143,13 @@ function _wkEventTopHeight(ev, dayStr) {
|
|||||||
// Date math if the string isn't shaped as expected.
|
// Date math if the string isn't shaped as expected.
|
||||||
const _toMin = (iso, fallbackDate) => {
|
const _toMin = (iso, fallbackDate) => {
|
||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
const m = iso.match(/T(\d{2}):(\d{2})/);
|
const mins = _timeToMin(iso);
|
||||||
if (m) {
|
if (mins !== null && iso.includes('T')) {
|
||||||
// If the event spans into a previous/next day, clamp to today's bounds.
|
// If the event spans into a previous/next day, clamp to today's bounds.
|
||||||
const evDate = iso.slice(0, 10);
|
const evDate = _localDateOf(iso);
|
||||||
if (evDate < fallbackDate) return 0; // event started before today
|
if (evDate < fallbackDate) return 0; // event started before today
|
||||||
if (evDate > fallbackDate) return 24 * 60; // event ends after today
|
if (evDate > fallbackDate) return 24 * 60; // event ends after today
|
||||||
return parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
|
return mins;
|
||||||
}
|
}
|
||||||
// All-day or date-only — treat as start of day.
|
// All-day or date-only — treat as start of day.
|
||||||
return 0;
|
return 0;
|
||||||
@@ -1204,8 +1206,8 @@ async function _renderWeek() {
|
|||||||
const timedEvents = _eventsForDay(ds).filter(e => _eventVisible(e) && !e.all_day);
|
const timedEvents = _eventsForDay(ds).filter(e => _eventVisible(e) && !e.all_day);
|
||||||
|
|
||||||
const isSun = d.getDay() === 0;
|
const isSun = d.getDay() === 0;
|
||||||
colsHtml += `<div class="cal-wk-col${isToday ? ' cal-wk-today' : ''}${isSun ? ' cal-wk-sun' : ''}" data-date="${ds}">`;
|
colsHtml += `<div class="cal-wk-col${isToday ? ' cal-wk-today' : ''}${isSun && !_weekStartSun ? ' cal-wk-sun' : ''}" data-date="${ds}">`;
|
||||||
colsHtml += `<div class="cal-wk-col-head"><span class="cal-wk-dn">${WEEKDAYS[idx]}</span><span class="cal-wk-dt">${d.getDate()}</span></div>`;
|
colsHtml += `<div class="cal-wk-col-head"><span class="cal-wk-dn">${(_weekStartSun ? WEEKDAYS_SUN : WEEKDAYS)[idx]}</span><span class="cal-wk-dt">${d.getDate()}</span></div>`;
|
||||||
// All-day strip
|
// All-day strip
|
||||||
colsHtml += `<div class="cal-wk-allday">`;
|
colsHtml += `<div class="cal-wk-allday">`;
|
||||||
for (const ev of allDayEvents) {
|
for (const ev of allDayEvents) {
|
||||||
@@ -1286,12 +1288,17 @@ async function _renderWeek() {
|
|||||||
if (!ev) return;
|
if (!ev) return;
|
||||||
const cols = Array.from(body.querySelectorAll('.cal-wk-grid'));
|
const cols = Array.from(body.querySelectorAll('.cal-wk-grid'));
|
||||||
if (!cols.length) return;
|
if (!cols.length) return;
|
||||||
// Original timing
|
// Local/display timing
|
||||||
const m1 = (ev.dtstart || '').match(/T(\d{2}):(\d{2})/);
|
const startMin0 = _timeToMin(ev.dtstart) ?? 0;
|
||||||
const m2 = (ev.dtend || '').match(/T(\d{2}):(\d{2})/);
|
const endMin0 = _timeToMin(ev.dtend) ?? startMin0 + 60;
|
||||||
const startMin0 = m1 ? parseInt(m1[1], 10) * 60 + parseInt(m1[2], 10) : 0;
|
|
||||||
const endMin0 = m2 ? parseInt(m2[1], 10) * 60 + parseInt(m2[2], 10) : startMin0 + 60;
|
let durationMin = endMin0 - startMin0;
|
||||||
const durationMin = Math.max(15, endMin0 - startMin0);
|
const startDs = _localDateOf(ev.dtstart);
|
||||||
|
const endDs = ev.dtend ? _localDateOf(ev.dtend) : startDs;
|
||||||
|
if (endDs > startDs && endMin0 <= startMin0) {
|
||||||
|
durationMin += 24 * 60;
|
||||||
|
}
|
||||||
|
durationMin = Math.max(15, durationMin);
|
||||||
|
|
||||||
// Where did the cursor grab the block? (offset from block-top in px)
|
// Where did the cursor grab the block? (offset from block-top in px)
|
||||||
const blockRect = block.getBoundingClientRect();
|
const blockRect = block.getBoundingClientRect();
|
||||||
@@ -1365,7 +1372,7 @@ async function _renderWeek() {
|
|||||||
// a plain click (no movement) must still open the event.
|
// a plain click (no movement) must still open the event.
|
||||||
if (moved) block.dataset.justResized = '1';
|
if (moved) block.dataset.justResized = '1';
|
||||||
// Decide whether anything actually moved.
|
// Decide whether anything actually moved.
|
||||||
const oldDs = (ev.dtstart || '').slice(0, 10);
|
const oldDs = _localDateOf(ev.dtstart);
|
||||||
if (!nextDs) return;
|
if (!nextDs) return;
|
||||||
if (nextDs === oldDs && nextStartMin === startMin0) return;
|
if (nextDs === oldDs && nextStartMin === startMin0) return;
|
||||||
// Snapshot the original times so we can offer an Undo.
|
// Snapshot the original times so we can offer an Undo.
|
||||||
@@ -1374,11 +1381,10 @@ async function _renderWeek() {
|
|||||||
const newEndMin = nextStartMin + durationMin;
|
const newEndMin = nextStartMin + durationMin;
|
||||||
const hh = String(Math.floor(nextStartMin / 60)).padStart(2, '0');
|
const hh = String(Math.floor(nextStartMin / 60)).padStart(2, '0');
|
||||||
const mm = String(nextStartMin % 60).padStart(2, '0');
|
const mm = String(nextStartMin % 60).padStart(2, '0');
|
||||||
const hh2 = String(Math.floor(newEndMin / 60)).padStart(2, '0');
|
const newDtstartDate = new Date(`${nextDs}T${hh}:${mm}:00`);
|
||||||
const mm2 = String((newEndMin) % 60).padStart(2, '0');
|
const _tz = _tzOffsetForDate(newDtstartDate);
|
||||||
const _tz = _tzOffset();
|
|
||||||
const newDtstart = `${nextDs}T${hh}:${mm}:00${_tz}`;
|
const newDtstart = `${nextDs}T${hh}:${mm}:00${_tz}`;
|
||||||
const newDtend = `${nextDs}T${hh2}:${mm2}:00${_tz}`;
|
const newDtend = _addMinutesToLocalIso(newDtstart, durationMin);
|
||||||
try {
|
try {
|
||||||
await _updateEvent(uid, { dtstart: newDtstart, dtend: newDtend });
|
await _updateEvent(uid, { dtstart: newDtstart, dtend: newDtend });
|
||||||
_render();
|
_render();
|
||||||
@@ -1410,10 +1416,7 @@ async function _renderWeek() {
|
|||||||
const uid = block.dataset.uid;
|
const uid = block.dataset.uid;
|
||||||
const ev = _events.find(x => x.uid === uid);
|
const ev = _events.find(x => x.uid === uid);
|
||||||
if (!ev || !grid || !ds) return;
|
if (!ev || !grid || !ds) return;
|
||||||
const startMin = (() => {
|
const startMin = _timeToMin(ev.dtstart) ?? 0;
|
||||||
const m = (ev.dtstart || '').match(/T(\d{2}):(\d{2})/);
|
|
||||||
return m ? parseInt(m[1], 10) * 60 + parseInt(m[2], 10) : 0;
|
|
||||||
})();
|
|
||||||
const initialTop = parseFloat(block.style.top || '0');
|
const initialTop = parseFloat(block.style.top || '0');
|
||||||
const gridRect = grid.getBoundingClientRect();
|
const gridRect = grid.getBoundingClientRect();
|
||||||
let newEndMin = startMin;
|
let newEndMin = startMin;
|
||||||
@@ -1438,9 +1441,8 @@ async function _renderWeek() {
|
|||||||
if (resized) block.dataset.justResized = '1';
|
if (resized) block.dataset.justResized = '1';
|
||||||
if (newEndMin === startMin) return;
|
if (newEndMin === startMin) return;
|
||||||
const prevDtend = ev.dtend;
|
const prevDtend = ev.dtend;
|
||||||
const hh = String(Math.floor(newEndMin / 60)).padStart(2, '0');
|
const durationMin = newEndMin - startMin;
|
||||||
const mm = String(newEndMin % 60).padStart(2, '0');
|
const newDtend = _addMinutesToLocalIso(ev.dtstart, durationMin);
|
||||||
const newDtend = `${ds}T${hh}:${mm}:00${_tzOffset()}`;
|
|
||||||
try {
|
try {
|
||||||
await _updateEvent(uid, { dtend: newDtend });
|
await _updateEvent(uid, { dtend: newDtend });
|
||||||
_render();
|
_render();
|
||||||
@@ -1724,9 +1726,9 @@ async function _renderYear() {
|
|||||||
for (let m = 0; m < 12; m++) {
|
for (let m = 0; m < 12; m++) {
|
||||||
h += `<div class="cal-year-month" data-month="${m}"><div class="cal-year-month-title">${MON_SHORT[m]}</div>`;
|
h += `<div class="cal-year-month" data-month="${m}"><div class="cal-year-month-title">${MON_SHORT[m]}</div>`;
|
||||||
h += '<div class="cal-year-grid">';
|
h += '<div class="cal-year-grid">';
|
||||||
for (const wd of ['M', 'T', 'W', 'T', 'F', 'S', 'S']) h += `<div class="cal-year-wd">${wd}</div>`;
|
for (const wd of (_weekStartSun ? ['S','M','T','W','T','F','S'] : ['M','T','W','T','F','S','S'])) h += `<div class="cal-year-wd">${wd}</div>`;
|
||||||
const first = new Date(y, m, 1);
|
const first = new Date(y, m, 1);
|
||||||
const dow = (first.getDay() + 6) % 7;
|
const dow = _weekStartSun ? first.getDay() : (first.getDay() + 6) % 7;
|
||||||
const daysInMonth = new Date(y, m + 1, 0).getDate();
|
const daysInMonth = new Date(y, m + 1, 0).getDate();
|
||||||
for (let p = 0; p < dow; p++) h += '<div class="cal-year-cell"></div>';
|
for (let p = 0; p < dow; p++) h += '<div class="cal-year-cell"></div>';
|
||||||
for (let d = 1; d <= daysInMonth; d++) {
|
for (let d = 1; d <= daysInMonth; d++) {
|
||||||
@@ -1966,10 +1968,10 @@ function _wireAll(body) {
|
|||||||
const ad = document.getElementById('cal-f-allday');
|
const ad = document.getElementById('cal-f-allday');
|
||||||
if (ad && !ad.checked) { ad.checked = true; ad.dispatchEvent(new Event('change')); }
|
if (ad && !ad.checked) { ad.checked = true; ad.dispatchEvent(new Event('change')); }
|
||||||
} else {
|
} else {
|
||||||
const t1 = (ev.dtstart || '').match(/T(\d{2}:\d{2})/);
|
const t1 = _fmtTime(ev.dtstart);
|
||||||
const t2 = (ev.dtend || '').match(/T(\d{2}:\d{2})/);
|
const t2 = _fmtTime(ev.dtend);
|
||||||
if (t1) set('cal-f-start', t1[1]);
|
if (t1) set('cal-f-start', t1);
|
||||||
if (t2) set('cal-f-end', t2[1]);
|
if (t2) set('cal-f-end', t2);
|
||||||
document.getElementById('cal-f-start')?.dispatchEvent(new Event('input'));
|
document.getElementById('cal-f-start')?.dispatchEvent(new Event('input'));
|
||||||
}
|
}
|
||||||
// Make sure the details panel is open so the user can verify time.
|
// Make sure the details panel is open so the user can verify time.
|
||||||
@@ -2474,6 +2476,13 @@ async function _showCalSettings() {
|
|||||||
</div>
|
</div>
|
||||||
<div style="font-size:10px;opacity:0.4;margin-top:4px;">Download a calendar as .ics for backup or to import into another app.</div>
|
<div style="font-size:10px;opacity:0.4;margin-top:4px;">Download a calendar as .ics for backup or to import into another app.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="border-top:1px solid var(--border);padding-top:12px;">
|
||||||
|
<div style="font-size:11px;opacity:0.5;margin-bottom:6px;">Week starts on</div>
|
||||||
|
<div style="display:flex;gap:6px;">
|
||||||
|
<button id="cal-wstart-mon" type="button" style="font-size:12px;padding:3px 10px;border-radius:4px;border:1px solid var(--border);background:${!_weekStartSun ? 'color-mix(in srgb, var(--accent,var(--red)) 18%, var(--panel))' : 'var(--panel)'};color:var(--fg);cursor:pointer;transition:background 0.1s,border-color 0.1s;outline:none;">Monday</button>
|
||||||
|
<button id="cal-wstart-sun" type="button" style="font-size:12px;padding:3px 10px;border-radius:4px;border:1px solid var(--border);background:${_weekStartSun ? 'color-mix(in srgb, var(--accent,var(--red)) 18%, var(--panel))' : 'var(--panel)'};color:var(--fg);cursor:pointer;transition:background 0.1s,border-color 0.1s;outline:none;">Sunday</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div style="border-top:1px solid var(--border);padding-top:12px;">
|
<div style="border-top:1px solid var(--border);padding-top:12px;">
|
||||||
<div style="font-size:11px;opacity:0.5;margin-bottom:6px;">Sync</div>
|
<div style="font-size:11px;opacity:0.5;margin-bottom:6px;">Sync</div>
|
||||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||||
@@ -2494,6 +2503,28 @@ async function _showCalSettings() {
|
|||||||
overlay.querySelector('#cal-settings-close').addEventListener('click', cleanup);
|
overlay.querySelector('#cal-settings-close').addEventListener('click', cleanup);
|
||||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(); });
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(); });
|
||||||
|
|
||||||
|
// Week-start toggle: save to localStorage, update module state, re-render.
|
||||||
|
const _monBtn = overlay.querySelector('#cal-wstart-mon');
|
||||||
|
const _sunBtn = overlay.querySelector('#cal-wstart-sun');
|
||||||
|
const _activeStyle = 'color-mix(in srgb, var(--accent,var(--red)) 18%, var(--panel))';
|
||||||
|
const _inactiveStyle = 'var(--panel)';
|
||||||
|
const _applyWeekStartActive = () => {
|
||||||
|
if (_monBtn) _monBtn.style.background = _weekStartSun ? _inactiveStyle : _activeStyle;
|
||||||
|
if (_sunBtn) _sunBtn.style.background = _weekStartSun ? _activeStyle : _inactiveStyle;
|
||||||
|
};
|
||||||
|
_monBtn?.addEventListener('click', () => {
|
||||||
|
_weekStartSun = false;
|
||||||
|
localStorage.setItem('cal-week-start', 'mon');
|
||||||
|
_applyWeekStartActive();
|
||||||
|
if (_open) _render();
|
||||||
|
});
|
||||||
|
_sunBtn?.addEventListener('click', () => {
|
||||||
|
_weekStartSun = true;
|
||||||
|
localStorage.setItem('cal-week-start', 'sun');
|
||||||
|
_applyWeekStartActive();
|
||||||
|
if (_open) _render();
|
||||||
|
});
|
||||||
|
|
||||||
// Create a new (local) calendar. Defaults the name + next palette color, then
|
// Create a new (local) calendar. Defaults the name + next palette color, then
|
||||||
// reopens the panel so the user can rename it inline and pick a color.
|
// reopens the panel so the user can rename it inline and pick a color.
|
||||||
overlay.querySelector('#cal-settings-add')?.addEventListener('click', async (e) => {
|
overlay.querySelector('#cal-settings-add')?.addEventListener('click', async (e) => {
|
||||||
@@ -2918,35 +2949,68 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
|||||||
const startEl = document.getElementById('cal-f-start');
|
const startEl = document.getElementById('cal-f-start');
|
||||||
const endEl = document.getElementById('cal-f-end');
|
const endEl = document.getElementById('cal-f-end');
|
||||||
if (!startEl || !endEl) return;
|
if (!startEl || !endEl) return;
|
||||||
|
|
||||||
const _toMin = (v) => {
|
const _toMin = (v) => {
|
||||||
if (!v || !/^\d{2}:\d{2}$/.test(v)) return null;
|
if (!v || !/^\d{2}:\d{2}$/.test(v)) return null;
|
||||||
const [h, m] = v.split(':').map(n => parseInt(n, 10));
|
const [h, m] = v.split(':').map(n => parseInt(n, 10));
|
||||||
return h * 60 + m;
|
return h * 60 + m;
|
||||||
};
|
};
|
||||||
|
|
||||||
const _toHHMM = (mins) => {
|
const _toHHMM = (mins) => {
|
||||||
let m = ((mins % 1440) + 1440) % 1440;
|
let m = ((mins % 1440) + 1440) % 1440;
|
||||||
const hh = String(Math.floor(m / 60)).padStart(2, '0');
|
const hh = String(Math.floor(m / 60)).padStart(2, '0');
|
||||||
const mm = String(m % 60).padStart(2, '0');
|
const mm = String(m % 60).padStart(2, '0');
|
||||||
return `${hh}:${mm}`;
|
return `${hh}:${mm}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const _autoAdvanceEndDate = () => {
|
||||||
|
const isAD = document.getElementById('cal-f-allday')?.checked;
|
||||||
|
if (isAD) return;
|
||||||
|
|
||||||
|
const dv = document.getElementById('cal-f-date')?.value;
|
||||||
|
const dvEndEl = document.getElementById('cal-f-date-end');
|
||||||
|
if (!dv || !dvEndEl || dvEndEl.value !== dv) return;
|
||||||
|
|
||||||
|
const sVal = startEl.value;
|
||||||
|
const eVal = endEl.value;
|
||||||
|
|
||||||
|
if (sVal && eVal && eVal <= sVal) {
|
||||||
|
const d = new Date(`${dv}T00:00:00`);
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
|
||||||
|
dvEndEl.value = _ds(d);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let prevStartMin = _toMin(startEl.value);
|
let prevStartMin = _toMin(startEl.value);
|
||||||
endEl.addEventListener('input', () => { endEl.dataset.userEdited = '1'; });
|
|
||||||
|
endEl.addEventListener('input', () => {
|
||||||
|
endEl.dataset.userEdited = '1';
|
||||||
|
});
|
||||||
|
|
||||||
|
endEl.addEventListener('change', _autoAdvanceEndDate);
|
||||||
|
|
||||||
startEl.addEventListener('change', () => {
|
startEl.addEventListener('change', () => {
|
||||||
const newStartMin = _toMin(startEl.value);
|
const newStartMin = _toMin(startEl.value);
|
||||||
const endMin = _toMin(endEl.value);
|
const endMin = _toMin(endEl.value);
|
||||||
if (newStartMin == null) { prevStartMin = newStartMin; return; }
|
|
||||||
// Compute the duration before the change. Use the user's existing
|
if (newStartMin == null) {
|
||||||
// start→end gap, fallback to 1 hour.
|
|
||||||
let durationMin = 60;
|
|
||||||
if (prevStartMin != null && endMin != null && endMin > prevStartMin) {
|
|
||||||
durationMin = endMin - prevStartMin;
|
|
||||||
} else if (endMin != null && newStartMin != null && endMin > newStartMin && endEl.dataset.userEdited === '1') {
|
|
||||||
// User already set a custom end before changing start — leave it.
|
|
||||||
prevStartMin = newStartMin;
|
prevStartMin = newStartMin;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let durationMin = 60;
|
||||||
|
|
||||||
|
if (prevStartMin != null && endMin != null && endMin > prevStartMin) {
|
||||||
|
durationMin = endMin - prevStartMin;
|
||||||
|
} else if (endMin != null && newStartMin != null && endMin > newStartMin && endEl.dataset.userEdited === '1') {
|
||||||
|
prevStartMin = newStartMin;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
endEl.value = _toHHMM(newStartMin + durationMin);
|
endEl.value = _toHHMM(newStartMin + durationMin);
|
||||||
prevStartMin = newStartMin;
|
prevStartMin = newStartMin;
|
||||||
|
_autoAdvanceEndDate();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
// Custom reminder picker
|
// Custom reminder picker
|
||||||
@@ -3007,6 +3071,20 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
|||||||
// proper UTC instants (is_utc=True). Without this, naive "10:00" gets
|
// proper UTC instants (is_utc=True). Without this, naive "10:00" gets
|
||||||
// re-interpreted as local elsewhere — the timezone-misfire bug.
|
// re-interpreted as local elsewhere — the timezone-misfire bug.
|
||||||
const _tz = _tzOffset();
|
const _tz = _tzOffset();
|
||||||
|
|
||||||
|
if (!isAD) {
|
||||||
|
const startVal = document.getElementById('cal-f-start').value;
|
||||||
|
const endVal = document.getElementById('cal-f-end').value;
|
||||||
|
|
||||||
|
const startDt = new Date(`${dv}T${startVal}:00`);
|
||||||
|
const endDt = new Date(`${dvEnd}T${endVal}:00`);
|
||||||
|
|
||||||
|
if (endDt <= startDt) {
|
||||||
|
uiModule.showToast('End time must be after start time');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
summary,
|
summary,
|
||||||
dtstart: isAD ? dv : `${dv}T${document.getElementById('cal-f-start').value}:00${_tz}`,
|
dtstart: isAD ? dv : `${dv}T${document.getElementById('cal-f-start').value}:00${_tz}`,
|
||||||
@@ -3215,6 +3293,37 @@ function _fmtTime(s) {
|
|||||||
}
|
}
|
||||||
return s.slice(11, 16);
|
return s.slice(11, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _timeToMin(iso) {
|
||||||
|
const hm = _fmtTime(iso);
|
||||||
|
if (!hm) return null;
|
||||||
|
const m = hm.match(/^(\d{1,2}):(\d{2})$/);
|
||||||
|
if (!m) return null;
|
||||||
|
const h = parseInt(m[1], 10);
|
||||||
|
const min = parseInt(m[2], 10);
|
||||||
|
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
|
||||||
|
return h * 60 + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _tzOffsetForDate(d) {
|
||||||
|
const off = -d.getTimezoneOffset();
|
||||||
|
const sign = off >= 0 ? '+' : '-';
|
||||||
|
const abs = Math.abs(off);
|
||||||
|
const hh = String(Math.floor(abs / 60)).padStart(2, '0');
|
||||||
|
const mm = String(abs % 60).padStart(2, '0');
|
||||||
|
return `${sign}${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _addMinutesToLocalIso(baseIso, addMinutes) {
|
||||||
|
const d = new Date(new Date(baseIso).getTime() + addMinutes * 60000);
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const mo = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const da = String(d.getDate()).padStart(2, '0');
|
||||||
|
const h = String(d.getHours()).padStart(2, '0');
|
||||||
|
const m = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
return `${y}-${mo}-${da}T${h}:${m}:00${_tzOffsetForDate(d)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function _e(s) { return uiModule.esc ? uiModule.esc(s || '') : (s || '').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
function _e(s) { return uiModule.esc ? uiModule.esc(s || '') : (s || '').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||||
|
|
||||||
// Linkify a location string: URLs become clickable, plain addresses get a Maps link.
|
// Linkify a location string: URLs become clickable, plain addresses get a Maps link.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
// No DOM, no fetch, no global mutable state — safe to import anywhere.
|
// No DOM, no fetch, no global mutable state — safe to import anywhere.
|
||||||
|
|
||||||
export const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
export const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||||
|
export const WEEKDAYS_SUN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
|
|
||||||
export const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
|
export const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
|
||||||
'July', 'August', 'September', 'October', 'November', 'December'];
|
'July', 'August', 'September', 'October', 'November', 'December'];
|
||||||
|
|||||||
+26
-7
@@ -1564,9 +1564,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
.replace(/<channel\|>/gi, '');
|
.replace(/<channel\|>/gi, '');
|
||||||
thinkText = thinkText.replace(/^\s*Thinking(?:\s+Process)?:\s*/i, '');
|
thinkText = thinkText.replace(/^\s*Thinking(?:\s+Process)?:\s*/i, '');
|
||||||
_liveThinkInner.innerHTML = markdownModule.mdToHtml(thinkText);
|
_liveThinkInner.innerHTML = markdownModule.mdToHtml(thinkText);
|
||||||
// Keep thinking box scrolled to bottom
|
// Keep thinking box scrolled to bottom, but let user scroll up
|
||||||
var thinkBox = _liveThinkInner.closest('.thinking-content');
|
var thinkBox = _liveThinkInner.closest('.thinking-content');
|
||||||
if (thinkBox) thinkBox.scrollTop = thinkBox.scrollHeight;
|
if (thinkBox) {
|
||||||
|
var nearBottom = thinkBox.scrollHeight - thinkBox.clientHeight - thinkBox.scrollTop < 80;
|
||||||
|
if (nearBottom) thinkBox.scrollTop = thinkBox.scrollHeight;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
uiModule.scrollHistory();
|
uiModule.scrollHistory();
|
||||||
continue;
|
continue;
|
||||||
@@ -3865,7 +3868,9 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
|
|
||||||
// Also submit on Enter (without shift)
|
// Also submit on Enter (without shift)
|
||||||
editor.addEventListener('keydown', (e) => {
|
editor.addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
const isMobile = window.innerWidth <= 768
|
||||||
|
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
saveBtn.click();
|
saveBtn.click();
|
||||||
}
|
}
|
||||||
@@ -3873,9 +3878,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resend a user message — truncates history to that point and resubmits.
|
* Resend a user message. Normal resend appends a fresh copy at the end of
|
||||||
|
* the current thread; regenerate flows can opt into replacing from here.
|
||||||
*/
|
*/
|
||||||
export async function resendUserMessage(userMsgElement) {
|
export async function resendUserMessage(userMsgElement, opts = {}) {
|
||||||
|
const replaceFromHere = Boolean(opts && opts.replaceFromHere);
|
||||||
const box = document.getElementById('chat-history');
|
const box = document.getElementById('chat-history');
|
||||||
const allMsgs = Array.from(box.querySelectorAll('.msg'));
|
const allMsgs = Array.from(box.querySelectorAll('.msg'));
|
||||||
const msgIndex = allMsgs.indexOf(userMsgElement);
|
const msgIndex = allMsgs.indexOf(userMsgElement);
|
||||||
@@ -3921,9 +3928,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
const sessionId = sessionModule.getCurrentSessionId();
|
const sessionId = sessionModule.getCurrentSessionId();
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
|
|
||||||
// Truncate backend to keep everything before this user message
|
|
||||||
const keepCount = msgIndex;
|
|
||||||
try {
|
try {
|
||||||
|
if (replaceFromHere) {
|
||||||
|
// Regenerate flows intentionally trim history to this point before
|
||||||
|
// resubmitting. The plain "Resend message" action must not do this.
|
||||||
|
const keepCount = msgIndex;
|
||||||
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -3940,6 +3949,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
sibling = next;
|
sibling = next;
|
||||||
}
|
}
|
||||||
_hideUserBubble = true;
|
_hideUserBubble = true;
|
||||||
|
}
|
||||||
_pendingRegenAttachments = _ids;
|
_pendingRegenAttachments = _ids;
|
||||||
|
|
||||||
// Resubmit
|
// Resubmit
|
||||||
@@ -4473,6 +4483,15 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
|||||||
* Delete an AI message and its preceding user message from the conversation.
|
* Delete an AI message and its preceding user message from the conversation.
|
||||||
*/
|
*/
|
||||||
export async function deleteMessage(msgElement) {
|
export async function deleteMessage(msgElement) {
|
||||||
|
if (uiModule && uiModule.styledConfirm) {
|
||||||
|
const ok = await uiModule.styledConfirm('Delete this message?', {
|
||||||
|
confirmText: 'Delete',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
|
||||||
const box = document.getElementById('chat-history');
|
const box = document.getElementById('chat-history');
|
||||||
const allMsgs = Array.from(box.querySelectorAll('.msg'));
|
const allMsgs = Array.from(box.querySelectorAll('.msg'));
|
||||||
const clickedIndex = allMsgs.indexOf(msgElement);
|
const clickedIndex = allMsgs.indexOf(msgElement);
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ function _openVisionEditor(att, userMsgEl) {
|
|||||||
await _saveVisionText();
|
await _saveVisionText();
|
||||||
_closeVisionEditor();
|
_closeVisionEditor();
|
||||||
if (userMsgEl && window.chatModule?.resendUserMessage) {
|
if (userMsgEl && window.chatModule?.resendUserMessage) {
|
||||||
window.chatModule.resendUserMessage(userMsgEl);
|
window.chatModule.resendUserMessage(userMsgEl, { replaceFromHere: true });
|
||||||
} else if (uiModule?.showToast) {
|
} else if (uiModule?.showToast) {
|
||||||
uiModule.showToast('Saved');
|
uiModule.showToast('Saved');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const EVAL_PROMPTS = {
|
|||||||
chat: [
|
chat: [
|
||||||
// ── ★ Featured — prompts that have actually broken frontier models ──
|
// ── ★ Featured — prompts that have actually broken frontier models ──
|
||||||
{ sub: '★ Featured', label: 'Sum digits 2^100', answer: '115', prompt: 'Compute the sum of the decimal digits of 2^100. Do NOT use code execution — work it out by reasoning about the number. Show every step, then end with the final number on its own line.' },
|
{ sub: '★ Featured', label: 'Sum digits 2^100', answer: '115', prompt: 'Compute the sum of the decimal digits of 2^100. Do NOT use code execution — work it out by reasoning about the number. Show every step, then end with the final number on its own line.' },
|
||||||
{ sub: '★ Featured', label: 'Three jugs', answer: '4 pours: 7→5, 5→3, 3→7, 5→3', prompt: 'You have three jugs of capacities 7, 5, and 3 liters. The 7-liter jug starts full; the others empty. Using only pouring (no markings), produce the shortest sequence of pours that leaves exactly 2 liters in the 3-liter jug. Output each step as `pour A → B` on its own line. Then state the total number of pours on a final line.' },
|
{ sub: '★ Featured', label: 'Three jugs', answer: '2 pours: 7→5, 7→3', prompt: 'You have three jugs of capacities 7, 5, and 3 liters. The 7-liter jug starts full; the others empty. Using only pouring (no markings), produce the shortest sequence of pours that leaves exactly 2 liters in the 3-liter jug. Output each step as `pour A → B` on its own line. Then state the total number of pours on a final line.' },
|
||||||
|
|
||||||
{ sub: 'Visual', label: 'Draw SVG', prompt: 'Output a complete self-contained HTML file (```html block, no explanation, no other text) that centers a single SVG illustration on a simple background. The SVG must use only inline shapes — no <img>, no external assets, no JavaScript. Make it expressive and detailed. The SVG should depict: a friendly robot' },
|
{ sub: 'Visual', label: 'Draw SVG', prompt: 'Output a complete self-contained HTML file (```html block, no explanation, no other text) that centers a single SVG illustration on a simple background. The SVG must use only inline shapes — no <img>, no external assets, no JavaScript. Make it expressive and detailed. The SVG should depict: a friendly robot' },
|
||||||
{ sub: 'Visual explain', label: 'Black hole HTML', prompt: 'Output a complete HTML file (```html block, no explanation outside the code) that visually explains how a black hole forms. Use four labeled "frames" laid out left-to-right (or stacked on small screens) showing: 1) a glowing massive star, 2) the star going supernova with shockwave rings, 3) collapse into a singularity, 4) the final black hole with a curved accretion disk and bent light around it. Use only vanilla HTML, CSS, and inline SVG — no JavaScript, no images. Each frame should have a one-sentence caption.' },
|
{ sub: 'Visual explain', label: 'Black hole HTML', prompt: 'Output a complete HTML file (```html block, no explanation outside the code) that visually explains how a black hole forms. Use four labeled "frames" laid out left-to-right (or stacked on small screens) showing: 1) a glowing massive star, 2) the star going supernova with shockwave rings, 3) collapse into a singularity, 4) the final black hole with a curved accretion disk and bent light around it. Use only vanilla HTML, CSS, and inline SVG — no JavaScript, no images. Each frame should have a one-sentence caption.' },
|
||||||
|
|||||||
@@ -320,6 +320,15 @@ export const ERROR_PATTERNS = [
|
|||||||
}},
|
}},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
pattern: /sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops)|(Python\.h|libnuma\.so\.1|common_ops)[\s\S]*sgl_kernel|Please ensure sgl_kernel is properly installed/i,
|
||||||
|
message: 'SGLang native dependencies are missing on this server.',
|
||||||
|
fixes: [
|
||||||
|
{ label: 'Copy OS package command', action: () => _copyText('sudo apt-get install -y libnuma-dev python3.12-dev build-essential') },
|
||||||
|
{ label: 'Copy kernel upgrade', action: () => _copyText('python3 -m pip install --upgrade sglang-kernel') },
|
||||||
|
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('sglang') },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
pattern: /sglang.*command not found|No module named sglang|SGLang is not installed/i,
|
pattern: /sglang.*command not found|No module named sglang|SGLang is not installed/i,
|
||||||
message: 'SGLang is not installed or not in PATH.',
|
message: 'SGLang is not installed or not in PATH.',
|
||||||
|
|||||||
@@ -750,6 +750,80 @@ export async function _hwfitFetch(fresh = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Renders a non-blocking hardware visibility warning when Cookbook is using
|
||||||
|
// container-visible hardware that may not match the user's actual host machine.
|
||||||
|
function _renderHwVisibilityWarning(sys) {
|
||||||
|
const row = document.getElementById('hwfit-hw-row');
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
let box = document.getElementById('hwfit-hw-visibility-warning');
|
||||||
|
|
||||||
|
// Manual hardware is an explicit user override, so avoid showing stale
|
||||||
|
// container-detection warnings once the user has chosen a simulated profile.
|
||||||
|
const warning = sys?.manual_hardware ? null : sys?.hardware_visibility_warning;
|
||||||
|
|
||||||
|
if (!warning) {
|
||||||
|
if (box) box.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!box) {
|
||||||
|
box = document.createElement('div');
|
||||||
|
box.id = 'hwfit-hw-visibility-warning';
|
||||||
|
box.className = 'hwfit-loading hwfit-hw-visibility-warning';
|
||||||
|
row.insertAdjacentElement('afterend', box);
|
||||||
|
}
|
||||||
|
|
||||||
|
box.innerHTML = `
|
||||||
|
<div class="hwfit-hw-visibility-warning-title">${esc(warning.title || 'Hardware visibility note')}</div>
|
||||||
|
<div class="hwfit-hw-visibility-warning-body">${esc(warning.message || '')}</div>
|
||||||
|
<div class="hwfit-hw-visibility-warning-actions">
|
||||||
|
<button type="button" class="hwfit-gpu-btn" data-hw-action="manual">Edit manual hardware</button>
|
||||||
|
<button type="button" class="hwfit-gpu-btn" data-hw-action="rescan">Rescan</button>
|
||||||
|
<button type="button" class="hwfit-gpu-btn" data-hw-action="copy">Copy diagnostics</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
box.querySelector('[data-hw-action="manual"]')?.addEventListener('click', () => {
|
||||||
|
const panel = document.getElementById('hwfit-manual-panel');
|
||||||
|
if (panel) panel.classList.remove('hidden');
|
||||||
|
document.getElementById('hwfit-hw-manual-btn')?.scrollIntoView?.({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
box.querySelector('[data-hw-action="rescan"]')?.addEventListener('click', () => {
|
||||||
|
_resetGpuToggleState();
|
||||||
|
_hwfitCache = null;
|
||||||
|
_hwfitFetch(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
box.querySelector('[data-hw-action="copy"]')?.addEventListener('click', () => {
|
||||||
|
// Keep diagnostics copy/paste friendly for GitHub issues and Docker support.
|
||||||
|
const text = [
|
||||||
|
'Odysseus Cookbook hardware diagnostics',
|
||||||
|
`probe_scope=${sys?.probe_scope || ''}`,
|
||||||
|
`containerized=${sys?.containerized === true}`,
|
||||||
|
`backend=${sys?.backend || ''}`,
|
||||||
|
`has_gpu=${sys?.has_gpu === true}`,
|
||||||
|
`gpu_name=${sys?.gpu_name || ''}`,
|
||||||
|
`gpu_count=${sys?.gpu_count || 0}`,
|
||||||
|
`gpu_vram_gb=${sys?.gpu_vram_gb || ''}`,
|
||||||
|
`ram=${sys?.available_ram_gb || '?'} / ${sys?.total_ram_gb || '?'} GB`,
|
||||||
|
`cpu_cores=${sys?.cpu_cores || ''}`,
|
||||||
|
`cpu_name=${sys?.cpu_name || ''}`,
|
||||||
|
'',
|
||||||
|
'Useful checks:',
|
||||||
|
'docker compose exec odysseus nvidia-smi -L',
|
||||||
|
'docker compose exec odysseus cat /proc/meminfo | head',
|
||||||
|
'docker compose exec odysseus python -c "from services.hwfit.hardware import detect_system; import json; print(json.dumps(detect_system(fresh=True), indent=2))"',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
_copyText(text);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function _hwfitRenderHw(el, sys) {
|
export function _hwfitRenderHw(el, sys) {
|
||||||
if (!el || !sys) return;
|
if (!el || !sys) return;
|
||||||
// Cache system info globally so other modules can read VRAM without refetching
|
// Cache system info globally so other modules can read VRAM without refetching
|
||||||
@@ -838,6 +912,7 @@ export function _hwfitRenderHw(el, sys) {
|
|||||||
+ chip('cores', cores)
|
+ chip('cores', cores)
|
||||||
+ chip('backend', esc(sys.backend || ''))
|
+ chip('backend', esc(sys.backend || ''))
|
||||||
+ manualChip;
|
+ manualChip;
|
||||||
|
_renderHwVisibilityWarning(sys);
|
||||||
// Body click → toggle "off" (dimmed, still visible). Membership of
|
// Body click → toggle "off" (dimmed, still visible). Membership of
|
||||||
// _dismissedHwChips is what the ranker reads, so both add+remove
|
// _dismissedHwChips is what the ranker reads, so both add+remove
|
||||||
// here also flips the model list. The manual chip is excluded —
|
// here also flips the model list. The manual chip is excluded —
|
||||||
|
|||||||
@@ -597,7 +597,8 @@ export function _buildServeCmd(f, modelName, backend) {
|
|||||||
} else if (backend === 'diffusers') {
|
} else if (backend === 'diffusers') {
|
||||||
const gpuStr = f.gpus?.trim();
|
const gpuStr = f.gpus?.trim();
|
||||||
if (gpuStr) cmd += `CUDA_VISIBLE_DEVICES=${gpuStr} `;
|
if (gpuStr) cmd += `CUDA_VISIBLE_DEVICES=${gpuStr} `;
|
||||||
cmd += `python3 scripts/diffusion_server.py --model ${modelName} --port ${f.port || '8100'}`;
|
const diffusersPy = _isWindows() ? 'python' : _py3Bin;
|
||||||
|
cmd += `${diffusersPy} scripts/diffusion_server.py --model ${modelName} --port ${f.port || '8100'}`;
|
||||||
if (f.diff_dtype && f.diff_dtype !== 'bfloat16') cmd += ` --dtype ${f.diff_dtype}`;
|
if (f.diff_dtype && f.diff_dtype !== 'bfloat16') cmd += ` --dtype ${f.diff_dtype}`;
|
||||||
if (f.diff_device_map && f.diff_device_map !== 'balanced') cmd += ` --device-map ${f.diff_device_map}`;
|
if (f.diff_device_map && f.diff_device_map !== 'balanced') cmd += ` --device-map ${f.diff_device_map}`;
|
||||||
if (f.diff_steps) cmd += ` --steps ${f.diff_steps}`;
|
if (f.diff_steps) cmd += ` --steps ${f.diff_steps}`;
|
||||||
@@ -718,7 +719,7 @@ async function _fetchDependencies() {
|
|||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
const pkgs = data.packages || [];
|
const pkgs = data.packages || [];
|
||||||
if (!pkgs.length) { list.innerHTML = '<div class="hwfit-loading">No packages found</div>'; return; }
|
if (!pkgs.length) { list.innerHTML = '<div class="hwfit-loading">No packages found</div>'; return; }
|
||||||
const _winUnsupported = new Set(['diffusers', 'hf_transfer', 'vllm', 'rembg', 'gfpgan']);
|
const _winUnsupported = new Set(['hf_transfer', 'vllm', 'rembg', 'gfpgan']);
|
||||||
|
|
||||||
const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => {
|
const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => {
|
||||||
if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">N/A</span>`;
|
if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">N/A</span>`;
|
||||||
|
|||||||
@@ -793,9 +793,10 @@ function _winSessionCmd(task, tmuxArgs) {
|
|||||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||||
}
|
}
|
||||||
if (tmuxArgs.includes('kill-session')) {
|
if (tmuxArgs.includes('kill-session')) {
|
||||||
|
const stopTree = `function Stop-Tree([int]$Id) { Get-CimInstance Win32_Process -Filter "ParentProcessId = $Id" -ErrorAction SilentlyContinue | ForEach-Object { Stop-Tree ([int]$_.ProcessId) }; Stop-Process -Id $Id -Force -ErrorAction SilentlyContinue }`;
|
||||||
const ps = host
|
const ps = host
|
||||||
? `$p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }; Remove-Item '${sd}\\${sid}.*' -Force -ErrorAction SilentlyContinue`
|
? `${stopTree}; $p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p -match '^\\d+$') { Stop-Tree ([int]$p) }; Remove-Item '${sd}\\${sid}.*' -Force -ErrorAction SilentlyContinue`
|
||||||
: `$p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }; Remove-Item (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.*') -Force -ErrorAction SilentlyContinue`;
|
: `${stopTree}; $p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p -match '^\\d+$') { Stop-Tree ([int]$p) }; Remove-Item (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.*') -Force -ErrorAction SilentlyContinue`;
|
||||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||||
}
|
}
|
||||||
if (tmuxArgs.includes('send-keys') && tmuxArgs.includes('C-c')) {
|
if (tmuxArgs.includes('send-keys') && tmuxArgs.includes('C-c')) {
|
||||||
@@ -3532,12 +3533,22 @@ async function _pollBackgroundStatus() {
|
|||||||
// dead-session check inspects). Recover "done" from the retained output's
|
// dead-session check inspects). Recover "done" from the retained output's
|
||||||
// exit-0 sentinel so a clean install isn't downgraded to crashed.
|
// exit-0 sentinel so a clean install isn't downgraded to crashed.
|
||||||
const depDone = !!task.payload?._dep && _depInstallSucceeded(task.output);
|
const depDone = !!task.payload?._dep && _depInstallSucceeded(task.output);
|
||||||
|
// A finished model download whose tmux pane is gone is also reported
|
||||||
|
// "stopped" (the dead-session check can miss the landed snapshot).
|
||||||
|
// Recover "done" from the terminal `DOWNLOAD_OK` sentinel — emitted
|
||||||
|
// only after the runner exits 0 — so a completed download isn't
|
||||||
|
// downgraded to crashed. This background poll runs blind (no live
|
||||||
|
// stream to debounce against), so unlike the reconnect loop it keys
|
||||||
|
// off the conclusive exit sentinel only, never the `/snapshots/` path,
|
||||||
|
// which can be printed mid-stream for multi-file downloads.
|
||||||
|
const downloadDone = task.type === 'download'
|
||||||
|
&& String(task.output || '').includes('DOWNLOAD_OK');
|
||||||
const nextStatus = live.status === 'completed'
|
const nextStatus = live.status === 'completed'
|
||||||
? 'done'
|
? 'done'
|
||||||
: (live.status === 'error'
|
: (live.status === 'error'
|
||||||
? 'error'
|
? 'error'
|
||||||
: (live.status === 'stopped'
|
: (live.status === 'stopped'
|
||||||
? (depDone ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped'))
|
? ((depDone || downloadDone) ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped'))
|
||||||
: null));
|
: null));
|
||||||
if (nextStatus && task.status !== nextStatus) {
|
if (nextStatus && task.status !== nextStatus) {
|
||||||
updates.status = nextStatus;
|
updates.status = nextStatus;
|
||||||
|
|||||||
@@ -530,7 +530,7 @@ function _rerenderCachedModels() {
|
|||||||
: (_lastUsed || (_isLegacyFlat ? _allSs : {}));
|
: (_lastUsed || (_isLegacyFlat ? _allSs : {}));
|
||||||
const detectedBackend = _detectBackend(m).backend;
|
const detectedBackend = _detectBackend(m).backend;
|
||||||
const _allowedBackends = new Set(_isWindows()
|
const _allowedBackends = new Set(_isWindows()
|
||||||
? ['llamacpp']
|
? ['llamacpp', 'diffusers']
|
||||||
: (_isMetal() ? ['llamacpp', 'ollama'] : ['vllm', 'sglang', 'llamacpp', 'ollama', 'diffusers']));
|
: (_isMetal() ? ['llamacpp', 'ollama'] : ['vllm', 'sglang', 'llamacpp', 'ollama', 'diffusers']));
|
||||||
const defaultBackend = (ss._forceBackend && ss.backend && _allowedBackends.has(ss.backend))
|
const defaultBackend = (ss._forceBackend && ss.backend && _allowedBackends.has(ss.backend))
|
||||||
? ss.backend
|
? ss.backend
|
||||||
@@ -590,7 +590,7 @@ function _rerenderCachedModels() {
|
|||||||
// Row 1: Backend + Server + Env
|
// Row 1: Backend + Server + Env
|
||||||
panelHtml += `<div class="hwfit-serve-row">`;
|
panelHtml += `<div class="hwfit-serve-row">`;
|
||||||
const _backendChoices = _isWindows()
|
const _backendChoices = _isWindows()
|
||||||
? [['llamacpp','llama.cpp']]
|
? [['llamacpp','llama.cpp'],['diffusers','Diffusers']]
|
||||||
: _isMetal()
|
: _isMetal()
|
||||||
// Diffusers (diffusion_server.py) is CUDA-only — omit it on Metal.
|
// Diffusers (diffusion_server.py) is CUDA-only — omit it on Metal.
|
||||||
? [['llamacpp','llama.cpp'],['ollama','Ollama']]
|
? [['llamacpp','llama.cpp'],['ollama','Ollama']]
|
||||||
|
|||||||
@@ -994,7 +994,7 @@ export function makeEdgeDockController(modal, side = 'right', dockClass) {
|
|||||||
stripe.style.bottom = '0';
|
stripe.style.bottom = '0';
|
||||||
stripe.style.width = '10px';
|
stripe.style.width = '10px';
|
||||||
stripe.style.cursor = 'col-resize';
|
stripe.style.cursor = 'col-resize';
|
||||||
stripe.style.zIndex = '9999';
|
stripe.style.zIndex = '261';
|
||||||
stripe.style.background = 'linear-gradient(to right, transparent 0 3px, color-mix(in srgb, var(--accent, var(--red)) 35%, transparent) 3px 7px, transparent 7px 10px)';
|
stripe.style.background = 'linear-gradient(to right, transparent 0 3px, color-mix(in srgb, var(--accent, var(--red)) 35%, transparent) 3px 7px, transparent 7px 10px)';
|
||||||
stripe.style.pointerEvents = 'auto';
|
stripe.style.pointerEvents = 'auto';
|
||||||
stripe.style.touchAction = 'none';
|
stripe.style.touchAction = 'none';
|
||||||
|
|||||||
@@ -1099,6 +1099,9 @@ export function openPanel() {
|
|||||||
if (_open) return;
|
if (_open) return;
|
||||||
_open = true;
|
_open = true;
|
||||||
_editingId = null;
|
_editingId = null;
|
||||||
|
// Reset the search filter — the rebuilt pane's search input renders empty, so a
|
||||||
|
// stale _searchQuery would silently hide non-matching notes after a reopen.
|
||||||
|
_searchQuery = '';
|
||||||
_clearViewedReminderGlows();
|
_clearViewedReminderGlows();
|
||||||
_firedDotDismissedAt = Date.now();
|
_firedDotDismissedAt = Date.now();
|
||||||
try { localStorage.setItem(REMINDER_DISMISSED_AT_KEY, String(_firedDotDismissedAt)); } catch {}
|
try { localStorage.setItem(REMINDER_DISMISSED_AT_KEY, String(_firedDotDismissedAt)); } catch {}
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ function _buildPanelHTML() {
|
|||||||
<span>Multi-step web research with an LLM-in-the-loop agent</span>
|
<span>Multi-step web research with an LLM-in-the-loop agent</span>
|
||||||
</p>
|
</p>
|
||||||
<div id="research-no-past-hint" class="memory-desc doclib-desc" style="display:none;margin-top:-2px;font-size:11px;opacity:0.7;">All past research found in <button type="button" class="research-library-link">Library, Research</button></div>
|
<div id="research-no-past-hint" class="memory-desc doclib-desc" style="display:none;margin-top:-2px;font-size:11px;opacity:0.7;">All past research found in <button type="button" class="research-library-link">Library, Research</button></div>
|
||||||
<textarea id="research-query" class="research-query" placeholder="e.g. Trace Odysseus's ten-year journey home from Troy — every island, monster, and detour, and why each one cost him" rows="4"></textarea>
|
<textarea id="research-query" class="research-query" placeholder="e.g. Trace Odysseus's ten-year journey home from Troy — every island, monster, and detour, and what each one cost him." rows="4"></textarea>
|
||||||
<div class="research-category-row" id="research-category-row">
|
<div class="research-category-row" id="research-category-row">
|
||||||
<button class="research-cat active" data-cat="" title="LLM auto-detects the best format">Auto</button>
|
<button class="research-cat active" data-cat="" title="LLM auto-detects the best format">Auto</button>
|
||||||
<button class="research-cat" data-cat="product">Product</button>
|
<button class="research-cat" data-cat="product">Product</button>
|
||||||
|
|||||||
@@ -3644,7 +3644,11 @@ async function initUnifiedIntegrations() {
|
|||||||
el('uf-api-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
|
el('uf-api-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
|
||||||
el('uf-api-save').addEventListener('click', async () => {
|
el('uf-api-save').addEventListener('click', async () => {
|
||||||
const presetKey = preset.value || undefined;
|
const presetKey = preset.value || undefined;
|
||||||
const body = { name: name.value, base_url: url.value, auth_type: auth.value, auth_header: header.value, preset: presetKey };
|
const nameValue = name.value.trim();
|
||||||
|
const urlValue = url.value.trim();
|
||||||
|
if (!nameValue) { el('uf-api-msg').textContent = 'Name required'; el('uf-api-msg').style.color = 'var(--red)'; return; }
|
||||||
|
if (!urlValue) { el('uf-api-msg').textContent = 'Base URL required'; el('uf-api-msg').style.color = 'var(--red)'; return; }
|
||||||
|
const body = { name: nameValue, base_url: urlValue, auth_type: auth.value, auth_header: header.value, preset: presetKey };
|
||||||
if (key.value) body.api_key = key.value;
|
if (key.value) body.api_key = key.value;
|
||||||
try {
|
try {
|
||||||
const u = _editId ? `/api/auth/integrations/${_editId}` : '/api/auth/integrations';
|
const u = _editId ? `/api/auth/integrations/${_editId}` : '/api/auth/integrations';
|
||||||
|
|||||||
@@ -514,6 +514,8 @@ function _buildBuiltinCards() {
|
|||||||
|
|
||||||
card.addEventListener('click', (e) => {
|
card.addEventListener('click', (e) => {
|
||||||
if (e.target.closest('button, input, textarea')) return;
|
if (e.target.closest('button, input, textarea')) return;
|
||||||
|
// Editing in progress → don't collapse on an outside-the-textarea click.
|
||||||
|
if (card.querySelector('.skill-md-editor')) return;
|
||||||
_expandBuiltinCard(card, b.name);
|
_expandBuiltinCard(card, b.name);
|
||||||
});
|
});
|
||||||
return card;
|
return card;
|
||||||
@@ -786,6 +788,10 @@ function renderSkillsList() {
|
|||||||
card.addEventListener('click', (e) => {
|
card.addEventListener('click', (e) => {
|
||||||
if (card._suppressNextClick) { card._suppressNextClick = false; return; }
|
if (card._suppressNextClick) { card._suppressNextClick = false; return; }
|
||||||
if (e.target.closest('button, input, textarea')) return;
|
if (e.target.closest('button, input, textarea')) return;
|
||||||
|
// While editing, a click on the card body (outside the textarea) must
|
||||||
|
// NOT collapse the card — that silently discards unsaved edits. Only
|
||||||
|
// Save/Cancel exit edit mode.
|
||||||
|
if (card.querySelector('.skill-md-editor')) return;
|
||||||
if (_selectMode) {
|
if (_selectMode) {
|
||||||
const cb = card.querySelector('.skill-select-cb');
|
const cb = card.querySelector('.skill-select-cb');
|
||||||
if (cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); }
|
if (cb) { cb.checked = !cb.checked; cb.dispatchEvent(new Event('change')); }
|
||||||
|
|||||||
@@ -339,10 +339,13 @@ function _submitComposedMessage(text) {
|
|||||||
const msgInput = document.getElementById('message');
|
const msgInput = document.getElementById('message');
|
||||||
const form = document.getElementById('chat-form');
|
const form = document.getElementById('chat-form');
|
||||||
if (!msgInput || !form) return false;
|
if (!msgInput || !form) return false;
|
||||||
|
// The slash handler and app-level form debounce must both release before
|
||||||
|
// sending the pinned prompt, otherwise the follow-up submit is dropped.
|
||||||
|
setTimeout(() => {
|
||||||
msgInput.value = text;
|
msgInput.value = text;
|
||||||
msgInput.dispatchEvent(new Event('input', { bubbles: true }));
|
msgInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
if (typeof form.requestSubmit === 'function') form.requestSubmit();
|
form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
||||||
else form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
}, 350);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user