mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-11 23:52:20 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40880f8fdb | ||
|
|
68aa830485 | ||
|
|
8257c661ad | ||
|
|
84c7eabfce | ||
|
|
fd79776964 | ||
|
|
0eb6b9eb1d |
@@ -105,17 +105,6 @@ SEARXNG_INSTANCE=http://localhost:8080
|
||||
# trusted single-user setups that accept injected content reaching side-effect tools.
|
||||
# ODYSSEUS_TOOL_APPROVAL_GATE=1
|
||||
|
||||
# Comma-separated list of exact qualified MCP read and write tool names
|
||||
# on host-local servers. Only explicitly configured stdio MCP servers qualify.
|
||||
# Declarations do not override built-in tool effects. Declared reads remain
|
||||
# untrusted (EXTERNAL_UNTRUSTED) to prevent prompt injections from reaching shell
|
||||
# or network tools, but allow continuation to declared non-destructive write tools
|
||||
# on the exact same stdio server without repeated approvals. Stdio transport does
|
||||
# not guarantee network isolation. Authorization must still originate from the
|
||||
# trusted request. Automatic same-store writes carry residual prompt-injection risk.
|
||||
# ODYSSEUS_LOCAL_MCP_READ_TOOLS=mcp__notes__search_notes,mcp__notes__read_note
|
||||
# ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=mcp__notes__create_note,mcp__notes__update_note
|
||||
|
||||
# Mark session cookies Secure. Left unset, this follows the request scheme:
|
||||
# an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to
|
||||
# force it on, or false to force it off while you still serve plain HTTP.
|
||||
|
||||
@@ -14,8 +14,6 @@ body:
|
||||
If your idea needs community input before it becomes a concrete proposal,
|
||||
start a [discussion](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas) instead.
|
||||
|
||||
How a proposal is triaged and decided: [product and feature proposals](https://github.com/odysseus-dev/odysseus/blob/dev/docs/maintainers/product-and-features.md#feature-proposal-workflow).
|
||||
|
||||
- type: checkboxes
|
||||
id: prerequisites
|
||||
attributes:
|
||||
@@ -81,32 +79,6 @@ body:
|
||||
label: Prior Art / Related Issues
|
||||
description: Link any related issues, discussions, or external references that informed this proposal.
|
||||
|
||||
- type: dropdown
|
||||
id: scope
|
||||
attributes:
|
||||
label: Size and reversibility
|
||||
description: Your best guess; the triager decides. Definitions are in the maintainer handbook (product-and-features.md).
|
||||
multiple: true
|
||||
options:
|
||||
- "Small, reversible change within ROADMAP"
|
||||
- "Big (more than one PR, or changes existing default behaviour)"
|
||||
- "New direction (new area, multi-user, data leaving the machine, ...)"
|
||||
- "Hard to undo (migration, deleted data, changed contract or permissions)"
|
||||
- "Not sure"
|
||||
|
||||
- type: textarea
|
||||
id: decision_questions
|
||||
attributes:
|
||||
label: Questions for the Product Owner
|
||||
description: If this needs a product decision, the exact yes/no questions — one per independent part.
|
||||
placeholder: "1. Should notes be shareable between accounts on one instance? (yes/no)"
|
||||
|
||||
- type: textarea
|
||||
id: evidence
|
||||
attributes:
|
||||
label: Screenshots, recording or demo
|
||||
description: For anything that renders, attach screenshots or a short recording. Link a demo branch if you have one.
|
||||
|
||||
- type: dropdown
|
||||
id: willing_to_implement
|
||||
attributes:
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
|
||||
Private maintainer-preview PRs may instead use:
|
||||
N/A — maintainer integration work
|
||||
|
||||
New features: the linked issue carries `product: approved` or
|
||||
`product: maintainer decision` (docs/maintainers/product-and-features.md).
|
||||
-->
|
||||
|
||||
Fixes #
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Reject skipped/empty native CI runs without conflating driver child counts."""
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('report', type=Path)
|
||||
parser.add_argument('--minimum', type=int, required=True)
|
||||
args = parser.parse_args()
|
||||
root = ET.parse(args.report).getroot()
|
||||
cases = list(root.iter('testcase'))
|
||||
if len(cases) < args.minimum:
|
||||
raise SystemExit(f'Incomplete execution: {len(cases)} cases, expected at least {args.minimum}')
|
||||
for case in cases:
|
||||
if any(case.find(tag) is not None for tag in ('skipped', 'failure', 'error')):
|
||||
raise SystemExit(f'Case did not pass: {case.get("classname")}::{case.get("name")}')
|
||||
print(f'Verified {len(cases)} executed cases; no skipped, failed or errored cases')
|
||||
@@ -0,0 +1,46 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { parse } from 'acorn';
|
||||
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'tests/native-ci-manifest.json'), 'utf8'));
|
||||
function files(directory) {
|
||||
return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
|
||||
const target = path.join(directory, entry.name);
|
||||
return entry.isDirectory() ? files(target) : /\.(?:mjs|cjs|js)$/.test(entry.name) ? [target] : [];
|
||||
});
|
||||
}
|
||||
function importsNativeTest(node) {
|
||||
if (!node || typeof node !== 'object') return false;
|
||||
if (node.type === 'ImportDeclaration' && node.source.value === 'node:test') return true;
|
||||
if (node.type === 'CallExpression' && node.callee.name === 'require' && node.arguments[0]?.value === 'node:test') return true;
|
||||
return Object.values(node).some(value => Array.isArray(value) ? value.some(importsNativeTest) : importsNativeTest(value));
|
||||
}
|
||||
const discovered = files(path.join(root, 'tests')).filter(file => {
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
return source.includes('node:test') && importsNativeTest(parse(source, { ecmaVersion: 'latest', sourceType: 'module' }));
|
||||
}).map(file => path.relative(root, file).split(path.sep).join('/')).sort();
|
||||
const assigned = Object.values(manifest).flatMap(group => Array.isArray(group) ? group : Object.keys(group));
|
||||
if (new Set(assigned).size !== assigned.length || JSON.stringify([...assigned].sort()) !== JSON.stringify(discovered)) {
|
||||
throw new Error('Native test coverage must be exhaustive and disjoint; update tests/native-ci-manifest.json for added, removed or duplicated files');
|
||||
}
|
||||
for (const wrappers of Object.values(manifest['python-ci'])) {
|
||||
for (const wrapper of wrappers) if (!fs.existsSync(path.join(root, wrapper))) throw new Error(`Missing pytest wrapper: ${wrapper}`);
|
||||
}
|
||||
if (process.argv.includes('--list')) {
|
||||
console.log(JSON.stringify({ discovered, runners: manifest }, null, 2));
|
||||
} else {
|
||||
const reports = path.join(root, 'test-results/native-node');
|
||||
fs.mkdirSync(reports, { recursive: true });
|
||||
const result = spawnSync(process.execPath, ['--test', '--test-concurrency=1',
|
||||
'--test-reporter=spec', '--test-reporter-destination=stdout',
|
||||
'--test-reporter=junit', `--test-reporter-destination=${path.join(reports, 'junit.xml')}`,
|
||||
...manifest['native-ci']], {
|
||||
cwd: root, stdio: 'inherit',
|
||||
env: { ...process.env, ENDPOINT_URL: 'http://127.0.0.1:1' },
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
process.exitCode = result.status ?? 1;
|
||||
}
|
||||
+465
@@ -0,0 +1,465 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-closed independent verification and sanitization gate for test diagnostics.
|
||||
|
||||
Runs after test suite completion or abnormal runner termination (e.g. failures, timeouts, crashes).
|
||||
Ensures that no unsanitized traces, raw credentials, temporary archives, or unauthorized
|
||||
diagnostics can ever be published as CI artifacts, while preserving safe JUnit XML reporting.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
# Add repository root to Python path to import sanitizer
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
try:
|
||||
from scripts.sanitize_trace_artifacts import sanitize_trace_archive
|
||||
except ImportError:
|
||||
# If import fails, define fail-closed fallback
|
||||
def sanitize_trace_archive(zp: str) -> bool:
|
||||
p = Path(zp)
|
||||
if p.exists():
|
||||
try:
|
||||
p.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||
logger = logging.getLogger("verify_sanitized_artifacts")
|
||||
|
||||
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
ALLOWED_JUNIT_ROOT_TAGS = frozenset({"testsuites", "testsuite"})
|
||||
ALLOWED_PUBLISHED_JUNIT_TAGS = frozenset({"testsuites", "testsuite", "testcase", "failure", "error", "skipped"})
|
||||
DISALLOWED_JUNIT_TAGS = frozenset({"properties", "property", "system-out", "system-err"})
|
||||
|
||||
ALLOWED_ATTRIBUTES_BY_TAG = {
|
||||
"testsuites": frozenset({"name", "tests", "failures", "errors", "skipped", "time", "id", "disabled"}),
|
||||
"testsuite": frozenset({
|
||||
"name", "tests", "failures", "errors", "skipped", "time", "timestamp", "hostname",
|
||||
"id", "package", "disabled", "file"
|
||||
}),
|
||||
"testcase": frozenset({"name", "classname", "time", "file", "line", "assertions", "status"}),
|
||||
"failure": frozenset({"message", "type"}),
|
||||
"error": frozenset({"message", "type"}),
|
||||
"skipped": frozenset({"message", "type"}),
|
||||
}
|
||||
|
||||
SAFE_SKIPPED_STATUSES = frozenset({"skipped", "todo", "ignored", "disabled", "pending", "omitted", "test skipped"})
|
||||
|
||||
SENSITIVE_KEY_PATTERN = re.compile(
|
||||
r"(?i)(password|passwd|secret|token|api_key|apikey|access_token|refresh_token|"
|
||||
r"private_key|auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)"
|
||||
)
|
||||
|
||||
SECRET_REDACTION_PATTERNS = [
|
||||
# 1. Authorization headers (Bearer / Basic)
|
||||
re.compile(r"(?i)\b(bearer|basic)\s+([^\s,;\'\"<>]+)"),
|
||||
# 2. Sensitive key-value pairs (key=value or key: value)
|
||||
re.compile(
|
||||
r"(?i)\b([a-z0-9_.-]*(?:password|passwd|secret|token|api_key|apikey|access_token|refresh_token|"
|
||||
r"auth_token|client_secret|session|session_id|sid|csrf|xsrf|jwt|credential|authorization)[a-z0-9_.-]*)"
|
||||
r"\s*([:=])\s*([^\s,;\'\"<>]+)"
|
||||
),
|
||||
# 3. URL credentials (scheme://user:pass@host)
|
||||
re.compile(r"([a-zA-Z][a-zA-Z0-9+.-]*://[^:\s]+):([^@\s]+)@"),
|
||||
# 4. JSON-formatted key-value pairs
|
||||
re.compile(
|
||||
r'(?i)("?(?:[a-z0-9_.-]*(?:password|passwd|secret|token|key|auth)[a-z0-9_.-]*)"?\s*:\s*")([^"]+)(")'
|
||||
),
|
||||
# 5. Synthetic secret keywords or tokens
|
||||
re.compile(r"(?i)\b[a-z0-9_.-]*synthetic[-_]?[a-z0-9_.-]*\b"),
|
||||
]
|
||||
|
||||
|
||||
def safe_remove(filepath: str | Path) -> bool:
|
||||
"""Safely remove a file or directory fail-closed.
|
||||
|
||||
Returns True if the target does not exist or was successfully removed.
|
||||
Returns False if removal failed and the target still exists on disk.
|
||||
"""
|
||||
p = Path(filepath)
|
||||
if not p.exists():
|
||||
return True
|
||||
try:
|
||||
if p.is_dir():
|
||||
shutil.rmtree(p, ignore_errors=False)
|
||||
else:
|
||||
p.unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
logger.error("Failed to delete unauthorized file %s: %s", filepath, exc)
|
||||
return False
|
||||
|
||||
if p.exists():
|
||||
logger.error("Target %s still exists on disk after deletion attempt", filepath)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def sanitize_text_content(text: str) -> tuple[str, bool]:
|
||||
"""Scrub sensitive credentials, tokens, and synthetic secrets from text."""
|
||||
if not text or not isinstance(text, str):
|
||||
return text, False
|
||||
|
||||
modified = False
|
||||
out = text
|
||||
|
||||
# 1. Replace Bearer / Basic tokens
|
||||
def _sub_auth(m: re.Match) -> str:
|
||||
nonlocal modified
|
||||
modified = True
|
||||
return f"{m.group(1)} [REDACTED]"
|
||||
|
||||
out = SECRET_REDACTION_PATTERNS[0].sub(_sub_auth, out)
|
||||
|
||||
# 2. Replace key-value pairs
|
||||
def _sub_kv(m: re.Match) -> str:
|
||||
nonlocal modified
|
||||
modified = True
|
||||
return f"{m.group(1)}{m.group(2)}[REDACTED]"
|
||||
|
||||
out = SECRET_REDACTION_PATTERNS[1].sub(_sub_kv, out)
|
||||
|
||||
# 3. Replace URL credentials
|
||||
def _sub_url(m: re.Match) -> str:
|
||||
nonlocal modified
|
||||
modified = True
|
||||
return f"{m.group(1)}:[REDACTED]@"
|
||||
|
||||
out = SECRET_REDACTION_PATTERNS[2].sub(_sub_url, out)
|
||||
|
||||
# 4. Replace JSON key-value secrets
|
||||
def _sub_json(m: re.Match) -> str:
|
||||
nonlocal modified
|
||||
modified = True
|
||||
return f"{m.group(1)}[REDACTED]{m.group(3)}"
|
||||
|
||||
out = SECRET_REDACTION_PATTERNS[3].sub(_sub_json, out)
|
||||
|
||||
# 5. Replace synthetic secret markers
|
||||
def _sub_synthetic(m: re.Match) -> str:
|
||||
nonlocal modified
|
||||
modified = True
|
||||
return "[REDACTED]"
|
||||
|
||||
out = SECRET_REDACTION_PATTERNS[4].sub(_sub_synthetic, out)
|
||||
|
||||
return out, modified
|
||||
|
||||
|
||||
def prune_and_sanitize_junit_element(elem: ET.Element) -> bool:
|
||||
"""Recursively enforce restrictive published-JUnit representation.
|
||||
|
||||
Removes properties, property entries, system-out, system-err, and any
|
||||
unnecessary free-form content. Preserves test identities, counts, outcomes,
|
||||
and safe failure classifications.
|
||||
Returns True if any modifications were made.
|
||||
"""
|
||||
modified = False
|
||||
|
||||
# 1. Prune child elements not permitted in restrictive published representation
|
||||
for child in list(elem):
|
||||
tag_lower = child.tag.lower()
|
||||
if tag_lower in DISALLOWED_JUNIT_TAGS or tag_lower not in ALLOWED_PUBLISHED_JUNIT_TAGS:
|
||||
elem.remove(child)
|
||||
modified = True
|
||||
else:
|
||||
if prune_and_sanitize_junit_element(child):
|
||||
modified = True
|
||||
|
||||
tag_lower = elem.tag.lower()
|
||||
|
||||
# 2. Prune attributes not permitted in restrictive published representation
|
||||
allowed_attrs = ALLOWED_ATTRIBUTES_BY_TAG.get(tag_lower, frozenset())
|
||||
for attr_name in list(elem.attrib.keys()):
|
||||
attr_lower = attr_name.lower()
|
||||
if attr_lower not in allowed_attrs:
|
||||
del elem.attrib[attr_name]
|
||||
modified = True
|
||||
elif tag_lower == "skipped":
|
||||
val = elem.attrib[attr_name].strip()
|
||||
if val.lower() in SAFE_SKIPPED_STATUSES:
|
||||
if elem.attrib[attr_name] != val.lower():
|
||||
elem.attrib[attr_name] = val.lower()
|
||||
modified = True
|
||||
else:
|
||||
elem.attrib[attr_name] = "skipped"
|
||||
modified = True
|
||||
else:
|
||||
val = elem.attrib[attr_name]
|
||||
if isinstance(val, str):
|
||||
new_val, changed = sanitize_text_content(val)
|
||||
if changed:
|
||||
elem.attrib[attr_name] = new_val
|
||||
modified = True
|
||||
|
||||
# 3. Handle text content: structural and skipped elements do not carry free-form text
|
||||
if tag_lower in ("testsuites", "testsuite", "testcase", "skipped"):
|
||||
if elem.text:
|
||||
elem.text = None
|
||||
modified = True
|
||||
elif tag_lower in ("failure", "error"):
|
||||
if elem.text:
|
||||
new_text, changed = sanitize_text_content(elem.text)
|
||||
if changed:
|
||||
elem.text = new_text
|
||||
modified = True
|
||||
|
||||
# 4. Clean up tails: strip any non-whitespace characters from tails to prevent leaks
|
||||
if elem.tail and elem.tail.strip():
|
||||
cleaned_tail = re.sub(r"\S+", "", elem.tail)
|
||||
if cleaned_tail != elem.tail:
|
||||
elem.tail = cleaned_tail
|
||||
modified = True
|
||||
|
||||
return modified
|
||||
|
||||
|
||||
def sanitize_and_verify_junit_xml(filepath: str | Path) -> bool:
|
||||
"""Verify that an XML file is an authentic JUnit report and enforce published representation.
|
||||
|
||||
Returns True if the XML file is a valid recognized JUnit report and was safely sanitized.
|
||||
Returns False if the file is invalid, unrecognized, corrupted, or failed sanitization.
|
||||
"""
|
||||
p = Path(filepath)
|
||||
name_lower = p.name.lower()
|
||||
|
||||
# 1. Filename restriction: must match standard JUnit report naming
|
||||
if not (name_lower == "junit.xml" or name_lower == "browser-junit.xml" or name_lower.endswith("-junit.xml") or name_lower.endswith(".junit.xml")):
|
||||
logger.warning("Unrecognized XML report filename %s; not an authorized JUnit report", filepath)
|
||||
return False
|
||||
|
||||
# 2. Parse XML
|
||||
try:
|
||||
tree = ET.parse(p)
|
||||
except Exception as exc:
|
||||
logger.warning("Corrupted XML report %s: %s", filepath, exc)
|
||||
return False
|
||||
|
||||
root = tree.getroot()
|
||||
# 3. Root tag restriction
|
||||
if root.tag.lower() not in ALLOWED_JUNIT_ROOT_TAGS:
|
||||
logger.warning("XML report %s has invalid root element <%s>; not a JUnit report", filepath, root.tag)
|
||||
return False
|
||||
|
||||
# 4. Enforce restrictive published representation on root and all descendants
|
||||
prune_and_sanitize_junit_element(root)
|
||||
|
||||
# 5. Normalize and rewrite sanitized report
|
||||
try:
|
||||
tree.write(str(p), encoding="utf-8", xml_declaration=True)
|
||||
# Verify rewritten XML parses cleanly
|
||||
ET.parse(p)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to write sanitized XML report %s: %s", filepath, exc)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def is_valid_disposable_ci_fixture(target_dir: str | Path | None = None) -> bool:
|
||||
"""Verify that execution ran inside an authorized disposable synthetic fixture."""
|
||||
is_ci = bool(os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS"))
|
||||
if not is_ci:
|
||||
# Local runs require explicit trace opt-in
|
||||
trace_opt_in = os.environ.get("PLAYWRIGHT_ENABLE_TRACE", "").lower() in ("1", "true")
|
||||
return trace_opt_in
|
||||
|
||||
if os.environ.get("ODYSSEUS_E2E_DISPOSABLE_ARTIFACTS") != "1":
|
||||
return False
|
||||
if os.environ.get("PHOTO_EDITOR_E2E_AUTH") == "0":
|
||||
return False
|
||||
|
||||
allowed_tmp = [os.path.abspath(p) for p in (tempfile.gettempdir(), "/tmp", "/var/tmp")]
|
||||
|
||||
# Check 1: Verify fixture ownership via PHOTO_EDITOR_E2E_DATA_DIR
|
||||
data_dir = os.environ.get("PHOTO_EDITOR_E2E_DATA_DIR")
|
||||
if data_dir:
|
||||
resolved = os.path.abspath(data_dir)
|
||||
base_name = os.path.basename(resolved)
|
||||
is_tmp = any(resolved.startswith(b + os.sep) or resolved == b for b in allowed_tmp)
|
||||
if base_name.startswith("odysseus-photo-editor-e2e-") and is_tmp:
|
||||
expected_db = os.path.join(resolved, "app.db")
|
||||
db_path = os.environ.get("PHOTO_EDITOR_E2E_DB_PATH")
|
||||
if not db_path or db_path == expected_db:
|
||||
marker_path = os.path.join(resolved, ".disposable-ci-fixture.json")
|
||||
if os.path.exists(marker_path):
|
||||
try:
|
||||
with open(marker_path, "r", encoding="utf-8") as f:
|
||||
fixture = json.load(f)
|
||||
if (
|
||||
fixture.get("synthetic") is True
|
||||
and fixture.get("authenticated") is True
|
||||
and fixture.get("database") == expected_db
|
||||
):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check 2: Verify fixture proof in target_dir if provided
|
||||
if target_dir:
|
||||
target_marker = os.path.join(str(target_dir), ".disposable-ci-fixture.json")
|
||||
if os.path.exists(target_marker):
|
||||
try:
|
||||
with open(target_marker, "r", encoding="utf-8") as f:
|
||||
fixture = json.load(f)
|
||||
db_in_fixture = fixture.get("database", "")
|
||||
resolved_db = os.path.abspath(db_in_fixture)
|
||||
db_dir = os.path.dirname(resolved_db)
|
||||
db_base = os.path.basename(db_dir)
|
||||
is_tmp = any(db_dir.startswith(b + os.sep) or db_dir == b for b in allowed_tmp)
|
||||
if (
|
||||
fixture.get("synthetic") is True
|
||||
and fixture.get("authenticated") is True
|
||||
and db_base.startswith("odysseus-photo-editor-e2e-")
|
||||
and is_tmp
|
||||
and os.path.basename(resolved_db) == "app.db"
|
||||
):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def verify_and_sanitize_artifacts(target_dir: str) -> bool:
|
||||
"""Independently verify and sanitize diagnostics fail-closed, preserving safe JUnit XML.
|
||||
|
||||
Returns True if verification succeeded and artifacts are safe for publication.
|
||||
Returns False if verification failed or deletion could not be confirmed.
|
||||
"""
|
||||
if not os.path.exists(target_dir):
|
||||
logger.info("Target directory %s does not exist; nothing to sanitize.", target_dir)
|
||||
return True
|
||||
|
||||
disposable_ci = is_valid_disposable_ci_fixture(target_dir)
|
||||
preserved_xmls = 0
|
||||
sanitized_archives = 0
|
||||
purged_files = 0
|
||||
overall_success = True
|
||||
|
||||
try:
|
||||
for root, dirs, files in os.walk(target_dir, topdown=False):
|
||||
for filename in files:
|
||||
filepath = os.path.join(root, filename)
|
||||
name_lower = filename.lower()
|
||||
|
||||
# Cleanup disposable fixture marker so it is not published
|
||||
if filename == ".disposable-ci-fixture.json":
|
||||
if not safe_remove(filepath):
|
||||
overall_success = False
|
||||
continue
|
||||
|
||||
# 1. XML reports: verify JUnit structure and sanitize credentials
|
||||
if name_lower.endswith(".xml"):
|
||||
if sanitize_and_verify_junit_xml(filepath):
|
||||
preserved_xmls += 1
|
||||
continue
|
||||
else:
|
||||
logger.warning("Rejecting invalid/unrecognized XML report %s; purging fail-closed", filepath)
|
||||
if not safe_remove(filepath):
|
||||
overall_success = False
|
||||
purged_files += 1
|
||||
continue
|
||||
|
||||
# 2. Check if diagnostics are authorized (disposable synthetic fixture)
|
||||
if not disposable_ci:
|
||||
logger.info("Purging diagnostic artifact %s (disposable CI fixture not verified)", filepath)
|
||||
if not safe_remove(filepath):
|
||||
overall_success = False
|
||||
purged_files += 1
|
||||
continue
|
||||
|
||||
# 3. Disposable CI is authorized: enforce strict sanitization
|
||||
if name_lower.endswith(".zip"):
|
||||
ok = sanitize_trace_archive(filepath)
|
||||
if ok and os.path.exists(filepath):
|
||||
sanitized_archives += 1
|
||||
else:
|
||||
logger.warning("Trace archive %s failed sanitization; purging fail-closed", filepath)
|
||||
if not safe_remove(filepath):
|
||||
overall_success = False
|
||||
purged_files += 1
|
||||
|
||||
elif name_lower.endswith(".png"):
|
||||
# Validate PNG magic bytes to ensure not a secret payload disguised as an image
|
||||
is_png = False
|
||||
try:
|
||||
with open(filepath, "rb") as pf:
|
||||
header = pf.read(8)
|
||||
if header == PNG_MAGIC:
|
||||
is_png = True
|
||||
except Exception:
|
||||
is_png = False
|
||||
|
||||
if is_png:
|
||||
continue
|
||||
|
||||
logger.warning("Invalid or suspicious screenshot %s; purging fail-closed", filepath)
|
||||
if not safe_remove(filepath):
|
||||
overall_success = False
|
||||
purged_files += 1
|
||||
|
||||
else:
|
||||
# Purge any .tmp, .har, .db, .sqlite, .log or unrecognized files
|
||||
logger.info("Purging unauthorized diagnostic file %s", filepath)
|
||||
if not safe_remove(filepath):
|
||||
overall_success = False
|
||||
purged_files += 1
|
||||
|
||||
# Remove empty subdirectories
|
||||
for d in dirs:
|
||||
dirpath = os.path.join(root, d)
|
||||
try:
|
||||
if not os.listdir(dirpath):
|
||||
os.rmdir(dirpath)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if not overall_success:
|
||||
logger.error("Verification failed: one or more unauthorized files could not be purged.")
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
"Artifact verification complete for %s: preserved %d XML reports, sanitized %d archives, purged %d files.",
|
||||
target_dir,
|
||||
preserved_xmls,
|
||||
sanitized_archives,
|
||||
purged_files,
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Unexpected error during artifact verification: %s; purging all non-XML files fail-closed", exc)
|
||||
try:
|
||||
for p in Path(target_dir).rglob("*"):
|
||||
if p.is_file() and not p.name.lower().endswith(".xml"):
|
||||
safe_remove(p)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("target_dir", nargs="?", default="test-results", help="Directory containing test results")
|
||||
args = parser.parse_args()
|
||||
|
||||
ok = verify_and_sanitize_artifacts(args.target_dir)
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -179,7 +179,6 @@ jobs:
|
||||
uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
|
||||
python scripts/check_ca_trust.py --python
|
||||
uv pip install --system -r requirements.txt
|
||||
uv pip install --system "playwright==1.62.0"
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
@@ -264,20 +263,3 @@ jobs:
|
||||
ODYSSEUS_TEST_PYTHON=python ODYSSEUS_LAYOUT_BROWSER=chromium npx --no-install playwright test \
|
||||
--config tests/e2e/ios-layout.config.cjs \
|
||||
--reporter=line
|
||||
|
||||
benchmark-gfm-security:
|
||||
name: Benchmark GFM rendering security
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
# Test-only renderer, pinned independently of application requirements.
|
||||
- name: Install GitHub Flavored Markdown renderer
|
||||
run: |
|
||||
python -m pip install --disable-pip-version-check --only-binary=:all: "cmarkgfm==2025.10.22"
|
||||
- name: Verify rendered benchmark report security
|
||||
run: python tests/test_audit_c5_benchmark_evidence.py --gfm
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
name: Native test suites
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: native-tests-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
native-node:
|
||||
name: Native Node regressions
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22.23.4'
|
||||
cache: npm
|
||||
- run: npm ci --ignore-scripts --no-audit --no-fund
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ms-playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- name: Validate exhaustive disjoint runner mapping and execute omitted suites
|
||||
run: |
|
||||
node .github/scripts/run_native_tests.mjs
|
||||
python .github/scripts/check_test_execution.py test-results/native-node/junit.xml --minimum 43
|
||||
- name: Verify and sanitize native test results fail-closed
|
||||
id: verify-native-results
|
||||
if: always()
|
||||
run: |
|
||||
python .github/scripts/verify_sanitized_artifacts.py test-results/native-node/
|
||||
- name: Save native test identities and results
|
||||
if: always() && steps.verify-native-results.outcome == 'success'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: native-node-results
|
||||
path: test-results/native-node/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
browser:
|
||||
name: Chromium application regressions
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
ODYSSEUS_E2E_DISPOSABLE_ARTIFACTS: '1'
|
||||
ODYSSEUS_TEST_PYTHON: python
|
||||
CHROMADB_HOST: 127.0.0.1
|
||||
CHROMADB_PORT: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install FFmpeg and establish system trust
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends ffmpeg ca-certificates
|
||||
python scripts/check_ca_trust.py --system
|
||||
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
with:
|
||||
version: '0.13.5'
|
||||
enable-cache: true
|
||||
cache-dependency-glob: requirements.txt
|
||||
prune-cache: false
|
||||
- run: |
|
||||
uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
|
||||
python scripts/check_ca_trust.py --python
|
||||
uv pip install --system -r requirements.txt
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22.23.4'
|
||||
cache: npm
|
||||
- run: npm ci --ignore-scripts --no-audit --no-fund
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ms-playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- name: Run complete Chromium suite against a fresh authenticated app
|
||||
env:
|
||||
PLAYWRIGHT_JUNIT_OUTPUT_FILE: test-results/browser-junit.xml
|
||||
run: |
|
||||
npm run test:photo-editor -- --reporter=line,junit
|
||||
python .github/scripts/check_test_execution.py test-results/browser-junit.xml --minimum 137
|
||||
- name: Verify and sanitize failure diagnostics fail-closed
|
||||
id: verify-browser-results
|
||||
if: always()
|
||||
run: |
|
||||
python .github/scripts/verify_sanitized_artifacts.py test-results/
|
||||
- name: Save per-test results and sanitized failure diagnostics
|
||||
if: always() && steps.verify-browser-results.outcome == 'success'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: chromium-regression-results
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
authoritative-native-suites:
|
||||
name: Authoritative native suites
|
||||
if: always()
|
||||
needs: [native-node, browser]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Require both complete suites to succeed
|
||||
env:
|
||||
NODE_RESULT: ${{ needs.native-node.result }}
|
||||
BROWSER_RESULT: ${{ needs.browser.result }}
|
||||
run: |
|
||||
test "$NODE_RESULT" = success
|
||||
test "$BROWSER_RESULT" = success
|
||||
+63
-107
@@ -1,50 +1,28 @@
|
||||
# Contributing to Odysseus
|
||||
|
||||
Thanks for helping improve Odysseus. The best contributions are **focused, reviewable, and backed by meaningful validation**.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Public pull requests target `dev`, not `main`.** Search existing work before implementing, and follow the current [PR template](.github/pull_request_template.md). Runtime changes require relevant execution evidence, not just a green CI badge.
|
||||
|
||||
## At a glance
|
||||
|
||||
| Topic | What contributors should do |
|
||||
| --- | --- |
|
||||
| **Branch** | Target `dev`; `main` is the curated release branch |
|
||||
| **Scope** | One coherent change per PR; coordinate larger modularization work through [#4071](https://github.com/odysseus-dev/odysseus/issues/4071) |
|
||||
| **Validation** | Document commands, results, runtime checks, and anything not tested |
|
||||
| **Visual changes** | Run the UI, match the existing visual language, and attach screenshots or clips |
|
||||
| **Review** | Follow the [risk-based maintainer policy](docs/maintainers/changes-and-review.md) and applicable repository protections |
|
||||
| **Security** | Report vulnerabilities privately using [SECURITY.md](SECURITY.md) |
|
||||
|
||||
**Jump to:** [Branches](#branch-model) · [Getting started](#before-you-start) · [Setup](#setup) · [Checks](#running-checks) · [Pull requests](#pull-requests) · [Modularization](#structural-changes-and-modularization) · [Visual changes](#style-and-visual-changes) · [Code conventions](#code-conventions) · [Issue reports](#issue-reports)
|
||||
|
||||
---
|
||||
Thanks for helping. The project is moving quickly, so the best contributions are focused, easy to review, and easy to test.
|
||||
|
||||
## Branch model
|
||||
|
||||
| Branch | Purpose |
|
||||
| --- | --- |
|
||||
| **`dev`** | Default integration branch and target for every public PR. Work can be in flux. |
|
||||
| **`main`** | Curated release branch. A [Release Maintainer](docs/maintainers/testing-and-releases.md#release-ownership) promotes a verified `dev` commit. |
|
||||
Odysseus has two branches:
|
||||
|
||||
If your PR accidentally targets `main`, use **Edit** on GitHub to change its base to `dev`. Changing the base does not itself require a rebase.
|
||||
- **`dev`** — where all PRs land. Things can be in flux here; the merge button gets used freely.
|
||||
- **`main`** — what users run. Curated and tested by the release's [Release Maintainer](docs/maintainers/testing-and-releases.md#release-ownership). Fast-forwarded to a stable `dev` commit at each release.
|
||||
|
||||
End users cloning the repository land on `dev` by default. To use the curated release branch, run `git checkout main` after cloning.
|
||||
**Open your PR against `dev`, not `main`.** The GitHub "base" dropdown defaults to `dev`. If you opened a PR against `main` by accident, click "Edit" on the PR and change the base — no rebase needed.
|
||||
|
||||
End-users cloning the repo will land on `dev` by default. To run the curated/stable version: `git checkout main` after clone.
|
||||
|
||||
## Before You Start
|
||||
|
||||
1. Search [open issues](https://github.com/odysseus-dev/odysseus/issues) and [PRs](https://github.com/odysseus-dev/odysseus/pulls) to avoid duplicate or conflicting work.
|
||||
2. Prefer one bug fix, feature, or coherent refactor per PR. Avoid broad rewrites, formatting-only churn, and unrelated file moves.
|
||||
3. For a feature, open a **Feature Request** first. Big, new-direction, or difficult-to-reverse changes require the documented [product decision process](docs/maintainers/product-and-features.md#feature-proposal-workflow).
|
||||
4. For structural work, coordinate the intended module boundary with maintainers and the [modularization tracker #4071](https://github.com/odysseus-dev/odysseus/issues/4071).
|
||||
|
||||
> [!NOTE]
|
||||
> **Coding agents:** If you use an LLM coding agent, open an issue describing the problem before submitting its PR. Bulk agent-generated submissions that ignore the project's contribution format or visual style may be closed without review, even when the underlying fix is correct.
|
||||
- Search existing issues and pull requests before opening a new one.
|
||||
- Prefer one bug fix or feature per pull request.
|
||||
- Avoid broad rewrites, formatting-only changes, or moving many files unless the issue is specifically about structure.
|
||||
- If you want to work on a large feature, open an issue first and describe the approach.
|
||||
|
||||
## Setup
|
||||
|
||||
<details open>
|
||||
<summary><strong>Docker (recommended for normal testing)</strong></summary>
|
||||
Docker is the recommended path for normal testing:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/odysseus-dev/odysseus.git
|
||||
@@ -53,10 +31,7 @@ cp .env.example .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Manual Python development (Python 3.11+)</strong></summary>
|
||||
Manual development uses Python 3.11+:
|
||||
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
@@ -65,14 +40,11 @@ pip install -r requirements.txt
|
||||
python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
> [!NOTE]
|
||||
> Native Windows is not actively tested. Docker on Linux or a Linux/macOS manual installation is the safer path for now. Document the environment actually used for testing.
|
||||
Windows is not actively tested. Docker on Linux or a Linux/macOS manual install is the safer path for now.
|
||||
|
||||
## Running Checks
|
||||
|
||||
Run relevant checks for the affected area. Examples from the existing contributor workflow:
|
||||
Run the smallest relevant checks for your change:
|
||||
|
||||
```bash
|
||||
python -m pytest
|
||||
@@ -88,94 +60,78 @@ docker compose up -d --build
|
||||
docker compose logs --tail=120 odysseus
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Report what actually ran.** Include commands, results, and relevant manual/runtime verification in the PR description. If a check was not run, mark it as **not run** and explain the limitation. Passing syntax checks and unit tests do not automatically prove changed behavior works in the running application.
|
||||
|
||||
See the [testing and releases handbook](docs/maintainers/testing-and-releases.md) for risk-based validation and isolation requirements.
|
||||
Mention what you ran in the pull request description. If you could not run a check, say so.
|
||||
|
||||
## Maintainer governance and review guidance
|
||||
|
||||
The [Odysseus maintainer handbook](docs/maintainers/README.md) is authoritative for repository roles, product decisions, risk-based approvals, issue triage, security review, CodeQL findings, and release handoffs.
|
||||
|
||||
> [!NOTE]
|
||||
> The handbook complements this contributor guide. It does not override the branch model, live GitHub protection rules, or the PR template.
|
||||
For repository roles, product decisions, risk-based approvals, issue triage, independent security reviews, CodeQL findings, and release handoffs, see the [Odysseus maintainer handbook](docs/maintainers/README.md). This complements the contributor requirements below and does not change the branch model or current PR template.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
Keep PRs **cohesive**. Small, focused changes are the default. A larger, coordinated module decomposition may be appropriate under the criteria below, but unrelated cleanup, formatting, refactors, and behavior changes must not be mixed.
|
||||
Good pull requests usually include:
|
||||
|
||||
| Include in the PR | Why it matters |
|
||||
| --- | --- |
|
||||
| **Summary and scope** | Explain the problem, intended behavior, and areas changed |
|
||||
| **Linked issue** | Connect the PR to the existing report or approved work, for example `Fixes #123` or `Part of #4071` |
|
||||
| **Validation evidence** | Show manual test steps or automated results that exercise the actual behavior |
|
||||
| **UI evidence, when applicable** | Attach screenshots or short recordings from the running application |
|
||||
| **Limitations** | Clearly identify tests or environments that were not exercised |
|
||||
- A short explanation of the bug or feature.
|
||||
- The files or areas changed.
|
||||
- Manual test steps or automated test results from running the actual app, not just the test suite.
|
||||
- Screenshots or short recordings for UI changes.
|
||||
- Links to related issues, for example `Fixes #123`.
|
||||
|
||||
For exact required sections and attestations, follow the current [PR template](.github/pull_request_template.md).
|
||||
Please keep PRs small. Large PRs that mix unrelated cleanup, formatting, refactors, and behavior changes are much harder to review.
|
||||
|
||||
### Structural changes and modularization
|
||||
|
||||
**One coherent responsibility is the unit of review.** A larger PR can be appropriate when extracting that responsibility from an oversized module is safer or simpler than splitting the work into many conflicting or interdependent PRs.
|
||||
|
||||
Before implementation and review:
|
||||
|
||||
- **Coordinate:** Read existing issues/PRs and agree on the boundary with maintainers. Use [#4071](https://github.com/odysseus-dev/odysseus/issues/4071) for codebase modularization.
|
||||
- **Constrain scope:** Keep one subsystem or responsibility per PR. Avoid unrelated features, formatting, and opportunistic cleanup.
|
||||
- **Preserve contracts:** Maintain observable behavior, imports, public interfaces, persistence contracts, and runtime invariants unless an approved issue explicitly scopes a change.
|
||||
- **Validate behavior:** Include targeted regression tests and runtime checks. Frontend work needs browser validation and screenshots or recordings when rendering may change.
|
||||
- **Explain tradeoffs:** Describe extraction boundaries, compatibility, validation results, and known limitations in the PR.
|
||||
|
||||
> [!TIP]
|
||||
> **Large does not mean broad.** There is no arbitrary line-count limit for a well-scoped extraction. Correctness, reviewability, and integration risk determine whether the scope is acceptable.
|
||||
|
||||
> [!CAUTION]
|
||||
> **Temporary Harness V2 coordination boundary:** Unrelated changes to `src/agent_loop.py` and closely related execution, authorization, orchestration, and context contracts are temporarily frozen while Harness V2 is developed and validated. Coordinate such work with maintainers. Other modules remain open, and urgent bug or security reports are welcome.
|
||||
|
||||
These contributions remain subject to the [risk-based review policy](docs/maintainers/changes-and-review.md), current repository protections, and PR template.
|
||||
> **Auto-generated PRs.** If you are running an LLM agent (Devin, Cursor, OpenHands, Claude Code, etc.) against this repo: please open an issue describing the problem first instead of opening a PR directly. Bulk agent-generated PRs that don't match the project's visual style or contribution format will be closed without review, even when the underlying fix is correct.
|
||||
|
||||
## Style and visual changes
|
||||
|
||||
Odysseus has an intentional visual language. Changes that ignore it can be closed without merge even when their underlying code is correct.
|
||||
Odysseus has an intentional visual style. PRs that ignore it will be closed without merge, no matter how correct the underlying code is.
|
||||
|
||||
For any change affecting buttons, icons, typography, colors, spacing, layout, CSS, HTML, SVG, or JavaScript that renders into the DOM:
|
||||
Before submitting any change that affects what the app looks like — buttons, icons, fonts, colors, spacing, layout, CSS, HTML, SVG, or any `static/js/` module that draws to the DOM — please:
|
||||
|
||||
1. **Run the application locally** and inspect the change in a browser. Type checks and unit tests alone are insufficient.
|
||||
2. **Attach a screenshot or short clip** from the running app, plus a mobile screenshot when mobile behavior changes.
|
||||
3. **Reuse the existing design system:**
|
||||
- Prefer existing CSS tokens (`--red`, `--fg`, `--bg`, `--card`, `--border`, etc.). Do not introduce parallel colors, type scales, or spacing systems.
|
||||
- Reuse existing button, input, card, and border classes rather than creating duplicate patterns.
|
||||
- Do **not** add Unicode emoji in UI or code. Use inline SVG consistent with `static/index.html` or plain text.
|
||||
- Preserve the existing primary monospaced font (`Fira Code`).
|
||||
- Keep dark mode as the default; route any light-mode work through the existing theme system.
|
||||
4. **Extend existing components** instead of introducing a parallel component for the same purpose.
|
||||
1. **Run the app locally** and view the change in a browser. Type-checks and unit tests are not enough.
|
||||
2. **Attach a screenshot or short clip** of the change in the running app. Add a mobile screenshot too if the change affects mobile.
|
||||
3. **Match the existing visual language.** Specifically:
|
||||
- Reuse existing CSS variables (`--red`, `--fg`, `--bg`, `--card`, `--border`, …). Do not introduce new color values, font sizes, or spacing units.
|
||||
- Reuse existing button, input, card, and border classes. Don't invent parallel styling for similar widgets.
|
||||
- **No Unicode emoji in UI or code.** Use inline SVG (matching the monochrome icon style already in `static/index.html`) or plain text.
|
||||
- Monospaced font (`Fira Code`) for primary UI text. Don't override.
|
||||
- Dark theme is the default; any light-mode work goes through the existing theme system, not hard-coded.
|
||||
4. **Don't add parallel components.** If a similar widget already exists in the app, extend it instead of writing a new one.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> If you are unsure whether a change is visual, assume that it is and provide a screenshot. Visual evidence does not replace behavioral validation.
|
||||
If you are unsure whether a change is "visual," it is. Default to attaching a screenshot.
|
||||
|
||||
## Code conventions
|
||||
|
||||
**Use existing constants and helpers.** Hardcoded duplicates drift, break non-default deployments, and can reintroduce previously fixed issues.
|
||||
Don't hardcode values that the project already exposes through a constant or a helper. Hardcoded literals drift out of sync, break on non-default deployments, and reintroduce bugs we've already fixed.
|
||||
|
||||
- **Filesystem paths:** Do not derive writable paths from `Path(__file__)...` into the source tree, hardcode `/app/...`, or use a relative `"data/..."` string. Persisted files and directories have named constants in `src/constants.py`, such as `AUTH_FILE`, `USER_PREFS_FILE`, `SETTINGS_FILE`, `TTS_CACHE_DIR`, and `CHROMA_DIR`. Import those names rather than deriving paths with `os.path.join(DATA_DIR, "x.json")` or `DATA_DIR / "x.json"`. `DATA_DIR` reads `ODYSSEUS_DATA_DIR` and is intended directly only for dynamic paths without fixed names, such as per-owner files. Add a constant when a fixed path does not yet have one. The source tree is read-only in Docker, and `/app/...` does not exist on native runs. Guard directory creation so unwritable paths degrade gracefully rather than crashing at import.
|
||||
- **Internal API and loopback URLs:** Do not hardcode `http://localhost:7000`. Use `internal_api_base()` from `src.constants`, which honors `ODYSSEUS_INTERNAL_BASE` and `APP_PORT`.
|
||||
- **Ports, limits, and model lists:** Reuse existing constants. When a value is shared by multiple locations and has no constant, add one in `src/constants.py` instead of duplicating a literal. `core/constants.py` is a compatibility re-export, not another source of truth.
|
||||
- **Filesystem paths:** never build writable paths from `Path(__file__)...` into the source tree, hardcode `/app/...`, or use a relative `"data/..."` string. Every persisted file and directory has a named constant in `src/constants.py` (for example `AUTH_FILE`, `USER_PREFS_FILE`, `SETTINGS_FILE`, `TTS_CACHE_DIR`, `CHROMA_DIR`). Import and use that named constant; do not re-derive the path locally with `os.path.join(DATA_DIR, "x.json")` or `DATA_DIR / "x.json"`. `DATA_DIR` is the single place that reads `ODYSSEUS_DATA_DIR`, so use it directly only for dynamic paths that have no fixed name (for example per-owner files). If a data file or directory has no constant yet, add one to `src/constants.py`. The source tree is read-only in Docker and `/app/...` does not exist on native runs; guard directory creation so an unwritable path degrades gracefully instead of crashing at import.
|
||||
- **Internal API / loopback URLs:** don't hardcode `http://localhost:7000`. Use `internal_api_base()` from `src.constants` (it honors `ODYSSEUS_INTERNAL_BASE` / `APP_PORT`).
|
||||
- **Ports, limits, model lists, and similar:** reuse the existing constant if one exists; if it doesn't and the value is used in more than one place, add a constant rather than copying the literal.
|
||||
|
||||
**Commits:** Use [Conventional Commits](https://www.conventionalcommits.org) with `type(scope): summary`, for example `fix(search): ...`, `feat(notes): ...`, or `docs(contributing): ...`. Common types include `fix`, `feat`, `refactor`, `docs`, `test`, `chore`, and `ci`. Keep the subject short and imperative, with the motivation in the body when useful.
|
||||
If you need a value that has no constant or helper yet, add it to `src/constants.py` (the single source of truth for paths and config; `core/constants.py` only re-exports it for backward compatibility) and import it, rather than repeating a literal across files.
|
||||
|
||||
**Commits:** use [Conventional Commits](https://www.conventionalcommits.org), `type(scope): summary` (e.g. `fix(search): ...`, `feat(notes): ...`, `docs(contributing): ...`). Common types: `fix`, `feat`, `refactor`, `docs`, `test`, `chore`, `ci`. Keep the subject short and imperative; put the "why" in the body when it isn't obvious.
|
||||
|
||||
## Issue Reports
|
||||
|
||||
| Bug reports should include | Model-serving reports should additionally include |
|
||||
| --- | --- |
|
||||
| Installation method (Docker, Python, WSL, etc.) | Backend (Ollama, vLLM, SGLang, llama.cpp, LM Studio, etc.) |
|
||||
| OS, browser, and device when relevant | Model name |
|
||||
| Exact reproduction steps | GPU/CPU and operating system |
|
||||
| Expected versus actual behavior | Cookbook task logs or backend/server logs |
|
||||
| Relevant logs, screenshots, or terminal output | Relevant backend configuration |
|
||||
For bugs, include:
|
||||
|
||||
Issues containing only "help", "does not work", or an unexplained screenshot may be closed as not actionable. Start with a reproducible problem or use [Discussions](https://github.com/odysseus-dev/odysseus/discussions) for setup questions.
|
||||
- Install method: Docker, manual Python, WSL, etc.
|
||||
- OS, browser, and device if relevant.
|
||||
- Exact steps to reproduce.
|
||||
- Expected behavior and actual behavior.
|
||||
- Logs, screenshots, or terminal output.
|
||||
|
||||
For model-serving issues, include:
|
||||
|
||||
- Backend: Ollama, vLLM, SGLang, llama.cpp, LM Studio, etc.
|
||||
- Model name.
|
||||
- GPU/CPU and operating system.
|
||||
- Cookbook task logs or server logs.
|
||||
|
||||
Issues with only "help", "does not work", or a screenshot without context may be closed as not actionable.
|
||||
|
||||
## Security
|
||||
|
||||
> [!CAUTION]
|
||||
> **Do not publish secrets.** Keep API keys, sensitive logs, personal documents, and public IP addresses out of public issues and PRs. Report vulnerabilities privately through [SECURITY.md](SECURITY.md).
|
||||
Do not post secrets, API keys, private logs, personal documents, or public IPs in issues or pull requests.
|
||||
|
||||
For security reports, follow [SECURITY.md](SECURITY.md).
|
||||
|
||||
|
||||
+74
-104
@@ -1,117 +1,87 @@
|
||||
# Roadmap / Help Wanted
|
||||
|
||||
Odysseus is an evolving, self-hosted AI workspace. This roadmap highlights where improvements and contributions are most useful, from reliability and maintainability to product capabilities.
|
||||
Odysseus is on a voyage, but not home yet. It works great for me (lol), but this ship is moving fast and feedback/help would be appreciated! (I don't know what I'm doing, help).
|
||||
|
||||
> [!NOTE]
|
||||
> **Direction, not a release schedule.** This page is not a complete issue backlog or a promise to deliver every proposal. Implementation scope and priority are coordinated through existing issues, pull requests, and the project's [feature proposal process](docs/maintainers/product-and-features.md).
|
||||
If you see weird CSS, strange layout behavior, or a suspiciously murky corner of
|
||||
the codebase, you are probably right to stay away.
|
||||
|
||||
## At a glance
|
||||
## High Priority
|
||||
|
||||
| Area | Direction | Coordination |
|
||||
| --- | --- | --- |
|
||||
| **Stabilization** | Reliability, CI, fresh installs, security, and integrations | [Stabilization & Modularization milestone](https://github.com/odysseus-dev/odysseus/milestone/1) |
|
||||
| **Modularization** | Clearer module ownership, safer changes, and contributor onboarding | [Canonical tracker #4071](https://github.com/odysseus-dev/odysseus/issues/4071) |
|
||||
| **Agent runtime** | Active Harness V2 development, with a narrow temporary change freeze | [Coordination in #4071](https://github.com/odysseus-dev/odysseus/issues/4071) |
|
||||
| **Product improvements** | Local models, research, frontend experience, and operations | [Issues](https://github.com/odysseus-dev/odysseus/issues) and [feature proposals](docs/maintainers/product-and-features.md) |
|
||||
- SQUASH BUGS
|
||||
- Fresh install smoke tests on Linux, macOS, and Windows. Docker, native Python,
|
||||
and WSL all need coverage.
|
||||
|
||||
**Jump to:** [Stabilization](#stabilization-and-reliability) · [Modularization](#modularization-and-maintainability) · [AI and local models](#ai-local-models-and-research) · [Frontend](#frontend-and-user-experience) · [Backend](#backend-and-operations) · [Contributing](#contributing)
|
||||
- Integration audit: do integrations even work? Confirm what works, what needs setup docs, and what should be removed or hidden.
|
||||
- Cookbook reliability on other computers. This is probably the area most likely to need work across different machines, GPUs, drivers, shells, and Python environments.
|
||||
- Cookbook SGLang support across platforms. Make sure SGLang setup/serve works
|
||||
predictably on Linux, Windows/WSL, macOS where possible, Docker, and common
|
||||
NVIDIA/AMD hardware paths.
|
||||
- Deep Research model presets by hardware. Recommend approved model/parameter
|
||||
profiles for small, medium, and large local setups so people with different
|
||||
hardware can use Deep Research without guessing. Surface this either in Deep
|
||||
Research settings or as a Cookbook scan/dropdown suggestion.
|
||||
- Cookbook model scan/download ranking. Prioritize newer architectures and
|
||||
better hardware-fit models instead of scoring everything almost the same.
|
||||
Ranking should account for architecture age, quant format, VRAM/RAM fit,
|
||||
backend support, vision/mmproj requirements, and likely serve reliability.
|
||||
- Cookbook error feedback and logging. Failed downloads, dependency installs,
|
||||
preflights, and serve jobs should show the actual command/output/error in the
|
||||
UI, with copyable logs and clear next steps instead of just "crashed".
|
||||
- Agent prompt/context bloat. Agent mode is too heavy for smaller local models:
|
||||
tool schemas, skills, memory, documents, and instructions can eat the context
|
||||
before the user request really starts. We need slimmer prompts, better tool
|
||||
selection, smaller default tool sets, and clearer guidance for models with
|
||||
4k/8k/16k context windows.
|
||||
- Local model speculative decoding support. For Odysseus-tuned local models,
|
||||
plan to ship or recommend a small same-tokenizer draft model when the serving
|
||||
backend supports it. Early vLLM testing showed a generic `Qwen3-0.6B` draft
|
||||
beside `Qwen3-8B` can materially reduce wall time, while an unsupported
|
||||
DSpark conversion performed poorly. Treat this as a supported draft-model lane
|
||||
first; keep MTP-specific packaging as future work only when the architecture
|
||||
and runtime support are real. Judge this by time-to-success, tool correctness,
|
||||
grammar, and unchanged target output, not tokens/sec alone.
|
||||
- Skill/tool prompt-injection audit. User-editable skills, notes, documents,
|
||||
fetched pages, and memories should be treated as untrusted data. Keep testing
|
||||
whether models follow malicious instructions from those surfaces.
|
||||
- Better degraded-state reporting for ChromaDB, SearXNG, email, ntfy, and provider probes.
|
||||
- Email performance audit. Fetching, searching, opening, deleting, and sending
|
||||
email can feel slow, especially over IMAP/SMTP providers with high latency.
|
||||
Need someone who knows mail performance to profile the current flow, identify
|
||||
whether the bottleneck is IMAP folder select/fetch, cache invalidation,
|
||||
attachment/body loading, SMTP handshakes, or frontend refresh behavior, then
|
||||
propose safer caching/prefetch/batching without breaking multi-account state.
|
||||
- Provider setup/probing audit for Anthropic, Gemini, Groq, xAI, OpenRouter, OpenAI, and DeepSeek.
|
||||
|
||||
---
|
||||
## Refactor Targets
|
||||
- CSS cleanup. `static/style.css` basically Calypso's island atm.
|
||||
- Tour core helper. The onboarding tours have too much copy-pasted scaffolding; promote a shared `tour-core.js` helper before adding more tours.
|
||||
- Modal/window positioning cleanup. Some window controls have improved, but the
|
||||
underlying popup/dropdown/fixed-position behavior is still too fragile.
|
||||
- Mobile media override discoverability. A lot of "CSS did not move" bugs are mobile `@media` overrides of the same selector; comments or linting around desktop/mobile paired rules would help.
|
||||
- Dead code pass for old routes, stale feature flags, and unused UI states.
|
||||
|
||||
## Stabilization and reliability
|
||||
## Frontend
|
||||
|
||||
**Priority:** Make Odysseus dependable across installations, platforms, providers, and supported hardware.
|
||||
- Expand the Editor for quicker, more robust everyday use. Better file/document
|
||||
handling, smoother window behavior, clearer save/export flows, stronger image
|
||||
editing affordances, and fewer brittle edge cases.
|
||||
- Better AI integration for Notes and Todos. Notes should be easier for the
|
||||
agent to read, update, summarize, and turn into actions. Todos should be
|
||||
assignable to an agent from the UI, possibly through a button, task action,
|
||||
or dedicated skill/tool flow.
|
||||
- Mobile gallery/editor polish. Easier to launch/download inpaint model or any missing pieces.
|
||||
- Accessibility pass: keyboard navigation, focus states, contrast, reduced motion.
|
||||
- Improve empty states and error messages on fresh installs.
|
||||
- Tighten first-run setup, hints, and tours so they do not repeat or fight each other.
|
||||
- Vendor CDN assets eventually for a more fully self-hosted/offline mode.
|
||||
|
||||
| Workstream | What needs improvement | Tracking |
|
||||
| --- | --- | --- |
|
||||
| **CI and validation** | Exact-revision validation, meaningful regression coverage, and release confidence | [#5932](https://github.com/odysseus-dev/odysseus/issues/5932), [#7109](https://github.com/odysseus-dev/odysseus/issues/7109) |
|
||||
| **Test quality** | Deterministic tests, isolation, focused execution, and maintainable test structure | [#2523](https://github.com/odysseus-dev/odysseus/issues/2523), [testing standard](tests/TESTING_STANDARD.md) |
|
||||
| **Fresh installations** | Smoke tests for Linux, macOS, Windows, WSL, and Docker; baseline startup and graceful degradation | [#3968](https://github.com/odysseus-dev/odysseus/issues/3968) |
|
||||
| **Email integration** | Remaining Google OAuth regressions, account isolation, and cross-provider reliability | [#5637](https://github.com/odysseus-dev/odysseus/issues/5637) |
|
||||
| **Storage integrity** | Upload payload growth, indexing, and attachment/file lifecycle | [#4889](https://github.com/odysseus-dev/odysseus/issues/4889) |
|
||||
## Backend
|
||||
|
||||
Additional reliability priorities:
|
||||
- More tests around endpoint probing and provider setup.
|
||||
- Better task scheduler defaults and visibility.
|
||||
- Backup/restore guide and helper flow for `data/`.
|
||||
- Security hardening around admin-only tools and clear docs for their risk.
|
||||
|
||||
- **Cookbook:** Improve model installation, dependency management, hardware detection, and model serving across operating systems, GPUs, drivers, shells, Python environments, and supported backends.
|
||||
- **Security:** Keep strengthening authorization, administrative tools, agent tool execution, prompt-injection boundaries, filesystem access, and untrusted-content handling.
|
||||
- **Integrations and degraded states:** Audit supported providers and optional services, including ChromaDB, SearXNG, email, and ntfy. Make failures observable and nonessential outages predictable.
|
||||
## Not The Focus Right Now
|
||||
|
||||
> [!TIP]
|
||||
> Start with an existing, reproducible issue. The [Stabilization & Modularization milestone](https://github.com/odysseus-dev/odysseus/milestone/1) is a curated work queue, not an invitation to implement every item in this roadmap without coordination.
|
||||
|
||||
## Modularization and maintainability
|
||||
|
||||
The goal is **clearer responsibility boundaries and safer development**, not smaller files for their own sake. Changes should preserve behavior, compatibility, and relevant runtime contracts.
|
||||
|
||||
| Area | Current foundation | Remaining opportunities |
|
||||
| --- | --- | --- |
|
||||
| **CSS** | The former `static/style.css` has been split into ordered files under `static/css/` | Improve ownership while preserving the cascade, themes, and responsive layouts |
|
||||
| **Tools** | Substantial tool-domain extraction has landed | Clarify remaining ownership and preserve compatibility through the existing tool interfaces |
|
||||
| **Frontend** | Existing feature modules remain in use | Decompose large document, gallery, settings, calendar, and other UI modules around coherent behavior |
|
||||
| **Backend** | Domain-specific routes and services already exist | Identify safe boundaries in Cookbook routes, persistence, and other tightly coupled modules |
|
||||
| **Tests** | Taxonomy, focused execution, and structural improvements exist | Align fixtures, helpers, and regression coverage with implementation boundaries |
|
||||
| **UI infrastructure** | Targeted cleanup is underway | Consolidate tour scaffolding, modal/window positioning, responsive overrides, and obsolete paths |
|
||||
|
||||
**Canonical coordination:** [Codebase modularization and contributor onboarding, #4071](https://github.com/odysseus-dev/odysseus/issues/4071).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Cohesive work is more important than a small line count.** A larger PR may be appropriate for one well-defined decomposition when several smaller PRs would create avoidable conflicts or integration overhead. Coordinate the boundary, avoid unrelated work, and supply regression and runtime evidence. See [CONTRIBUTING.md](CONTRIBUTING.md#structural-changes-and-modularization).
|
||||
|
||||
### Agent runtime and Harness V2
|
||||
|
||||
Harness V2 is under active development.
|
||||
|
||||
> [!CAUTION]
|
||||
> **Temporary agent-runtime freeze.** Unrelated changes to `src/agent_loop.py` and closely related execution, orchestration, authorization, and context contracts must be coordinated with maintainers while Harness V2 is developed and validated.
|
||||
>
|
||||
> This does **not** freeze unrelated application modules. Urgent bug reports and security issues remain welcome. No release date is implied.
|
||||
|
||||
## AI, local models, and research
|
||||
|
||||
These are **areas of product interest**, not commitments to a specific implementation or delivery order. Follow normal issue triage and product-decision requirements.
|
||||
|
||||
| Direction | Opportunity |
|
||||
| --- | --- |
|
||||
| **Smaller-model agents** | Reduce unnecessary prompt, tool-schema, skill, memory, and document context overhead; improve tool selection for constrained context windows |
|
||||
| **Cookbook and SGLang** | Improve installation and serving across relevant hardware, operating systems, and deployment methods |
|
||||
| **Model discovery** | Rank models by architecture, quantization, RAM/VRAM fit, backend support, multimodal requirements, and expected reliability |
|
||||
| **Deep Research presets** | Explore suitable model and parameter configurations for small, medium, and large local systems |
|
||||
| **Actionable diagnostics** | Expose command output, logs, and next steps for failed downloads, dependencies, preflights, and serving jobs |
|
||||
| **Speculative decoding** | Investigate compatible draft-model configurations and evaluate end-to-end correctness and time to success |
|
||||
| **Provider setup** | Improve endpoint discovery, capability checks, and setup for supported local and hosted providers |
|
||||
|
||||
## Frontend and user experience
|
||||
|
||||
- **Editors and files:** Improve document/file editing, window behavior, save/export flows, and image-editing reliability.
|
||||
- **Notes and Todos:** Expand agent-assisted reading, updates, summarization, and turning information into tasks.
|
||||
- **Mobile:** Improve gallery/editor usability, model setup, and responsive behavior.
|
||||
- **Accessibility:** Strengthen keyboard navigation, focus states, contrast, and reduced-motion support.
|
||||
- **First run:** Improve setup, hints, empty states, error messages, and onboarding tours without duplicate guidance.
|
||||
- **Self-hosting:** Reduce unnecessary external asset dependencies where practical for offline operation.
|
||||
|
||||
## Backend and operations
|
||||
|
||||
- **Email performance:** Measure fetching, searching, body and attachment loading, caching, sending, and frontend refresh behavior before optimizing.
|
||||
- **Provider reliability:** Expand regression coverage for setup, endpoint probing, and degraded-service behavior.
|
||||
- **Task scheduling:** Improve defaults, reliability, and operational visibility.
|
||||
- **Data recovery:** Improve backup and restore guidance and workflows for persistent data.
|
||||
- **Administrative security:** Review privileged capabilities and sensitive integrations against their authorization boundaries.
|
||||
|
||||
## Not a current priority
|
||||
|
||||
- Additional themes without a demonstrated product need.
|
||||
- Broad rewrites or framework migrations without clear, measurable value.
|
||||
- Changes undertaken solely to reduce file counts.
|
||||
- Uncoordinated changes to temporarily frozen agent-runtime contracts.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Read [CONTRIBUTING.md](CONTRIBUTING.md) and search [existing issues](https://github.com/odysseus-dev/odysseus/issues) and [PRs](https://github.com/odysseus-dev/odysseus/pulls).
|
||||
2. Coordinate modularization through [#4071](https://github.com/odysseus-dev/odysseus/issues/4071) and active stabilization work through the [milestone](https://github.com/odysseus-dev/odysseus/milestone/1).
|
||||
3. Use the repository's issue and PR templates and target **`dev`**.
|
||||
4. Include relevant tests, runtime evidence, and screenshots or recordings for visual changes.
|
||||
|
||||
> [!NOTE]
|
||||
> Product-direction or difficult-to-reverse changes follow the [feature proposal process](docs/maintainers/product-and-features.md). Report security vulnerabilities privately through [SECURITY.md](SECURITY.md), never in a public issue.
|
||||
I prob shouldnt add more themes.
|
||||
|
||||
+2
-3
@@ -64,13 +64,12 @@ External content that reaches the LLM is treated as untrusted via `src/prompt_se
|
||||
|
||||
`src/tool_capabilities.py` carries a second layer: once untrusted content has entered a run, `ToolRunSecurityContext.decision_for()` blocks tools that execute code, mutate state, or cause external side effects until the user authorises the action separately.
|
||||
|
||||
It is on unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set to a falsy value (`0`/`false`/`no`/`off`). The admin-only `tool_approval_mode` setting (Settings → Agent Tools, `off` = "Off (YOLO)") is the same knob exposed in the UI: with `off`, every run sets `approval_gate_bypassed` and this gate stops asking, exactly as if the env var were off. Request authority narrows which tool families a turn may use, but within an admitted family it does not bind the exact action: a request to read email admits `send_email` and `delete_email`, and agent processes inherit the host network. Until those are covered by their own boundaries, this gate is what stops an injected instruction from reaching them. Turning it off — by either knob — lets a successful injection reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation.
|
||||
It is on unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set to a falsy value (`0`/`false`/`no`/`off`). Request authority narrows which tool families a turn may use, but within an admitted family it does not bind the exact action: a request to read email admits `send_email` and `delete_email`, and agent processes inherit the host network. Until those are covered by their own boundaries, this gate is what stops an injected instruction from reaching them. Turning it off lets a successful injection reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation.
|
||||
|
||||
Three exemptions apply even when the gate is on, all deliberate:
|
||||
Two exemptions apply even when the gate is on, both deliberate:
|
||||
|
||||
- Sources in `_CONTROL_PLANE_CONTEXT_SOURCES` (skills, runtime descriptors, the open editor document, the open email, uploaded files) are treated as control-plane metadata and still permit read-only tools.
|
||||
- A TUI run that advertises a host shell bridge and declares `unattended_mode` exempts the local execution set in `TUI_CLIENT_TOOL_NAMES`. Personal, network and deployment-local tools are never exempted.
|
||||
- Narrow continuation for local MCP tools declared via `ODYSSEUS_LOCAL_MCP_READ_TOOLS` and `ODYSSEUS_LOCAL_MCP_WRITE_TOOLS`: Only explicitly configured stdio MCP servers qualify; remote HTTP and SSE servers fail closed. Declarations never override built-in tool effects, so built-in email, browser, memory, image generation, and other built-in servers cannot receive continuation exceptions. Declared read tools remain untrusted (`EXTERNAL_UNTRUSTED`) to ensure that prompt injections in local data cannot reach shell, network, or unrelated tools without approval. Stdio transport does not guarantee network isolation, as local processes may still reach the host network. However, declared non-destructive write tools on the exact same stdio MCP server are permitted to continue without an approval prompt if the only untrusted context in the run was produced by declared local reads on that same server, without any explicit error or external taint. Genuinely external content (web search, web fetch, emails, external message context) blocks these writes. Authorization must still originate from the trusted request: request authority must permit the tool and retain the sealed backend resource grant. The residual same-store write risk (the agent writing to the same local store after reading a local prompt injection) remains subject to the user's initial request authority.
|
||||
|
||||
## Security Headers
|
||||
|
||||
|
||||
+1
-39
@@ -47,7 +47,7 @@ ADMIN_PRIVILEGES["allowed_models_restricted"] = False
|
||||
# backwards for this sentinel.
|
||||
ADMIN_PRIVILEGES["block_all_models"] = False
|
||||
|
||||
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH, USERNAME_MAX_LENGTH
|
||||
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH
|
||||
from src.owner_identity import RESERVED_AUTH_USERNAMES
|
||||
DEFAULT_AUTH_PATH = AUTH_FILE
|
||||
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
||||
@@ -62,24 +62,6 @@ TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
||||
RESERVED_USERNAMES = frozenset(RESERVED_AUTH_USERNAMES)
|
||||
|
||||
|
||||
def username_error(username: str | None) -> Optional[str]:
|
||||
"""Why a new username is not acceptable, or None when it is.
|
||||
|
||||
Admin routes carry the name as a URL path segment, where "/" never
|
||||
reaches the handler, so such an account could not be managed afterwards.
|
||||
"""
|
||||
name = (username or "").strip()
|
||||
if not name:
|
||||
return "Username is required"
|
||||
if len(name) > USERNAME_MAX_LENGTH:
|
||||
return f"Username must be at most {USERNAME_MAX_LENGTH} characters"
|
||||
if "/" in name or "\\" in name or any(not c.isprintable() for c in name):
|
||||
return "Username must not contain slashes or control characters"
|
||||
if name in (".", ".."):
|
||||
return "Username is not allowed"
|
||||
return None
|
||||
|
||||
|
||||
def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]:
|
||||
"""Return a normalized username only when it exists in the auth user map."""
|
||||
key = str(username or "").strip().lower()
|
||||
@@ -414,15 +396,6 @@ class AuthManager:
|
||||
current = self.get_privileges(username)
|
||||
for k, v in privileges.items():
|
||||
if k in DEFAULT_PRIVILEGES:
|
||||
default = DEFAULT_PRIVILEGES[k]
|
||||
if isinstance(default, bool):
|
||||
ok = isinstance(v, bool)
|
||||
elif isinstance(default, int):
|
||||
ok = isinstance(v, int) and not isinstance(v, bool)
|
||||
else:
|
||||
ok = isinstance(v, list)
|
||||
if not ok:
|
||||
raise ValueError(f"Invalid value for privilege '{k}'")
|
||||
current[k] = v
|
||||
self._config["users"][username]["privileges"] = current
|
||||
self._save()
|
||||
@@ -463,17 +436,6 @@ class AuthManager:
|
||||
admin_count = sum(1 for d in self.users.values() if d.get("is_admin"))
|
||||
if admin_count <= 1:
|
||||
return SetAdminResult.LAST_ADMIN
|
||||
# Token creation is admin-only and a demoted user can neither
|
||||
# list nor revoke their tokens, so purge them with the role.
|
||||
# Done before the flag flips: if the token store fails the
|
||||
# exception propagates and the user is still an admin.
|
||||
from core.database import get_db_session, ApiToken
|
||||
with get_db_session() as db:
|
||||
removed_tokens = db.query(ApiToken).filter(ApiToken.owner == username).delete()
|
||||
if removed_tokens:
|
||||
logger.info(
|
||||
f"Revoked {removed_tokens} API token(s) owned by demoted admin '{username}'"
|
||||
)
|
||||
# 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 —
|
||||
|
||||
@@ -568,7 +568,6 @@ class ModelEndpoint(TimestampMixin, Base):
|
||||
hidden_models = Column(Text, nullable=True) # JSON list of model IDs that failed probing
|
||||
cached_models = Column(Text, nullable=True) # JSON list of last-known model IDs (avoids probe on list)
|
||||
pinned_models = Column(Text, nullable=True) # JSON list of admin-pinned model IDs (manual, may not appear in /v1/models)
|
||||
cookbook_serve_session_id = Column(String, nullable=True) # Current Cookbook serve identity
|
||||
model_type = Column(String, nullable=True, default="llm") # "llm" or "image"
|
||||
# auto = classify by URL; local = self-hosted server; api/proxy = external
|
||||
# OpenAI-compatible API even when reachable through a private/tailnet IP.
|
||||
@@ -1379,26 +1378,6 @@ def _migrate_add_pinned_models_column():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _migrate_add_cookbook_serve_session_id_column():
|
||||
"""Bind Cookbook cleanup to the serve session that owns the endpoint."""
|
||||
from sqlalchemy import inspect
|
||||
|
||||
with engine.begin() as conn:
|
||||
columns = {
|
||||
column["name"]
|
||||
for column in inspect(conn).get_columns("model_endpoints")
|
||||
}
|
||||
|
||||
if "cookbook_serve_session_id" not in columns:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE model_endpoints "
|
||||
"ADD COLUMN cookbook_serve_session_id VARCHAR"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migrate_add_notes_sort_order():
|
||||
"""Add sort_order, image_url, repeat columns to notes if they don't exist."""
|
||||
import sqlite3
|
||||
@@ -2411,7 +2390,6 @@ def init_db():
|
||||
_migrate_add_hidden_models_column()
|
||||
_migrate_add_cached_models_column()
|
||||
_migrate_add_pinned_models_column()
|
||||
_migrate_add_cookbook_serve_session_id_column()
|
||||
_migrate_add_notes_sort_order()
|
||||
_migrate_add_model_type_column()
|
||||
_migrate_add_model_endpoint_refresh_columns()
|
||||
|
||||
@@ -73,8 +73,6 @@ services:
|
||||
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
|
||||
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
|
||||
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
||||
- ODYSSEUS_LOCAL_MCP_READ_TOOLS=${ODYSSEUS_LOCAL_MCP_READ_TOOLS:-}
|
||||
- ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=${ODYSSEUS_LOCAL_MCP_WRITE_TOOLS:-}
|
||||
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
|
||||
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
|
||||
@@ -84,15 +82,13 @@ services:
|
||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
||||
# Host workspace translation is opt-in. Keep the public compose file
|
||||
# user-neutral; configure these in a local .env or use the host-workspace
|
||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
||||
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
|
||||
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
|
||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||
@@ -169,7 +165,7 @@ services:
|
||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||
exec /usr/local/searxng/entrypoint.sh
|
||||
ports:
|
||||
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||
- "127.0.0.1:8080:8080"
|
||||
volumes:
|
||||
- searxng-data:/etc/searxng
|
||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||
|
||||
@@ -72,8 +72,6 @@ services:
|
||||
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
|
||||
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
|
||||
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
||||
- ODYSSEUS_LOCAL_MCP_READ_TOOLS=${ODYSSEUS_LOCAL_MCP_READ_TOOLS:-}
|
||||
- ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=${ODYSSEUS_LOCAL_MCP_WRITE_TOOLS:-}
|
||||
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
|
||||
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
|
||||
@@ -83,15 +81,13 @@ services:
|
||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
||||
# Host workspace translation is opt-in. Keep the public compose file
|
||||
# user-neutral; configure these in a local .env or use the host-workspace
|
||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
||||
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
|
||||
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
|
||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||
@@ -172,7 +168,7 @@ services:
|
||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||
exec /usr/local/searxng/entrypoint.sh
|
||||
ports:
|
||||
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||
- "127.0.0.1:8080:8080"
|
||||
volumes:
|
||||
- searxng-data:/etc/searxng
|
||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||
|
||||
+2
-6
@@ -61,8 +61,6 @@ services:
|
||||
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
|
||||
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
|
||||
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
||||
- ODYSSEUS_LOCAL_MCP_READ_TOOLS=${ODYSSEUS_LOCAL_MCP_READ_TOOLS:-}
|
||||
- ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=${ODYSSEUS_LOCAL_MCP_WRITE_TOOLS:-}
|
||||
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
|
||||
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
|
||||
@@ -72,15 +70,13 @@ services:
|
||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
||||
# Host workspace translation is opt-in. Keep the public compose file
|
||||
# user-neutral; configure these in a local .env or use the host-workspace
|
||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
||||
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
|
||||
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
|
||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||
@@ -150,7 +146,7 @@ services:
|
||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||
exec /usr/local/searxng/entrypoint.sh
|
||||
ports:
|
||||
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||
- "127.0.0.1:8080:8080"
|
||||
volumes:
|
||||
- searxng-data:/etc/searxng
|
||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||
|
||||
@@ -31,43 +31,21 @@ for spec in $SPECS; do
|
||||
name="${spec%%==*}"
|
||||
ver="${spec##*==}"
|
||||
# pip download builds metadata (and trips the same bug), so fetch the raw
|
||||
# sdist URL from the PyPI JSON API instead. Use curl with retries to
|
||||
# survive transient TLS / network failures that urllib cannot recover from.
|
||||
metadata="${name}-${ver}.json"
|
||||
curl --fail --silent --show-error --location \
|
||||
--retry 8 \
|
||||
--retry-delay 3 \
|
||||
--retry-max-time 120 \
|
||||
--retry-all-errors \
|
||||
"https://pypi.org/pypi/${name}/${ver}/json" \
|
||||
-o "$metadata"
|
||||
|
||||
url="$(python - "$metadata" <<'PY'
|
||||
import json, sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
for item in data["urls"]:
|
||||
if item["packagetype"] == "sdist":
|
||||
print(item["url"])
|
||||
break
|
||||
# sdist URL from the PyPI JSON API instead.
|
||||
url="$(python - "$name" "$ver" <<'PY'
|
||||
import json, sys, urllib.request
|
||||
name, ver = sys.argv[1], sys.argv[2]
|
||||
data = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{ver}/json"))
|
||||
for f in data["urls"]:
|
||||
if f["packagetype"] == "sdist":
|
||||
print(f["url"]); break
|
||||
else:
|
||||
raise SystemExit("no sdist found")
|
||||
sys.exit(f"no sdist found for {name}=={ver}")
|
||||
PY
|
||||
)"
|
||||
rm -f "$metadata"
|
||||
|
||||
)"
|
||||
echo ">> fetching ${name} ${ver}: ${url}"
|
||||
curl --fail --silent --show-error --location \
|
||||
--retry 8 \
|
||||
--retry-delay 3 \
|
||||
--retry-max-time 120 \
|
||||
--retry-all-errors \
|
||||
"$url" \
|
||||
-o "${name}.tar.gz"
|
||||
curl -fsSL "$url" -o "${name}.tar.gz"
|
||||
tar xzf "${name}.tar.gz"
|
||||
rm -f "${name}.tar.gz"
|
||||
done
|
||||
|
||||
echo ">> patching get_version()"
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 91 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 97 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 146 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 105 KiB |
@@ -9,7 +9,6 @@ This handbook records the project's maintainer governance and gives reviewers a
|
||||
| Area | Working rule |
|
||||
|---|---|
|
||||
| Product direction | Pewds is the Product Owner |
|
||||
| New feature | Feature Request issue; big, new-direction or hard-to-undo features need a recorded Product Owner decision ([workflow](product-and-features.md#feature-proposal-workflow)) |
|
||||
| Ordinary, reversible maintainer PR | Self-merge allowed after appropriate validation and applicable repository checks |
|
||||
| External contribution | Maintainer review required |
|
||||
| High-risk change | At least one **independent, qualified** maintainer review |
|
||||
@@ -23,7 +22,7 @@ These are governance rules, not guarantees that a GitHub permission or branch-pr
|
||||
## Start here
|
||||
|
||||
- [Roles and access](roles-and-access.md): responsibility, permissions, and continuity.
|
||||
- [Product and feature proposals](product-and-features.md): product approval versus technical ownership, and the feature proposal workflow.
|
||||
- [Product and feature proposals](product-and-features.md): product approval versus technical ownership.
|
||||
- [Changes and review](changes-and-review.md): review thresholds, self-merge, contributor corrections.
|
||||
- [Practical review playbook](review-playbook.md): risk, provenance, CI, CodeQL, independent audit, and handoff evidence.
|
||||
- [Triage](triage.md): turning incoming reports into actionable work.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Native test merge gate
|
||||
|
||||
`Native test suites` runs on pull requests, dev/main pushes and manual dispatch.
|
||||
`Authoritative native suites` fails unless both the native Node and complete
|
||||
Chromium jobs succeed; it has no continue-on-error, retries or path bypass.
|
||||
Repository administrators must require this check alongside the four pytest
|
||||
checks in branch protection. The audited public dev branch had no protection or
|
||||
ruleset, so merely adding workflow jobs cannot prevent a manual merge.
|
||||
|
||||
`tests/native-ci-manifest.json` exhaustively partitions all native Node files:
|
||||
16 run in the native job,14 through existing pytest wrappers, one through the
|
||||
existing frontend architecture CI step, and the formerly live email-reply file
|
||||
through an explicitly identified Playwright driver. The manifest validator fails
|
||||
for omitted/duplicate/unreachable entries. Node emits individual JUnit identities
|
||||
and propagates its failure exit. No native file is newly run in each pytest shard.
|
||||
|
||||
The browser job uses the existing one-worker Chromium config and all 136 Photo
|
||||
Editor cases (including cursor and brush regressions), plus one driver for the native
|
||||
email-reply contract (137 cases total). Its ephemeral account,
|
||||
SQLite DB and server are created by the config/setup; reuseExistingServer remains
|
||||
false. NPX startup is offline/cache-only in a private cache; Notes/editor behavior
|
||||
and Python MCP services remain real. No developer app or external account is used.
|
||||
|
||||
Default CI trace suppression remains intact. Only this job's explicit opt-in plus
|
||||
a newly generated authenticated synthetic fixture marker permits failure traces.
|
||||
The existing structured sanitizer runs before artifacts are uploaded; invalid
|
||||
ownership, failed sanitization and missing tooling purge sensitive artifacts.
|
||||
Unsupported or malformed sensitive records cause the affected archive to be removed
|
||||
fail-closed. An independent fail-closed verification step (.github/scripts/verify_sanitized_artifacts.py)
|
||||
runs after test suite execution or abnormal runner termination to prevent publishing
|
||||
unsanitized diagnostics while preserving safe JUnit reporting. The verifier returns a nonzero
|
||||
exit code upon any inspection or deletion failure, publishable XML is restricted to recognized
|
||||
JUnit reports with sensitive tokens redacted, and workflow upload steps execute only when
|
||||
independent verification succeeds (steps.<id>.outcome == 'success').
|
||||
Never upload the fixture directory, database or browser storage state.
|
||||
|
||||
The stroke budget now excludes measured synthetic event-delivery pacing and also
|
||||
bounds actual Chromium main-thread work at the original5s. All240 samples and
|
||||
pixel/thumbnail/undo/redo assertions remain. Per-case timing attachments expose
|
||||
both clocks rather than treating driver frame cadence as application work.
|
||||
|
||||
Three Bombadil properties remain opt-in: they require a Bombadil/browser scenario
|
||||
and bounded fuzz campaign; no authoritative execution is claimed. The two model
|
||||
smoke scripts require supplied model endpoints/models and remain explicit live
|
||||
operations. No credentials or live-service authorization are implied by CI setup.
|
||||
Firefox/WebKit alternatives remain opt-in and are not certified by Chromium CI.
|
||||
@@ -2,15 +2,13 @@
|
||||
|
||||
Pewds ([@pewdiepie-archdaemon](https://github.com/pewdiepie-archdaemon)) is the Product Owner for Odysseus and has final authority over product direction. Maintainers own technical feasibility, implementation, and safety.
|
||||
|
||||
This page has two parts: **which changes need product approval**, and **the workflow every feature proposal follows**, from issue to merged PR. The terms in bold are defined in [Definitions](#definitions); when a case is unclear, the definitions decide, not intuition.
|
||||
|
||||
## Which changes require product approval?
|
||||
|
||||
**Product Owner approval before implementation:** features that are **big**, set a **new direction**, or are **hard to undo**.
|
||||
**Product Owner approval before implementation:** substantial new features, changes to product direction, and product changes that are hard to reverse.
|
||||
|
||||
**No separate product approval by default:** bug fixes, security fixes, maintenance, tests, CI, documentation, internal refactors, and features that are **small**, **reversible**, and **fit ROADMAP**.
|
||||
**No separate product approval by default:** bug fixes, security fixes, maintenance, tests, CI, documentation, internal refactors, and small, reversible product changes within existing direction.
|
||||
|
||||
Maintainers may make those smaller product decisions. Pewds can ask for changes or a rollback; the maintainer who decided owns the rollback or recovery.
|
||||
Maintainers may make those smaller product decisions. Pewds can ask for changes or a rollback; the maintainer owns safe rollback or recovery.
|
||||
|
||||
### Urgent security and reliability fixes
|
||||
|
||||
@@ -27,171 +25,19 @@ Product Owner proposal or implementation
|
||||
|
||||
A Product Owner proposal is already product-approved; it is not automatically technically approved or exempt from high-risk review.
|
||||
|
||||
## Feature proposal workflow
|
||||
## Community or maintainer initiated
|
||||
|
||||
Every community or maintainer feature proposal follows these steps. The issue is the record at every step: labels show where a proposal stands, and comments hold the decisions.
|
||||
Community proposals start as a Feature Request issue. Check [ROADMAP.md](../../ROADMAP.md) and existing issues first; take early ideas to [Discussions](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas).
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["0. Idea (optional)<br/>Discussions › Ideas"] -.-> B["1. Feature Request issue"]
|
||||
B --> C{"2. Triage<br/>within 7 days"}
|
||||
C -- "duplicate / not actionable" --> X["Closed with a reason"]
|
||||
C -- "small + reversible + fits ROADMAP" --> M["product: maintainer decision"]
|
||||
C -- "big, new direction or hard to undo" --> N["product: needs decision"]
|
||||
N --> D["3. Decision brief"]
|
||||
D --> E{"4. Product Owner decides"}
|
||||
E -- yes --> P["product: approved"]
|
||||
E -- no --> R["product: declined<br/>closed as not planned"]
|
||||
E -- "no answer in 14 days" --> L{"Only reason was size?"}
|
||||
L -- yes --> M
|
||||
L -- "no: new direction or hard to undo" --> N
|
||||
M --> F["5. Implementation PRs<br/>Part of #N"]
|
||||
P --> F
|
||||
F --> G["6. Review and merge"]
|
||||
G --> H["7. Close the issue"]
|
||||
```text
|
||||
Feature proposal
|
||||
-> Product Owner accepts / rejects, where approval is required
|
||||
-> technical feasibility
|
||||
-> plan and implementation
|
||||
-> risk-based review and validation
|
||||
```
|
||||
|
||||
### Step 0 — Idea (optional)
|
||||
|
||||
Half-formed ideas go to [Discussions › Ideas](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas) to gauge interest. Nothing is decided there. A proposal is ready for step 1 when it is **actionable**.
|
||||
|
||||
### Step 1 — Feature Request issue
|
||||
|
||||
**Who:** the proposer.
|
||||
|
||||
Open a [Feature Request](https://github.com/odysseus-dev/odysseus/issues/new?template=feature_request.yml) after checking [ROADMAP.md](../../ROADMAP.md) and open issues. Besides the required fields, the form asks three optional questions that make triage faster:
|
||||
|
||||
- whether the proposer thinks it is big, a new direction, or hard to undo;
|
||||
- the yes/no questions they want the Product Owner to answer;
|
||||
- screenshots, a recording, or a demo branch.
|
||||
|
||||
A proposal with several independent parts may be one issue, provided each part is listed separately so it can be decided separately.
|
||||
|
||||
### Step 2 — Triage
|
||||
|
||||
**Who:** a **triager**. **When:** within 7 days of the issue being opened.
|
||||
|
||||
The triager does one of three things and records why in a comment:
|
||||
|
||||
| Outcome | When | Label | Next |
|
||||
|---|---|---|---|
|
||||
| Close | **Duplicate** or not **actionable** | — (closed as duplicate or not planned, with a link) | — |
|
||||
| Maintainer decision | **Small** *and* **reversible** *and* **fits ROADMAP** | `product: maintainer decision` | Step 5 |
|
||||
| Needs the Product Owner | **Big**, *or* **new direction**, *or* **hard to undo** | `product: needs decision` | Step 3 |
|
||||
|
||||
The triage comment names the ROADMAP line the proposal fits, or says that it fits none, and which of big / new direction / hard to undo applies. When the triager is unsure, the proposal goes to the Product Owner.
|
||||
|
||||
### Step 3 — Decision brief
|
||||
|
||||
**Who:** the proposer writes it; the triager checks it is complete before the clock starts.
|
||||
|
||||
The brief goes at the top of the issue body (edit the issue, or post it as the first comment and link it from the body) and contains:
|
||||
|
||||
1. **Summary:** what changes for users, in three to five lines.
|
||||
2. **Questions:** the exact yes/no questions, one per independent part.
|
||||
3. **Evidence:** screenshots or a recording for anything that renders; a demo branch if one exists.
|
||||
4. **What cannot be undone:** migrations, deleted data, changed contracts, or "nothing".
|
||||
5. **For big designs only:** architecture, migration and rollback, and the planned PR slices. A long design may live in a proposal document under `/design` and be linked from the brief.
|
||||
|
||||
When the brief is complete, the triager mentions the Product Owner on the issue. This is **day 0** for the timeout.
|
||||
|
||||
### Step 4 — Product Owner decision
|
||||
|
||||
**Who:** the Product Owner. **When:** on the issue at any time, or at the next **queue review**.
|
||||
|
||||
| Answer | Label | Recorded as |
|
||||
|---|---|---|
|
||||
| Yes, possibly with conditions | `product: approved` | A **recorded decision** listing any conditions |
|
||||
| No | `product: declined` | A recorded decision; the issue is closed as not planned |
|
||||
| A question | stays `product: needs decision` | The clock pauses until the proposer answers, then restarts at day 0 |
|
||||
| Different answers per part | the label for the approved part(s) | The recorded decision lists each part and its answer |
|
||||
|
||||
A decision given outside GitHub (Matrix, a call) counts once a maintainer has recorded it on the issue. A declined feature can be maintained in a contributor's fork, and the issue can be reopened only with **materially new** information.
|
||||
|
||||
#### When there is no answer: lazy consensus
|
||||
|
||||
| Day | What happens |
|
||||
|---|---|
|
||||
| 0 | Brief complete; Product Owner mentioned on the issue |
|
||||
| 7 | One reminder from a maintainer, and the issue is put on the next queue review |
|
||||
| 14 | If there has been no **objection**: |
|
||||
|
||||
- If the **only** reason the proposal needed the Product Owner was that it is **big** — it is **reversible** and **fits ROADMAP** — a maintainer may decide. They record "decided by lazy consensus on YYYY-MM-DD", apply `product: maintainer decision`, and own any rollback Pewds later asks for.
|
||||
- If it is a **new direction** or **hard to undo**, it keeps waiting. Silence never approves a one-way change or a change of direction. It is raised at each queue review.
|
||||
- A proposal still waiting after **90 days** is closed as not planned with a comment saying it expired without a decision. It can be reopened by anyone with the same brief.
|
||||
|
||||
### Step 5 — Implementation
|
||||
|
||||
**Who:** the proposer or any contributor.
|
||||
|
||||
Feature PRs link the issue with `Part of #N` (or `Fixes #N` for the last slice) and stay within what was decided. A maintainer does not merge a feature PR whose issue carries neither `product: approved` nor `product: maintainer decision`, and checks any conditions in the recorded decision.
|
||||
|
||||
If the work grows beyond what was decided — a new part, or a part that becomes hard to undo — that part goes back to step 2.
|
||||
|
||||
A maintainer's own small, reversible feature that fits ROADMAP may skip the separate issue: the PR description states why it qualifies, and the PR is the record.
|
||||
|
||||
### Step 6 — Review and merge
|
||||
|
||||
Unchanged: [changes and review](changes-and-review.md). Product approval is not technical approval; high-risk changes still need an independent qualified review.
|
||||
|
||||
### Step 7 — Close
|
||||
|
||||
The issue is closed when the last PR lands. If the feature was a ROADMAP item, the same PR or a follow-up updates ROADMAP.md.
|
||||
|
||||
## Labels
|
||||
|
||||
| Label | Meaning | Applied by |
|
||||
|---|---|---|
|
||||
| `product: needs decision` | Waiting for the Product Owner | Triager |
|
||||
| `product: approved` | The Product Owner said yes (see the recorded decision for scope and conditions) | Product Owner or the maintainer recording the decision |
|
||||
| `product: maintainer decision` | Small, reversible and on ROADMAP, or decided by lazy consensus | Triager, or the maintainer applying lazy consensus |
|
||||
| `product: declined` | The Product Owner said no | Product Owner or the maintainer recording the decision |
|
||||
|
||||
Only maintainers and the Product Owner set or change these labels. Exactly one of them is on a triaged feature issue at any time.
|
||||
|
||||
## Definitions
|
||||
|
||||
**Feature.** A change that adds or alters user-visible behavior, a setting, or a public contract (HTTP API, MCP tool, plugin interface, file format). Restoring documented or clearly intended behavior is a bug fix, not a feature.
|
||||
|
||||
**Small.** All of the following are true:
|
||||
|
||||
- it ships as one PR of ordinary size — as a guide, no more than about 600 changed lines excluding tests, generated files and lockfiles;
|
||||
- it touches one area of the app;
|
||||
- it adds an option or a capability without changing what existing users see or get by default;
|
||||
- it adds no new dependency, service, or background process.
|
||||
|
||||
Anything else is **big**.
|
||||
|
||||
**Reversible.** Reverting the PR(s) fully restores the previous state for every user, with nothing left behind: no data migrated, rewritten or deleted; no stored format that other code or users now depend on; no public contract that integrations have started to use; no security boundary moved. If any of those is not true, the change is **hard to undo**.
|
||||
|
||||
**Hard to undo.** Not reversible. Typical cases: data migrations, deleting or purging data, new persisted formats, new or changed authentication, permission or sharing models, and removing or renaming a public API, MCP tool, setting or file location.
|
||||
|
||||
**New direction.** The change takes Odysseus somewhere it has not committed to. Any of:
|
||||
|
||||
- a new top-level area of the app (a new panel, tab or module);
|
||||
- multi-user or collaboration features where the app was single-user;
|
||||
- data or requests leaving the user's machine by default, including telemetry and new external services;
|
||||
- dropping or adding a supported platform, install method or model backend;
|
||||
- anything listed under "Not The Focus Right Now" in ROADMAP.md;
|
||||
- reversing a previous Product Owner decision.
|
||||
|
||||
**Fits ROADMAP.** The triager can point to a specific ROADMAP.md item that the proposal implements or directly extends, and the proposal is not listed under "Not The Focus Right Now". "It is in the same area as an item" is not enough.
|
||||
|
||||
**Actionable.** The issue states a concrete problem and a concrete change in behavior, specific enough to write acceptance criteria from.
|
||||
|
||||
**Duplicate.** Same user-facing outcome as an open issue or open PR, or a request for a ROADMAP item that adds nothing to it. Close it with a link, and move any new information to the original. A proposal that offers to *implement* a ROADMAP item, with a concrete design, is not a duplicate: it is triaged normally and usually **fits ROADMAP**.
|
||||
|
||||
**Triager.** Any maintainer other than the proposer. A maintainer's own proposal is triaged by another maintainer.
|
||||
|
||||
**Objection.** A comment on the issue — or one recorded there from another channel — by the Product Owner or a maintainer, asking to stop, wait, or change the scope. A maintainer's objection stops lazy consensus for that proposal; it then waits for the Product Owner like a new direction does. Questions and suggestions that do not ask to stop are not objections.
|
||||
|
||||
**Recorded decision.** A comment on the issue that states the decision, who made it, the date, where it was made if not on GitHub, the parts it covers, and any conditions — together with the matching label change.
|
||||
|
||||
**Queue review.** Every 14 days, a maintainer brings the [`product: needs decision` list](https://github.com/odysseus-dev/odysseus/issues?q=is%3Aissue+is%3Aopen+label%3A%22product%3A+needs+decision%22) to the Product Owner and records each answer on its issue. The maintainer who runs it is agreed among maintainers and noted here: *to be decided*.
|
||||
|
||||
**Days.** Calendar days, counted from the timestamp of the event on the issue (label applied, brief completed, reminder posted).
|
||||
|
||||
**Materially new.** Information the original decision did not have: a different design, new evidence of demand, or a changed ROADMAP.
|
||||
Record the decision in an issue, proposal, work item, or PR so future maintainers can discover it. Do not rely on a chat-only decision.
|
||||
|
||||
## Decision boundaries
|
||||
|
||||
@@ -199,4 +45,4 @@ The Product Owner decides **what belongs in Odysseus**. Maintainers decide **how
|
||||
|
||||
A rejected or out-of-scope feature can be maintained independently in a contributor's fork.
|
||||
|
||||
If the Product Owner is unavailable, work that needs product approval follows the timeout above. It never proceeds on silence when it is a new direction or hard to undo. Smaller decisions and urgent fixes follow the rules above.
|
||||
If the Product Owner is unavailable, work requiring prior product approval waits unless authority was explicitly delegated. Smaller decisions and urgent fixes follow the rules above.
|
||||
|
||||
@@ -26,8 +26,6 @@ Record what is known and distinguish it from what remains unverified:
|
||||
- Priority, ownership, and relevant domain expertise.
|
||||
- Remaining unknowns, risks, and safe recovery or workaround when available.
|
||||
|
||||
Feature requests follow the [feature proposal workflow](product-and-features.md#feature-proposal-workflow): triage within 7 days, and a product label on every triaged feature issue.
|
||||
|
||||
Small and obvious fixes may go straight to a focused PR, consistent with [CONTRIBUTING.md](../../CONTRIBUTING.md) and [changes and review](changes-and-review.md).
|
||||
|
||||
## Practical priority language
|
||||
|
||||
+15
-12
@@ -33,7 +33,6 @@ from mcp.types import Tool, TextContent
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.email_mime import decode_text_part as _decode_text_part
|
||||
server = Server("email")
|
||||
EMAIL_SOCKET_TIMEOUT = float(os.environ.get("EMAIL_SOCKET_TIMEOUT", "20"))
|
||||
from src.constants import DATA_DIR as _DATA_DIR, APP_DB, EMAIL_CACHE_DB, SETTINGS_FILE as _SETTINGS_FILE, MAIL_ATTACHMENTS_DIR
|
||||
@@ -924,25 +923,29 @@ def _extract_text(msg):
|
||||
"""Extract plain text body from email message."""
|
||||
if msg.is_multipart():
|
||||
text_parts = []
|
||||
html_parts = []
|
||||
for part in msg.walk():
|
||||
ct = part.get_content_type()
|
||||
cd = str(part.get("Content-Disposition", ""))
|
||||
if ct == "text/plain" and "attachment" not in cd:
|
||||
text = _decode_text_part(part)
|
||||
if text:
|
||||
text_parts.append(text)
|
||||
elif ct == "text/html" and not html_parts and "attachment" not in cd:
|
||||
raw_html = _decode_text_part(part)
|
||||
if raw_html:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
text_parts.append(payload.decode(charset, errors="replace"))
|
||||
elif ct == "text/html" and not text_parts and "attachment" not in cd:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
raw_html = payload.decode(charset, errors="replace")
|
||||
text = re.sub(r"<br\s*/?>", "\n", raw_html, flags=re.I)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
text = html.unescape(text)
|
||||
html_parts.append(text.strip())
|
||||
return "\n".join(text_parts or html_parts)
|
||||
text_parts.append(text.strip())
|
||||
return "\n".join(text_parts)
|
||||
else:
|
||||
text = _decode_text_part(msg)
|
||||
if text:
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = msg.get_content_charset() or "utf-8"
|
||||
text = payload.decode(charset, errors="replace")
|
||||
if msg.get_content_type() == "text/html":
|
||||
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
|
||||
text = re.sub(r"</(?:p|div|li|tr|h[1-6])\s*>", "\n", text, flags=re.I)
|
||||
|
||||
@@ -2,4 +2,3 @@
|
||||
-r requirements.txt
|
||||
# psutil lets `-n auto` use physical cores instead of logical CPU threads.
|
||||
pytest-xdist[psutil]>=3.8,<4
|
||||
playwright==1.62.0
|
||||
|
||||
+10
-53
@@ -13,12 +13,11 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
from core.atomic_io import atomic_write_json, atomic_write_text
|
||||
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL, username_error
|
||||
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL
|
||||
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR
|
||||
from src.auth_helpers import _auth_disabled
|
||||
from src.rate_limiter import RateLimiter
|
||||
from src.settings_scrub import scrub_settings
|
||||
from src import tool_capabilities
|
||||
from src.settings import (
|
||||
load_settings as _load_settings,
|
||||
save_settings as _save_settings,
|
||||
@@ -155,9 +154,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
raise HTTPException(400, "Already configured")
|
||||
if len(body.password) < PASSWORD_MIN_LENGTH:
|
||||
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||
name_error = username_error(body.username)
|
||||
if name_error:
|
||||
raise HTTPException(400, name_error)
|
||||
if len(body.username.strip()) < 1:
|
||||
raise HTTPException(400, "Username is required")
|
||||
if body.username.lower() in RESERVED_USERNAMES:
|
||||
raise HTTPException(403, "Username is reserved")
|
||||
ok = await asyncio.to_thread(auth_manager.setup, body.username, body.password)
|
||||
@@ -176,9 +174,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
raise HTTPException(403, "Registration is disabled. Ask an admin for an account.")
|
||||
if len(body.password) < PASSWORD_MIN_LENGTH:
|
||||
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||
name_error = username_error(body.username)
|
||||
if name_error:
|
||||
raise HTTPException(400, name_error)
|
||||
if len(body.username.strip()) < 1:
|
||||
raise HTTPException(400, "Username is required")
|
||||
if body.username.lower() in RESERVED_USERNAMES:
|
||||
raise HTTPException(403, "Username is reserved")
|
||||
ok = await asyncio.to_thread(auth_manager.create_user, body.username, body.password, is_admin=False)
|
||||
@@ -336,9 +333,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
raise HTTPException(403, "Admin only")
|
||||
if len(body.password) < PASSWORD_MIN_LENGTH:
|
||||
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||
name_error = username_error(body.username)
|
||||
if name_error:
|
||||
raise HTTPException(400, name_error)
|
||||
if len(body.username.strip()) < 1:
|
||||
raise HTTPException(400, "Username is required")
|
||||
if body.username.lower() in RESERVED_USERNAMES:
|
||||
raise HTTPException(403, "Username is reserved")
|
||||
ok = auth_manager.create_user(body.username, body.password, body.is_admin)
|
||||
@@ -365,16 +361,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
user = _get_current_user(request)
|
||||
if not user or not auth_manager.is_admin(user):
|
||||
raise HTTPException(403, "Admin only")
|
||||
try:
|
||||
body = await request.json()
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
try:
|
||||
ok = auth_manager.set_privileges(username, body)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
body = await request.json()
|
||||
ok = auth_manager.set_privileges(username, body)
|
||||
if not ok:
|
||||
raise HTTPException(404, "User not found or is admin")
|
||||
return {"ok": True, "privileges": auth_manager.get_privileges(username)}
|
||||
@@ -388,9 +376,6 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
new_username = (body.username or "").strip().lower()
|
||||
if not new_username:
|
||||
raise HTTPException(400, "Username required")
|
||||
name_error = username_error(new_username)
|
||||
if name_error:
|
||||
raise HTTPException(400, name_error)
|
||||
if old_username == new_username:
|
||||
return {"ok": True, "username": new_username, "renamed_self": old_username == user}
|
||||
if old_username not in auth_manager.users:
|
||||
@@ -668,18 +653,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
user = _get_current_user(request)
|
||||
if not user or not auth_manager.is_admin(user):
|
||||
raise HTTPException(403, "Admin only")
|
||||
try:
|
||||
result = auth_manager.set_admin(username, body.is_admin, user)
|
||||
finally:
|
||||
# A demotion purges the user's ApiToken rows; dirty the bearer
|
||||
# cache so already-cached tokens stop authenticating.
|
||||
if not body.is_admin:
|
||||
invalidator = getattr(
|
||||
getattr(getattr(request, "app", None), "state", None),
|
||||
"invalidate_token_cache", None,
|
||||
)
|
||||
if callable(invalidator):
|
||||
invalidator()
|
||||
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:
|
||||
@@ -779,11 +753,6 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
user = _get_current_user(request)
|
||||
settings = without_retired_settings(_load_settings())
|
||||
if _auth_disabled() or (user and auth_manager.is_admin(user)):
|
||||
# Deployment-level truth for the Tool approval row: a falsy
|
||||
# ODYSSEUS_TOOL_APPROVAL_GATE disables the approval gate for every
|
||||
# run regardless of the stored tool_approval_mode, so surface it to
|
||||
# admins instead of letting the row mislead them the other way.
|
||||
settings["tool_approval_gate_env_disabled"] = not tool_capabilities.TOOL_APPROVAL_GATE_ENABLED
|
||||
return settings
|
||||
return scrub_settings(settings)
|
||||
|
||||
@@ -800,9 +769,6 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
"agent_max_tool_calls": (0, 1000), # 0 = unlimited
|
||||
"auto_compact_threshold_percent": (50, 95),
|
||||
}
|
||||
_VALID_VALUES = {
|
||||
"tool_approval_mode": {"ask", "off"},
|
||||
}
|
||||
for key in DEFAULT_SETTINGS:
|
||||
if key in RETIRED_SETTING_KEYS:
|
||||
continue
|
||||
@@ -816,15 +782,6 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, f"{key} must be an integer")
|
||||
val = max(lo, min(val, hi))
|
||||
|
||||
if key in _VALID_VALUES:
|
||||
allowed = _VALID_VALUES[key]
|
||||
if not isinstance(val, str) or val not in allowed:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"{key} must be one of {sorted(allowed)}",
|
||||
)
|
||||
|
||||
current[key] = val
|
||||
_save_settings(current)
|
||||
return without_retired_settings(current)
|
||||
|
||||
@@ -696,10 +696,11 @@ async def auto_name_session(session_manager, sess):
|
||||
logger.info(f"Auto-named session {sess.id} deterministically (no authorized model endpoint): {title}")
|
||||
return
|
||||
|
||||
# A configured effort is the user's explicit choice. Otherwise thinking
|
||||
# stays off, because reasoning would use up this small token budget and
|
||||
# leave no title.
|
||||
title_effort = effort_for_call(t_url, t_model, owner, sess)
|
||||
# max_tokens big enough that reasoning models (Minimax M2,
|
||||
# DeepSeek R1, QwQ, etc.) have headroom for <think>…</think>
|
||||
# plus the actual title — 200 used to clip them mid-reasoning
|
||||
# so strip_think left an empty string and no rename happened.
|
||||
# Timeout matches: 60s gives slow local reasoners room to finish.
|
||||
try:
|
||||
title = await llm_call_async(
|
||||
t_url,
|
||||
@@ -712,8 +713,7 @@ async def auto_name_session(session_manager, sess):
|
||||
max_tokens=64,
|
||||
headers=t_headers,
|
||||
timeout=15,
|
||||
reasoning_effort=title_effort,
|
||||
thinking_mode=None if title_effort else "off",
|
||||
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("[auto-name] non-spec title call failed for %s: %s", sess.id, e)
|
||||
@@ -1276,7 +1276,6 @@ async def build_chat_context(
|
||||
message=_ctx_msg,
|
||||
session=sess,
|
||||
use_web=use_web_val and not skip_web,
|
||||
use_rag=use_rag_val,
|
||||
use_memory=mem_enabled,
|
||||
time_filter=time_filter,
|
||||
preset_system_prompt=preset.system_prompt,
|
||||
@@ -1287,6 +1286,8 @@ async def build_chat_context(
|
||||
incognito=incognito,
|
||||
use_skills=skills_enabled,
|
||||
)
|
||||
if use_rag is not None or is_research_spinoff or casual_low_signal:
|
||||
_preface_kwargs["use_rag"] = use_rag_val
|
||||
preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs)
|
||||
|
||||
# Capture used memories immediately
|
||||
|
||||
+1
-17
@@ -1901,7 +1901,7 @@ def _ts_or_zero(value) -> float:
|
||||
|
||||
|
||||
def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool:
|
||||
"""Recover sess.model from the endpoint's visible cached and pinned models.
|
||||
"""Re-populate sess.model from the matching endpoint's cached models.
|
||||
|
||||
Covers the window between endpoint setup and the first chat send: the
|
||||
picker showed a model in the dropdown but the session record never got
|
||||
@@ -1965,9 +1965,6 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
|
||||
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
|
||||
except Exception:
|
||||
visible = cached
|
||||
if not is_chatgpt_subscription:
|
||||
from src.endpoint_resolver import _endpoint_enabled_models
|
||||
visible = _endpoint_enabled_models(ep)
|
||||
if current_model and current_model in {str(item).strip() for item in visible}:
|
||||
return False
|
||||
if is_chatgpt_subscription:
|
||||
@@ -3926,19 +3923,6 @@ def setup_chat_routes(
|
||||
if ctx.preprocessed.attachment_meta:
|
||||
yield f"data: {json.dumps({'type': 'attachments', 'data': ctx.preprocessed.attachment_meta})}\n\n"
|
||||
|
||||
# Give the page the saved user message's id. Only the reply's id
|
||||
# was sent back, so a message sent in the open page had none and
|
||||
# a later regenerate/edit of it could not cut the chat by id.
|
||||
try:
|
||||
_last_user_msg = next((m for m in reversed(getattr(sess, "history", None) or [])
|
||||
if getattr(m, "role", None) == "user"), None)
|
||||
_user_db_id = ((getattr(_last_user_msg, "metadata", None) or {}).get("_db_id")
|
||||
if _last_user_msg is not None else None)
|
||||
if _user_db_id:
|
||||
yield f"data: {json.dumps({'type': 'user_message_saved', 'id': _user_db_id})}\n\n"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Announce any docs auto-created during preprocess (e.g. fillable
|
||||
# PDF → editable markdown) so the editor pane switches to them
|
||||
# before the model starts streaming.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Cookbook route domain package (#4071)."""
|
||||
@@ -1,284 +0,0 @@
|
||||
"""Auto-register Cookbook-served models as model endpoints."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from routes.cookbook_helpers import ServeRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _auto_register_image_endpoint(
|
||||
req: ServeRequest,
|
||||
remote: str | None,
|
||||
serve_session_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""Register a diffusion model as an image endpoint so it appears in the model selector."""
|
||||
import re
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from routes.model_routes import _normalize_model_ids
|
||||
from src.settings import load_settings, save_settings
|
||||
|
||||
# Parse port from command (--port NNNN), default 8100 for diffusion_server
|
||||
port_match = re.search(r'--port\s+(\d+)', req.cmd)
|
||||
port = int(port_match.group(1)) if port_match else 8100
|
||||
|
||||
# Determine host
|
||||
if remote:
|
||||
# SSH alias — use as hostname (Tailscale resolves it later)
|
||||
host = remote.split("@")[-1] if "@" in remote else remote
|
||||
else:
|
||||
host = "localhost"
|
||||
|
||||
base_url = f"http://{host}:{port}/v1"
|
||||
|
||||
# Friendly display name from repo_id
|
||||
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
|
||||
display_name = f"{short_name} (image)"
|
||||
pinned_models = [req.repo_id] if req.repo_id else []
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Check for existing endpoint with same base_url — update it
|
||||
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
|
||||
if existing:
|
||||
existing.is_enabled = True
|
||||
existing.model_type = "image"
|
||||
# Follow the newly served model unless the user renamed the endpoint.
|
||||
pinned_names = {f"{m.split('/')[-1]} (image)" for m in _normalize_model_ids(existing.pinned_models)}
|
||||
if existing.name in pinned_names:
|
||||
existing.name = display_name
|
||||
existing.endpoint_kind = "local"
|
||||
existing.model_refresh_mode = "manual"
|
||||
existing.cookbook_serve_session_id = serve_session_id
|
||||
if pinned_models:
|
||||
existing.cached_models = json.dumps(pinned_models)
|
||||
existing.pinned_models = json.dumps(pinned_models)
|
||||
db.commit()
|
||||
settings = load_settings()
|
||||
if settings.get("image_gen_enabled") is not True:
|
||||
settings["image_gen_enabled"] = True
|
||||
save_settings(settings)
|
||||
logger.info(f"Updated existing image endpoint: {base_url}")
|
||||
return existing.id
|
||||
|
||||
ep_id = f"img-{uuid.uuid4().hex[:8]}"
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name=display_name,
|
||||
base_url=base_url,
|
||||
api_key=None,
|
||||
is_enabled=True,
|
||||
model_type="image",
|
||||
endpoint_kind="local",
|
||||
model_refresh_mode="manual",
|
||||
cached_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
pinned_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
cookbook_serve_session_id=serve_session_id,
|
||||
)
|
||||
db.add(ep)
|
||||
db.commit()
|
||||
settings = load_settings()
|
||||
settings["image_gen_enabled"] = True
|
||||
if not settings.get("image_model"):
|
||||
settings["image_model"] = req.repo_id
|
||||
save_settings(settings)
|
||||
logger.info(f"Auto-registered image endpoint: {display_name} @ {base_url}")
|
||||
return ep_id
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-register image endpoint: {e}")
|
||||
db.rollback()
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _auto_register_llm_endpoint(
|
||||
req: ServeRequest,
|
||||
remote: str | None,
|
||||
serve_session_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""Register a freshly-served LLM as a model endpoint so it appears in the
|
||||
model picker without a manual /setup step — the text-model sibling of
|
||||
_auto_register_image_endpoint.
|
||||
|
||||
Cookbook serve commands launch an OpenAI-compatible server (llama.cpp's
|
||||
llama-server, vLLM, SGLang, or Ollama) on a known port. We point an
|
||||
endpoint at that server's /v1; the picker auto-discovers the model id by
|
||||
probing /v1/models and dims the endpoint until the server is reachable,
|
||||
so registering immediately (before the server finishes loading) is safe.
|
||||
"""
|
||||
logger.info(
|
||||
f"_auto_register_llm_endpoint: ENTRY repo_id={req.repo_id!r} "
|
||||
f"remote={remote!r} cmd_prefix={req.cmd[:80]!r}"
|
||||
)
|
||||
import re
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from routes.model_routes import _normalize_model_ids
|
||||
|
||||
# Port: ordered fallbacks so we match whatever the user actually
|
||||
# asked for, not a hardcoded default:
|
||||
# 1. explicit `--port N` (vllm / sglang / llama-server)
|
||||
# 2. `OLLAMA_HOST=host:port` (the way Ollama specifies its bind)
|
||||
# 3. fallback by backend (11434 ollama / 8080 llama.cpp)
|
||||
# Previously the OLLAMA_HOST form was silently ignored and we
|
||||
# registered every Ollama endpoint at 11434 — even if the user
|
||||
# set OLLAMA_HOST=0.0.0.0:11435 to avoid colliding with an
|
||||
# existing systemd Ollama, the registered endpoint pointed at
|
||||
# the OLD port and showed as offline.
|
||||
port_match = re.search(r'--port\s+(\d+)', req.cmd)
|
||||
# Host length is capped at the longest DNS name; unbounded, repeated
|
||||
# assignments make this scan quadratic and stall the event loop.
|
||||
ollama_host_match = re.search(r'OLLAMA_HOST=[^\s]{0,253}?:(\d+)', req.cmd)
|
||||
if port_match:
|
||||
port = int(port_match.group(1))
|
||||
elif ollama_host_match:
|
||||
port = int(ollama_host_match.group(1))
|
||||
elif "ollama" in req.cmd:
|
||||
port = 11434
|
||||
else:
|
||||
port = 8080 # llama.cpp's llama-server default — the Apple Silicon path
|
||||
|
||||
# Determine host. The cookbook tmux for `local=true` serves runs INSIDE
|
||||
# the odysseus container — so the right URL for the in-container
|
||||
# backend to reach it is `localhost`, NOT `host.docker.internal`
|
||||
# (the latter points at the docker HOST, which doesn't have a server
|
||||
# on that port). The previous host.docker.internal fallback only made
|
||||
# sense for /setup-added external services like systemd Ollama on the
|
||||
# host — and those go through manual setup, not this auto-register
|
||||
# code path. For remote serves we still use the SSH host alias.
|
||||
if remote:
|
||||
host = remote.split("@")[-1] if "@" in remote else remote
|
||||
elif re.search(r"\bdocker\s+exec\s+(?:ollama-rocm|ollama-test)\b", req.cmd or ""):
|
||||
host = "host.docker.internal"
|
||||
else:
|
||||
host = "localhost"
|
||||
|
||||
base_url = f"http://{host}:{port}/v1"
|
||||
|
||||
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
|
||||
display_name = short_name or "Local model"
|
||||
is_mlx_deepseek_v4 = (
|
||||
"mlx_lm.server" in (req.cmd or "")
|
||||
and "deepseek-v4" in ((req.repo_id or "") + " " + (req.cmd or "")).lower()
|
||||
)
|
||||
mlx_shim_model_id = ""
|
||||
if is_mlx_deepseek_v4 and short_name:
|
||||
home_match = re.search(r"((?:/Users|/home)/[^/\s'\"]+)", req.cmd or "")
|
||||
remote_home = home_match.group(1) if home_match else ""
|
||||
if remote_home:
|
||||
mlx_shim_model_id = f"{remote_home}/.cache/odysseus/mlx-shims/{short_name}"
|
||||
|
||||
# If the serve command opts models into OpenAI tool-calling, record it so
|
||||
# agent_loop trusts emitted tool_calls instead of the name heuristic.
|
||||
is_ollama_endpoint = "ollama" in (req.cmd or "").lower()
|
||||
supports_tools = True if "--enable-auto-tool-choice" in req.cmd else None
|
||||
# Pin the model the user launched for every Cookbook-created LLM
|
||||
# endpoint, not just Ollama. Some OpenAI-compatible servers report a
|
||||
# deployment alias from /v1/models, and a stale server can answer on the
|
||||
# same port while the new launch failed. Keeping the requested model id
|
||||
# pinned makes the picker reflect the actual launch intent.
|
||||
launched_model = mlx_shim_model_id or req.repo_id
|
||||
pinned_models = [launched_model] if launched_model else []
|
||||
|
||||
# Launch registration must not delete other endpoints.
|
||||
# Matching names, model pins, and failed probes do not prove that
|
||||
# another serve has stopped. Lifecycle cleanup owns termination.
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Reuse an endpoint already pointed at this URL instead of duplicating.
|
||||
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
|
||||
if existing:
|
||||
existing.is_enabled = True
|
||||
existing.model_type = "llm"
|
||||
# Follow the newly served model unless the user renamed the endpoint.
|
||||
pinned_names = {m.split("/")[-1] for m in _normalize_model_ids(existing.pinned_models)}
|
||||
if existing.name in pinned_names:
|
||||
existing.name = display_name
|
||||
existing.endpoint_kind = "local"
|
||||
existing.model_refresh_mode = "auto"
|
||||
# A reused endpoint belongs to the newest serve session.
|
||||
existing.cookbook_serve_session_id = serve_session_id
|
||||
if launched_model:
|
||||
# Pins represent model selection, not launch history.
|
||||
existing_pins = _normalize_model_ids(
|
||||
existing.pinned_models
|
||||
)
|
||||
if launched_model not in existing_pins:
|
||||
existing_pins.append(launched_model)
|
||||
existing.pinned_models = json.dumps(existing_pins)
|
||||
if is_ollama_endpoint:
|
||||
existing.endpoint_kind = "ollama"
|
||||
if pinned_models:
|
||||
existing.cached_models = json.dumps(pinned_models)
|
||||
if supports_tools is not None:
|
||||
existing.supports_tools = supports_tools
|
||||
db.commit()
|
||||
logger.info(f"Updated existing local model endpoint: {base_url}")
|
||||
# Re-probe so cached_models matches what the server actually
|
||||
# serves right now (the URL may have stayed the same but the
|
||||
# model behind it changed across launches).
|
||||
try:
|
||||
if mlx_shim_model_id:
|
||||
existing.cached_models = json.dumps([mlx_shim_model_id])
|
||||
existing.pinned_models = json.dumps([mlx_shim_model_id])
|
||||
db.commit()
|
||||
else:
|
||||
from routes.model_routes import _probe_endpoint
|
||||
import json as _json2
|
||||
probed = _probe_endpoint(base_url, existing.api_key, timeout=5)
|
||||
if probed:
|
||||
existing.cached_models = _json2.dumps(probed)
|
||||
db.commit()
|
||||
except Exception as _pe:
|
||||
logger.warning(f"Re-probe failed for {base_url}: {_pe!r}")
|
||||
return existing.id
|
||||
|
||||
ep_id = f"local-{uuid.uuid4().hex[:8]}"
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name=display_name,
|
||||
base_url=base_url,
|
||||
api_key=None,
|
||||
is_enabled=True,
|
||||
model_type="llm",
|
||||
endpoint_kind="ollama" if is_ollama_endpoint else "local",
|
||||
model_refresh_mode="auto",
|
||||
cached_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
pinned_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
supports_tools=supports_tools,
|
||||
cookbook_serve_session_id=serve_session_id,
|
||||
)
|
||||
db.add(ep)
|
||||
db.commit()
|
||||
logger.info(f"Auto-registered local model endpoint: {display_name} @ {base_url}")
|
||||
# Probe /v1/models NOW and write cached_models so the chat
|
||||
# picker actually shows the model on the next /api/models
|
||||
# call. Without this immediate probe, the endpoint has empty
|
||||
# cached_models until the next background refresh fires (up
|
||||
# to a minute later) and the picker shows nothing — even
|
||||
# though the endpoint is in the DB and the server is up.
|
||||
try:
|
||||
if mlx_shim_model_id:
|
||||
ep.cached_models = json.dumps([mlx_shim_model_id])
|
||||
ep.pinned_models = json.dumps([mlx_shim_model_id])
|
||||
db.commit()
|
||||
logger.info(f"Auto-register: pinned MLX DeepSeek-V4 shim model @ {base_url}")
|
||||
else:
|
||||
from routes.model_routes import _probe_endpoint
|
||||
import json as _json2
|
||||
probed = _probe_endpoint(base_url, None, timeout=5)
|
||||
if probed:
|
||||
ep.cached_models = _json2.dumps(probed)
|
||||
db.commit()
|
||||
logger.info(f"Auto-register: probed {len(probed)} models @ {base_url}")
|
||||
except Exception as _pe:
|
||||
logger.warning(f"Auto-register: probe-after-create failed for {base_url}: {_pe!r}")
|
||||
return ep_id
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-register local model endpoint: {e}")
|
||||
db.rollback()
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
@@ -728,13 +728,8 @@ def _check_serve_binary(seg: str) -> None:
|
||||
if not tokens:
|
||||
return
|
||||
env_re = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
||||
idx = next((i for i, t in enumerate(tokens) if not env_re.match(t)), len(tokens))
|
||||
first = tokens[idx] if idx < len(tokens) else ""
|
||||
first = next((t for t in tokens if not env_re.match(t)), "")
|
||||
base = os.path.basename(first)
|
||||
# The vLLM/SGLang Dependencies recipes install with `uv pip install …
|
||||
# --torch-backend auto`. Allow uv for that subcommand only, not `uv run`.
|
||||
if base == "uv" and tokens[idx + 1:idx + 3] == ["pip", "install"]:
|
||||
return
|
||||
if base not in _SERVE_CMD_ALLOWLIST:
|
||||
raise HTTPException(
|
||||
400,
|
||||
@@ -1496,20 +1491,6 @@ def _diagnose_serve_output(text: str) -> dict | None:
|
||||
"Port is already in use.",
|
||||
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
|
||||
),
|
||||
(
|
||||
# PyPI torch/vLLM wheels now target CUDA 13, which needs driver 580+.
|
||||
# torch: "The NVIDIA driver on your system is too old (found version 12080)";
|
||||
# vLLM kernels: "CUDA driver version is insufficient for CUDA runtime version".
|
||||
r"NVIDIA driver on your system is too old|CUDA driver version is insufficient",
|
||||
"The installed PyTorch/vLLM build needs a newer NVIDIA driver than this server has.",
|
||||
[
|
||||
{
|
||||
"label": "install builds for the driver's CUDA version, e.g. vLLM's +cu129 wheel from its GitHub release with uv pip install … --torch-backend cu129",
|
||||
"op": "manual",
|
||||
},
|
||||
{"label": "or update the NVIDIA driver (CUDA 13 builds need driver 580 or newer)", "op": "manual"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
|
||||
"No GPUs are visible to the serve process.",
|
||||
|
||||
+473
-65
@@ -43,9 +43,6 @@ from routes.cookbook_output import (
|
||||
error_aware_output_tail, classify_dead_download,
|
||||
HF_CACHE_COMPLETE_PROBE, HF_CACHE_INCOMPLETE_PROBE,
|
||||
)
|
||||
from routes.cookbook.endpoint_registration import (
|
||||
_auto_register_image_endpoint, _auto_register_llm_endpoint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -471,6 +468,175 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
task["payload"].pop("hf_token", None)
|
||||
return state
|
||||
|
||||
def _diagnose_serve_output(text: str) -> dict | None:
|
||||
"""Server-side mirror of the Cookbook UI's common serve diagnoses.
|
||||
|
||||
The browser uses cookbook-diagnosis.js for clickable fixes. This gives
|
||||
the agent/tool path the same structured signal so it can retry with an
|
||||
adjusted command instead of guessing from raw tmux output.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
tail = text[-6000:]
|
||||
patterns = [
|
||||
(
|
||||
r"No available memory for the cache blocks|Available KV cache memory:.*-",
|
||||
"No GPU memory left for KV cache after loading model.",
|
||||
[
|
||||
{"label": "retry with GPU memory utilization 0.95", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.95"},
|
||||
{"label": "retry with context 2048", "op": "replace", "flag": "--max-model-len", "value": "2048"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"CUDA out of memory|torch\.cuda\.OutOfMemoryError|CUDA error: out of memory|warming up sampler|max_num_seqs.*gpu_memory_utilization",
|
||||
"GPU ran out of memory during startup or warmup.",
|
||||
[
|
||||
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
|
||||
{"label": "retry with GPU memory utilization 0.80", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.80"},
|
||||
{"label": "retry with --enforce-eager", "op": "append", "arg": "--enforce-eager"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"not divisib|must be divisible|attention heads.*divisible",
|
||||
"Tensor parallel size is incompatible with the model.",
|
||||
[
|
||||
{"label": "retry with tensor parallel size 1", "op": "replace", "flag": "--tensor-parallel-size", "value": "1"},
|
||||
{"label": "retry with tensor parallel size 2", "op": "replace", "flag": "--tensor-parallel-size", "value": "2"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"KV cache.*too (small|large)|max_model_len.*exceeds|maximum.*context",
|
||||
"Context length is too large for available GPU memory.",
|
||||
[
|
||||
{"label": "retry with context 8192", "op": "replace", "flag": "--max-model-len", "value": "8192"},
|
||||
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"enable-auto-tool-choice requires --tool-call-parser",
|
||||
"Auto tool choice requires an explicit tool call parser.",
|
||||
[{"label": "retry with Hermes tool parser", "op": "append", "arg": "--tool-call-parser hermes"}],
|
||||
),
|
||||
(
|
||||
r"Please pass.*trust.remote.code=True|contains custom code which must be executed to correctly load|does not recognize this architecture|model type.*but Transformers does not",
|
||||
"Model requires custom code or newer model support.",
|
||||
[{"label": "retry with --trust-remote-code", "op": "append", "arg": "--trust-remote-code"}],
|
||||
),
|
||||
(
|
||||
r"Either a revision or a version must be specified|transformers\.integrations\.hub_kernels|kernels/layer",
|
||||
"vLLM/Transformers kernel package mismatch.",
|
||||
[{"label": "update vLLM, Transformers, and kernels on this server", "op": "dependency", "package": "vllm transformers kernels"}],
|
||||
),
|
||||
(
|
||||
r"Address already in use|bind.*address.*in use",
|
||||
"Port is already in use.",
|
||||
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
|
||||
),
|
||||
(
|
||||
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
|
||||
"No GPUs are visible to the serve process.",
|
||||
[{"label": "clear Cookbook GPU selection or choose available GPUs", "op": "settings", "field": "gpus", "value": ""}],
|
||||
),
|
||||
(
|
||||
r"Failed to infer device type|NVML Shared Library Not Found|No module named 'amdsmi'|platform is not available",
|
||||
"vLLM could not find a supported GPU (CUDA or ROCm). "
|
||||
"This machine may have integrated or unsupported graphics only.",
|
||||
[
|
||||
{"label": "switch to llama.cpp (CPU/Metal, works without a discrete GPU)", "op": "manual"},
|
||||
{"label": "switch to Ollama (CPU/Metal, works without a discrete GPU)", "op": "manual"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"vllm.*command not found|No module named vllm|ERROR: vLLM is not installed",
|
||||
"vLLM is not installed or not in PATH on this server.",
|
||||
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
|
||||
),
|
||||
(
|
||||
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)|"
|
||||
r"(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)[\s\S]*sgl_kernel|"
|
||||
r"Could not load any common_ops library|"
|
||||
r"Please ensure sgl_kernel is properly installed",
|
||||
"SGLang native kernel/runtime is missing or mismatched on this server.",
|
||||
[
|
||||
{"label": "repair sglang-kernel in this Python environment", "op": "dependency", "package": "sglang-kernel"},
|
||||
{"label": "if libnvrtc is still missing, install the matching CUDA/NVRTC runtime on this host", "op": "manual"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"sglang.*command not found|No module named sglang|SGLang is not installed",
|
||||
"SGLang is not installed or not in PATH on this server.",
|
||||
[{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}],
|
||||
),
|
||||
(
|
||||
r"No module named ['\"]?mlx_lm|mlx_lm.*command not found|MLX is not installed|MLX LM is not installed",
|
||||
"MLX LM is not installed on this server.",
|
||||
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
|
||||
),
|
||||
(
|
||||
r"OmniGen2Pipeline|module diffusers has no attribute .*Pipeline|custom_pipeline=.*failed",
|
||||
"This image model uses a custom Diffusers pipeline that the launch environment does not know yet.",
|
||||
[{"label": "update Diffusers image dependencies", "op": "dependency", "package": "diffusers transformers accelerate"}],
|
||||
),
|
||||
(
|
||||
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
|
||||
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
|
||||
[
|
||||
{"label": "relaunch from the cached local Hugging Face snapshot path on this Mac", "op": "manual"},
|
||||
{"label": "Odysseus now rewrites MLX repo-id launches to a cached snapshot when one exists", "op": "manual"},
|
||||
],
|
||||
),
|
||||
# System build deps come BEFORE the generic llama.cpp catch-all
|
||||
# so cmake / build-essential / git missing → a specific OS-package
|
||||
# remediation instead of "install llama-cpp-python[server]" (which
|
||||
# itself fails to compile when cmake is absent).
|
||||
(
|
||||
r"cmake: command not found|cmake.*not found.*[Cc]ould not",
|
||||
"cmake is required to build llama.cpp from source but isn't installed on this server.",
|
||||
[{"label": "install build deps for llama.cpp (apt: cmake build-essential git / pacman: cmake base-devel git / dnf: cmake gcc-c++ make git / brew: cmake git)", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||
),
|
||||
(
|
||||
r"^(make|g\+\+|gcc): command not found|Could not find C\+\+ compiler",
|
||||
"A C/C++ compiler (build-essential) is required to build llama.cpp from source.",
|
||||
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||
),
|
||||
(
|
||||
r"^git: command not found",
|
||||
"git is required to clone the llama.cpp source tree.",
|
||||
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||
),
|
||||
(
|
||||
r"llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'",
|
||||
"llama.cpp / llama-cpp-python dependencies are missing.",
|
||||
[{"label": "install llama.cpp dependencies or llama-cpp-python[server]", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||
),
|
||||
(
|
||||
r"No GGUF found on this host|no \.gguf file|No GGUF file found",
|
||||
"No GGUF file found for this model on this host. The llama.cpp backend needs a .gguf file.",
|
||||
[{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}],
|
||||
),
|
||||
(
|
||||
r"No module named 'torch'|No module named torch|No module named 'torchvision'|No module named torchvision|No module named 'diffusers'|No module named diffusers|No module named 'scipy'|No module named scipy|install scipy if you want to use beta sigmas|requires the Torchvision library",
|
||||
"Diffusion serving requires PyTorch, Torchvision, Diffusers, Accelerate, and SciPy.",
|
||||
[{"label": "install Diffusers image deps in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch] torchvision accelerate scipy python-multipart"}],
|
||||
),
|
||||
(
|
||||
r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review",
|
||||
"Model access is gated or unauthorized.",
|
||||
[{"label": "set HF token and request model access on HuggingFace", "op": "manual"}],
|
||||
),
|
||||
]
|
||||
for pattern, message, suggestions in patterns:
|
||||
if re.search(pattern, tail, re.I):
|
||||
return {"message": message, "suggestions": suggestions}
|
||||
if re.search(r"Traceback \(most recent call last\)", tail, re.I) and not re.search(
|
||||
r"Application startup complete|GET /v1/|Uvicorn running on", tail, re.I
|
||||
):
|
||||
return {
|
||||
"message": "Python traceback detected during serve startup.",
|
||||
"suggestions": [{"label": "inspect traceback and retry with adjusted backend/settings", "op": "manual"}],
|
||||
}
|
||||
return None
|
||||
|
||||
def _state_for_client(state):
|
||||
"""Return cookbook state without raw secrets for browser clients."""
|
||||
_strip_task_secrets(state)
|
||||
@@ -1411,6 +1577,80 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
|
||||
return {"models": models, "host": host or "local"}
|
||||
|
||||
def _auto_register_image_endpoint(req: ServeRequest, remote: str | None) -> str | None:
|
||||
"""Register a diffusion model as an image endpoint so it appears in the model selector."""
|
||||
import re
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from src.settings import load_settings, save_settings
|
||||
|
||||
# Parse port from command (--port NNNN), default 8100 for diffusion_server
|
||||
port_match = re.search(r'--port\s+(\d+)', req.cmd)
|
||||
port = int(port_match.group(1)) if port_match else 8100
|
||||
|
||||
# Determine host
|
||||
if remote:
|
||||
# SSH alias — use as hostname (Tailscale resolves it later)
|
||||
host = remote.split("@")[-1] if "@" in remote else remote
|
||||
else:
|
||||
host = "localhost"
|
||||
|
||||
base_url = f"http://{host}:{port}/v1"
|
||||
|
||||
# Friendly display name from repo_id
|
||||
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
|
||||
display_name = f"{short_name} (image)"
|
||||
pinned_models = [req.repo_id] if req.repo_id else []
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Check for existing endpoint with same base_url — update it
|
||||
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
|
||||
if existing:
|
||||
existing.is_enabled = True
|
||||
existing.model_type = "image"
|
||||
existing.name = display_name
|
||||
existing.endpoint_kind = "local"
|
||||
existing.model_refresh_mode = "manual"
|
||||
if pinned_models:
|
||||
existing.cached_models = json.dumps(pinned_models)
|
||||
existing.pinned_models = json.dumps(pinned_models)
|
||||
db.commit()
|
||||
settings = load_settings()
|
||||
if settings.get("image_gen_enabled") is not True:
|
||||
settings["image_gen_enabled"] = True
|
||||
save_settings(settings)
|
||||
logger.info(f"Updated existing image endpoint: {base_url}")
|
||||
return existing.id
|
||||
|
||||
ep_id = f"img-{uuid.uuid4().hex[:8]}"
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name=display_name,
|
||||
base_url=base_url,
|
||||
api_key=None,
|
||||
is_enabled=True,
|
||||
model_type="image",
|
||||
endpoint_kind="local",
|
||||
model_refresh_mode="manual",
|
||||
cached_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
pinned_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
)
|
||||
db.add(ep)
|
||||
db.commit()
|
||||
settings = load_settings()
|
||||
settings["image_gen_enabled"] = True
|
||||
if not settings.get("image_model"):
|
||||
settings["image_model"] = req.repo_id
|
||||
save_settings(settings)
|
||||
logger.info(f"Auto-registered image endpoint: {display_name} @ {base_url}")
|
||||
return ep_id
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-register image endpoint: {e}")
|
||||
db.rollback()
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def _pick_free_port_for_ollama(
|
||||
remote: str | None, ssh_port: str | None, start_port: int, max_offset: int
|
||||
) -> int | None:
|
||||
@@ -1539,66 +1779,244 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# let the probe layer mark it offline if nothing's listening.
|
||||
logger.info(f"crash-watchdog: serve {session_id} exited cleanly (0); leaving endpoint {endpoint_id}")
|
||||
return
|
||||
# A nonzero exit may delete only the endpoint owned by this serve.
|
||||
# Non-zero exit — drop the endpoint.
|
||||
try:
|
||||
from core.database import SessionLocal as _SL, ModelEndpoint as _ME
|
||||
from core.constants import internal_api_base as _internal_api_base
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||
from urllib.parse import quote
|
||||
import httpx
|
||||
|
||||
db = _SL()
|
||||
try:
|
||||
ep = db.query(_ME).filter(_ME.id == endpoint_id).first()
|
||||
if not ep or ep.cookbook_serve_session_id != session_id:
|
||||
if ep:
|
||||
# A scheduled serve can leave old non-zero exit markers
|
||||
# in tmux scrollback while the current OpenAI endpoint is
|
||||
# actually alive. Verify reachability before deleting the
|
||||
# endpoint row; otherwise chats fall back even though the
|
||||
# served model is ready.
|
||||
try:
|
||||
probe_url = ep.base_url.rstrip("/") + "/models"
|
||||
with urllib.request.urlopen(probe_url, timeout=3) as resp:
|
||||
if 200 <= getattr(resp, "status", 0) < 300:
|
||||
logger.info(
|
||||
f"crash-watchdog: serve {session_id} has exit marker {exit_code} "
|
||||
f"but endpoint {ep.id} is reachable; leaving it registered"
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(
|
||||
"crash-watchdog: endpoint %s no longer owned by %s",
|
||||
endpoint_id, session_id,
|
||||
f"crash-watchdog: dropping endpoint {endpoint_id} "
|
||||
f"({ep.name} @ {ep.base_url}) — serve exited {exit_code}"
|
||||
)
|
||||
return
|
||||
probe_url = ep.base_url.rstrip("/") + "/models"
|
||||
db.delete(ep)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Preserve an endpoint that is already healthy, including
|
||||
# after an old runner emits a stale failure marker.
|
||||
try:
|
||||
with urllib.request.urlopen(probe_url, timeout=3) as resp:
|
||||
if 200 <= getattr(resp, "status", 0) < 300:
|
||||
logger.info(
|
||||
"crash-watchdog: endpoint %s is reachable; keeping it",
|
||||
endpoint_id,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async with httpx.AsyncClient(timeout=8) as client:
|
||||
response = await client.delete(
|
||||
f"{_internal_api_base()}/api/model-endpoints/{quote(endpoint_id, safe='')}",
|
||||
params={"expected_cookbook_serve_session_id": session_id},
|
||||
headers={INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
"crash-watchdog: deleted endpoint %s after serve %s exited %s",
|
||||
endpoint_id, session_id, exit_code,
|
||||
)
|
||||
elif response.status_code in (404, 409):
|
||||
logger.info(
|
||||
"crash-watchdog: endpoint %s absent or reassigned; keeping it",
|
||||
endpoint_id,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"crash-watchdog: endpoint %s deletion returned HTTP %s",
|
||||
endpoint_id, response.status_code,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("crash-watchdog: endpoint cleanup failed: %r", e)
|
||||
logger.warning(f"crash-watchdog: endpoint cleanup failed: {e!r}")
|
||||
return
|
||||
logger.debug(f"crash-watchdog: no exit marker for {session_id} within window; leaving endpoint {endpoint_id}")
|
||||
|
||||
def _auto_register_llm_endpoint(req: ServeRequest, remote: str | None) -> str | None:
|
||||
"""Register a freshly-served LLM as a model endpoint so it appears in the
|
||||
model picker without a manual /setup step — the text-model sibling of
|
||||
_auto_register_image_endpoint.
|
||||
|
||||
Cookbook serve commands launch an OpenAI-compatible server (llama.cpp's
|
||||
llama-server, vLLM, SGLang, or Ollama) on a known port. We point an
|
||||
endpoint at that server's /v1; the picker auto-discovers the model id by
|
||||
probing /v1/models and dims the endpoint until the server is reachable,
|
||||
so registering immediately (before the server finishes loading) is safe.
|
||||
"""
|
||||
logger.info(
|
||||
f"_auto_register_llm_endpoint: ENTRY repo_id={req.repo_id!r} "
|
||||
f"remote={remote!r} cmd_prefix={req.cmd[:80]!r}"
|
||||
)
|
||||
import re
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
|
||||
# Port: ordered fallbacks so we match whatever the user actually
|
||||
# asked for, not a hardcoded default:
|
||||
# 1. explicit `--port N` (vllm / sglang / llama-server)
|
||||
# 2. `OLLAMA_HOST=host:port` (the way Ollama specifies its bind)
|
||||
# 3. fallback by backend (11434 ollama / 8080 llama.cpp)
|
||||
# Previously the OLLAMA_HOST form was silently ignored and we
|
||||
# registered every Ollama endpoint at 11434 — even if the user
|
||||
# set OLLAMA_HOST=0.0.0.0:11435 to avoid colliding with an
|
||||
# existing systemd Ollama, the registered endpoint pointed at
|
||||
# the OLD port and showed as offline.
|
||||
port_match = re.search(r'--port\s+(\d+)', req.cmd)
|
||||
ollama_host_match = re.search(r'OLLAMA_HOST=[^\s]*?:(\d+)', req.cmd)
|
||||
if port_match:
|
||||
port = int(port_match.group(1))
|
||||
elif ollama_host_match:
|
||||
port = int(ollama_host_match.group(1))
|
||||
elif "ollama" in req.cmd:
|
||||
port = 11434
|
||||
else:
|
||||
port = 8080 # llama.cpp's llama-server default — the Apple Silicon path
|
||||
|
||||
# Determine host. The cookbook tmux for `local=true` serves runs INSIDE
|
||||
# the odysseus container — so the right URL for the in-container
|
||||
# backend to reach it is `localhost`, NOT `host.docker.internal`
|
||||
# (the latter points at the docker HOST, which doesn't have a server
|
||||
# on that port). The previous host.docker.internal fallback only made
|
||||
# sense for /setup-added external services like systemd Ollama on the
|
||||
# host — and those go through manual setup, not this auto-register
|
||||
# code path. For remote serves we still use the SSH host alias.
|
||||
if remote:
|
||||
host = remote.split("@")[-1] if "@" in remote else remote
|
||||
elif re.search(r"\bdocker\s+exec\s+(?:ollama-rocm|ollama-test)\b", req.cmd or ""):
|
||||
host = "host.docker.internal"
|
||||
else:
|
||||
host = "localhost"
|
||||
|
||||
base_url = f"http://{host}:{port}/v1"
|
||||
|
||||
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
|
||||
display_name = short_name or "Local model"
|
||||
is_mlx_deepseek_v4 = (
|
||||
"mlx_lm.server" in (req.cmd or "")
|
||||
and "deepseek-v4" in ((req.repo_id or "") + " " + (req.cmd or "")).lower()
|
||||
)
|
||||
mlx_shim_model_id = ""
|
||||
if is_mlx_deepseek_v4 and short_name:
|
||||
home_match = re.search(r"((?:/Users|/home)/[^/\s'\"]+)", req.cmd or "")
|
||||
remote_home = home_match.group(1) if home_match else ""
|
||||
if remote_home:
|
||||
mlx_shim_model_id = f"{remote_home}/.cache/odysseus/mlx-shims/{short_name}"
|
||||
|
||||
# If the serve command opts models into OpenAI tool-calling, record it so
|
||||
# agent_loop trusts emitted tool_calls instead of the name heuristic.
|
||||
is_ollama_endpoint = "ollama" in (req.cmd or "").lower()
|
||||
supports_tools = True if "--enable-auto-tool-choice" in req.cmd else None
|
||||
# Pin the model the user launched for every Cookbook-created LLM
|
||||
# endpoint, not just Ollama. Some OpenAI-compatible servers report a
|
||||
# deployment alias from /v1/models, and a stale server can answer on the
|
||||
# same port while the new launch failed. Keeping the requested model id
|
||||
# pinned makes the picker reflect the actual launch intent.
|
||||
pinned_models = [mlx_shim_model_id] if mlx_shim_model_id else ([req.repo_id] if req.repo_id else [])
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Reuse an endpoint already pointed at this URL instead of duplicating.
|
||||
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
|
||||
if existing:
|
||||
existing.is_enabled = True
|
||||
existing.model_type = "llm"
|
||||
existing.name = display_name
|
||||
existing.endpoint_kind = "local"
|
||||
existing.model_refresh_mode = "auto"
|
||||
if pinned_models:
|
||||
try:
|
||||
existing_pinned = json.loads(existing.pinned_models or "[]")
|
||||
except Exception:
|
||||
existing_pinned = []
|
||||
merged_pinned = []
|
||||
for mid in [*existing_pinned, *pinned_models]:
|
||||
if mid and mid not in merged_pinned:
|
||||
merged_pinned.append(mid)
|
||||
existing.pinned_models = json.dumps(merged_pinned) if merged_pinned else None
|
||||
if is_ollama_endpoint:
|
||||
existing.endpoint_kind = "ollama"
|
||||
if pinned_models:
|
||||
existing.cached_models = json.dumps(pinned_models)
|
||||
if supports_tools is not None:
|
||||
existing.supports_tools = supports_tools
|
||||
db.commit()
|
||||
logger.info(f"Updated existing local model endpoint: {base_url}")
|
||||
# Re-probe so cached_models matches what the server actually
|
||||
# serves right now (the URL may have stayed the same but the
|
||||
# model behind it changed across launches).
|
||||
try:
|
||||
if mlx_shim_model_id:
|
||||
existing.cached_models = json.dumps([mlx_shim_model_id])
|
||||
existing.pinned_models = json.dumps([mlx_shim_model_id])
|
||||
db.commit()
|
||||
else:
|
||||
from routes.model_routes import _probe_endpoint
|
||||
import json as _json2
|
||||
probed = _probe_endpoint(base_url, existing.api_key, timeout=5)
|
||||
if probed:
|
||||
existing.cached_models = _json2.dumps(probed)
|
||||
db.commit()
|
||||
except Exception as _pe:
|
||||
logger.warning(f"Re-probe failed for {base_url}: {_pe!r}")
|
||||
# Sweep stale dupes: other endpoints with the same display name
|
||||
# at DIFFERENT URLs (likely failed earlier-attempt ports) get
|
||||
# deleted so the picker doesn't show an offline ghost next to
|
||||
# the working one. Only sweeps endpoints whose id starts with
|
||||
# `local-` so we never touch a user's hand-added DeepSeek/OpenAI/
|
||||
# etc. entry with a coincidentally matching name.
|
||||
stale = (db.query(ModelEndpoint)
|
||||
.filter(ModelEndpoint.name == display_name)
|
||||
.filter(ModelEndpoint.base_url != base_url)
|
||||
.filter(ModelEndpoint.id.like("local-%"))
|
||||
.all())
|
||||
for s in stale:
|
||||
logger.info(f"Sweeping stale local endpoint {s.id} ({s.base_url})")
|
||||
db.delete(s)
|
||||
if stale:
|
||||
db.commit()
|
||||
return existing.id
|
||||
|
||||
ep_id = f"local-{uuid.uuid4().hex[:8]}"
|
||||
ep = ModelEndpoint(
|
||||
id=ep_id,
|
||||
name=display_name,
|
||||
base_url=base_url,
|
||||
api_key=None,
|
||||
is_enabled=True,
|
||||
model_type="llm",
|
||||
endpoint_kind="ollama" if is_ollama_endpoint else "local",
|
||||
model_refresh_mode="auto",
|
||||
cached_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
pinned_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
supports_tools=supports_tools,
|
||||
)
|
||||
db.add(ep)
|
||||
db.commit()
|
||||
logger.info(f"Auto-registered local model endpoint: {display_name} @ {base_url}")
|
||||
# Same sweep on first-register path: drop any pre-existing local-*
|
||||
# endpoints with this display name pointed elsewhere.
|
||||
stale = (db.query(ModelEndpoint)
|
||||
.filter(ModelEndpoint.name == display_name)
|
||||
.filter(ModelEndpoint.id != ep_id)
|
||||
.filter(ModelEndpoint.id.like("local-%"))
|
||||
.all())
|
||||
for s in stale:
|
||||
logger.info(f"Sweeping stale local endpoint {s.id} ({s.base_url})")
|
||||
db.delete(s)
|
||||
if stale:
|
||||
db.commit()
|
||||
# Probe /v1/models NOW and write cached_models so the chat
|
||||
# picker actually shows the model on the next /api/models
|
||||
# call. Without this immediate probe, the endpoint has empty
|
||||
# cached_models until the next background refresh fires (up
|
||||
# to a minute later) and the picker shows nothing — even
|
||||
# though the endpoint is in the DB and the server is up.
|
||||
try:
|
||||
if mlx_shim_model_id:
|
||||
ep.cached_models = json.dumps([mlx_shim_model_id])
|
||||
ep.pinned_models = json.dumps([mlx_shim_model_id])
|
||||
db.commit()
|
||||
logger.info(f"Auto-register: pinned MLX DeepSeek-V4 shim model @ {base_url}")
|
||||
else:
|
||||
from routes.model_routes import _probe_endpoint
|
||||
import json as _json2
|
||||
probed = _probe_endpoint(base_url, None, timeout=5)
|
||||
if probed:
|
||||
ep.cached_models = _json2.dumps(probed)
|
||||
db.commit()
|
||||
logger.info(f"Auto-register: probed {len(probed)} models @ {base_url}")
|
||||
except Exception as _pe:
|
||||
logger.warning(f"Auto-register: probe-after-create failed for {base_url}: {_pe!r}")
|
||||
return ep_id
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-register local model endpoint: {e}")
|
||||
db.rollback()
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.post("/api/model/serve")
|
||||
async def model_serve(request: Request, req: ServeRequest):
|
||||
"""Launch a model server in a tmux session (or PowerShell background process on Windows).
|
||||
@@ -2390,17 +2808,9 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
endpoint_id = None
|
||||
is_image_endpoint = "diffusion_server.py" in req.cmd or "mlx_image_server.py" in req.cmd
|
||||
if is_image_endpoint:
|
||||
endpoint_id = _auto_register_image_endpoint(
|
||||
req,
|
||||
remote,
|
||||
serve_session_id=session_id,
|
||||
)
|
||||
endpoint_id = _auto_register_image_endpoint(req, remote)
|
||||
elif not is_pip_install:
|
||||
endpoint_id = _auto_register_llm_endpoint(
|
||||
req,
|
||||
remote,
|
||||
serve_session_id=session_id,
|
||||
)
|
||||
endpoint_id = _auto_register_llm_endpoint(req, remote)
|
||||
|
||||
# Crash watchdog: the auto-register above writes the endpoint row
|
||||
# IMMEDIATELY (before the server has even bound its port) so the
|
||||
@@ -3520,7 +3930,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
return {"ok": False, "files": [], "error": f"HF API HTTP {resp.status_code}"}
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
logger.exception("HF GGUF file scan failed for %s", repo_id)
|
||||
logger.exception("HF GGUF file scan failed for %s", repo)
|
||||
return {"ok": False, "files": [], "error": "HF API request failed"}
|
||||
files = [
|
||||
str(s.get("rfilename") or "")
|
||||
@@ -4036,9 +4446,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# fills the visible tail. Without this, output_tail ends up
|
||||
# as just "Locale: C / Ubuntu_Odysseus ❯" and the agent
|
||||
# can't diagnose the actual error.
|
||||
# -J joins lines tmux wrapped at the pane width, so an error
|
||||
# split mid-word still matches the serve diagnosis patterns.
|
||||
capture_cmd = ssh_base + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-J", "-S", "-500")]
|
||||
capture_cmd = ssh_base + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-S", "-500")]
|
||||
elif IS_WINDOWS:
|
||||
# LOCAL Windows task: launched as a detached process (no tmux).
|
||||
# Liveness comes from the <session>.pid file, output from the
|
||||
@@ -4047,7 +4455,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
capture_cmd = None
|
||||
else:
|
||||
check_cmd = ["tmux", "has-session", "-t", session_id]
|
||||
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-J", "-S", "-500"]
|
||||
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-S", "-500"]
|
||||
|
||||
local_win_task = (not remote) and IS_WINDOWS
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ from core.database import SessionLocal, Document, DocumentVersion
|
||||
from core.database import Session as DbSession
|
||||
from src.auth_helpers import get_current_user, _auth_disabled
|
||||
from src.constants import MAIL_ATTACHMENTS_DIR
|
||||
from src.session_search import _escape_like
|
||||
from src.upload_handler import reserve_upload_references
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -572,10 +571,9 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
# match docs containing both words regardless of position/order.
|
||||
if search:
|
||||
for tok in search.split():
|
||||
term = f"%{_escape_like(tok)}%"
|
||||
term = f"%{tok}%"
|
||||
q = q.filter(
|
||||
Document.title.ilike(term, escape="\\")
|
||||
| Document.current_content.ilike(term, escape="\\")
|
||||
Document.title.ilike(term) | Document.current_content.ilike(term)
|
||||
)
|
||||
|
||||
# Language filter. "pdf" is a display language derived from the
|
||||
@@ -814,13 +812,10 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
ext = _ext.get(doc.language or "text", ".txt")
|
||||
base = (doc.title or "document").strip() or "document"
|
||||
base = re.sub(r"[^\w\-. ]+", "", base)[:60].strip() or doc.id
|
||||
has_ext = base.lower().endswith(ext)
|
||||
stem = base[:-len(ext)] if has_ext else base
|
||||
suffix = base[-len(ext):] if has_ext else ext
|
||||
name = stem + suffix
|
||||
name = base if "." in base else base + ext
|
||||
i = 1
|
||||
while name in used:
|
||||
name = f"{stem}-{i}{suffix}"
|
||||
name = f"{base}-{i}" + ("" if "." in base else ext)
|
||||
i += 1
|
||||
used.add(name)
|
||||
content = doc.current_content or ""
|
||||
|
||||
@@ -846,64 +846,6 @@ def _plan_psd_steps(prompt: str) -> Optional[List[Dict[str, Any]]]:
|
||||
return None
|
||||
|
||||
|
||||
def _is_background_removal_intent(prompt: str) -> bool:
|
||||
"""Linear-time, bounded intent parser for background removal commands.
|
||||
|
||||
Replaces the previous backtracking regular expression flagged by CodeQL
|
||||
(#1212) while preserving the exact command grammar.
|
||||
"""
|
||||
if not prompt or not isinstance(prompt, str):
|
||||
return False
|
||||
i = len(prompt)
|
||||
while i > 0 and prompt[i - 1] in ".! \t\r\n":
|
||||
i -= 1
|
||||
cleaned = prompt[:i].strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
|
||||
tokens = cleaned.split()
|
||||
if not tokens:
|
||||
return False
|
||||
|
||||
idx = 0
|
||||
if tokens[idx].lower() == "please":
|
||||
idx += 1
|
||||
if idx >= len(tokens):
|
||||
return False
|
||||
|
||||
if tokens[idx].lower() not in {"remove", "erase", "delete", "clear", "strip"}:
|
||||
return False
|
||||
idx += 1
|
||||
if idx >= len(tokens):
|
||||
return False
|
||||
|
||||
if tokens[idx].lower() == "the":
|
||||
idx += 1
|
||||
if idx >= len(tokens):
|
||||
return False
|
||||
|
||||
if tokens[idx].lower() not in {"bg", "background", "backdrop"}:
|
||||
return False
|
||||
idx += 1
|
||||
|
||||
if idx == len(tokens):
|
||||
return True
|
||||
|
||||
if tokens[idx].lower() in {"from", "of"}:
|
||||
idx += 1
|
||||
if idx < len(tokens) and tokens[idx].lower() == "the":
|
||||
idx += 1
|
||||
if (
|
||||
idx + 1 < len(tokens)
|
||||
and tokens[idx].lower() in {"active", "current", "selected"}
|
||||
and tokens[idx + 1].lower() == "layer"
|
||||
and idx + 2 == len(tokens)
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _plan_special(
|
||||
prompt: str, state: Any, provider_available: bool,
|
||||
) -> Optional[Tuple[List[Dict[str, Any]], str, str]]:
|
||||
@@ -913,8 +855,6 @@ def _plan_special(
|
||||
left to the model. Returns (steps, reply, note) or None. These never need an
|
||||
image provider except generative border fill, used only when one exists.
|
||||
"""
|
||||
if _is_background_removal_intent(prompt):
|
||||
return [{"op": "ai.removeBackground", "params": {}}], "Removing the active layer's background.", ""
|
||||
# Targeted recolour is fully deterministic (object mask + masked hue shift)
|
||||
# and must never be left to the model, which sometimes substitutes a
|
||||
# generative placeImage. Highest priority so it can never vary.
|
||||
@@ -1707,13 +1647,6 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
screenshot = body.get("screenshot")
|
||||
# Optional user-picked image model (from the editor model selector).
|
||||
model_choice = str(body.get("model") or body.get("image_model") or "").strip()
|
||||
generation_model_choice = str(body.get("generation_model") or "").strip()
|
||||
use_image_settings = body.get("_use_image_settings") is True
|
||||
if use_image_settings:
|
||||
from src.editor_image_settings import editor_image_models
|
||||
image_models = editor_image_models(owner=owner)
|
||||
model_choice = image_models["edit"]
|
||||
generation_model_choice = image_models["generation"]
|
||||
# Optional user-picked PLANNER model (the editor agent/planner model).
|
||||
agent_model = str(body.get("agent_model") or "").strip()
|
||||
tools_offered = [tool for tool in _scoped_editor_tools() if tool not in disabled]
|
||||
@@ -1864,22 +1797,9 @@ def setup_editor_agent_routes() -> APIRouter:
|
||||
# Planner output is untrusted: it cannot redirect this turn to
|
||||
# another editor session or override an explicit model choice.
|
||||
tool_payload["session"] = session
|
||||
if use_image_settings:
|
||||
tool_payload["_use_image_settings"] = True
|
||||
if op == "generate_image" and state.get("width") and state.get("height"):
|
||||
try:
|
||||
width, height = int(state["width"]), int(state["height"])
|
||||
if width > 0 and height > 0:
|
||||
tool_payload["size"] = f"{width}x{height}"
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
# Route to the user-picked model when one is selected.
|
||||
selected_model = generation_model_choice if op == "generate_image" else model_choice
|
||||
if selected_model:
|
||||
tool_payload["model"] = selected_model
|
||||
elif op == "generate_image" or use_image_settings:
|
||||
# Let server settings resolve Auto, not a planner-invented model.
|
||||
tool_payload.pop("model", None)
|
||||
if model_choice:
|
||||
tool_payload["model"] = model_choice
|
||||
# Hand the AI step the canvas image the client already captured
|
||||
# for this request. Without it the tool falls back to a separate
|
||||
# bridge screenshot, which is not reliably answered right after a
|
||||
|
||||
@@ -36,7 +36,6 @@ from fastapi import Query, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
|
||||
from src.email_mime import decode_text_part as _decode_text_part
|
||||
from src.auth_helpers import _auth_disabled, get_current_user
|
||||
from src.secret_storage import decrypt as _decrypt
|
||||
from src.url_safety import OutboundAddressBlocked, connect_outbound_tcp
|
||||
@@ -1777,38 +1776,44 @@ def _extract_html(msg):
|
||||
ct = part.get_content_type()
|
||||
cd = str(part.get("Content-Disposition", ""))
|
||||
if ct == "text/html" and "attachment" not in cd:
|
||||
text = _decode_text_part(part)
|
||||
if text:
|
||||
return text
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
return payload.decode(charset, errors="replace")
|
||||
elif msg.get_content_type() == "text/html":
|
||||
text = _decode_text_part(msg)
|
||||
if text:
|
||||
return text
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = msg.get_content_charset() or "utf-8"
|
||||
return payload.decode(charset, errors="replace")
|
||||
return None
|
||||
|
||||
|
||||
def _extract_text(msg):
|
||||
if msg.is_multipart():
|
||||
text_parts = []
|
||||
html_parts = []
|
||||
for part in msg.walk():
|
||||
ct = part.get_content_type()
|
||||
cd = str(part.get("Content-Disposition", ""))
|
||||
if ct == "text/plain" and "attachment" not in cd:
|
||||
text = _decode_text_part(part)
|
||||
if text:
|
||||
text_parts.append(text)
|
||||
elif ct == "text/html" and not html_parts and "attachment" not in cd:
|
||||
raw_html = _decode_text_part(part)
|
||||
if raw_html:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
text_parts.append(payload.decode(charset, errors="replace"))
|
||||
elif ct == "text/html" and not text_parts and "attachment" not in cd:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
raw_html = payload.decode(charset, errors="replace")
|
||||
text = re.sub(r"<br\s*/?>", "\n", raw_html, flags=re.I)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
text = html.unescape(text)
|
||||
html_parts.append(text.strip())
|
||||
return "\n".join(text_parts or html_parts)
|
||||
text_parts.append(text.strip())
|
||||
return "\n".join(text_parts)
|
||||
else:
|
||||
text = _decode_text_part(msg)
|
||||
if text:
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = msg.get_content_charset() or "utf-8"
|
||||
text = payload.decode(charset, errors="replace")
|
||||
if msg.get_content_type() == "text/html":
|
||||
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
|
||||
text = re.sub(r"</(?:p|div|li|tr|h[1-6])\s*>", "\n", text, flags=re.I)
|
||||
|
||||
@@ -18,7 +18,6 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
|
||||
from core.database import Session as DbSession
|
||||
from src.auth_helpers import get_current_user, owner_filter, require_privilege
|
||||
from src.session_search import escape_like_pattern
|
||||
from src.upload_limits import (
|
||||
read_upload_limited,
|
||||
GALLERY_UPLOAD_MAX_BYTES,
|
||||
@@ -38,24 +37,6 @@ _SAM_STATE: Dict[str, Any] = {}
|
||||
_GROUNDING_STATE: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def _compose_cloud_image_edit(source, generated, mask, *, instruction=False):
|
||||
from PIL import Image, ImageChops, ImageOps
|
||||
|
||||
full_frame = instruction or mask.getextrema() == (255, 255)
|
||||
if full_frame:
|
||||
return generated, True
|
||||
# Partial edits must share the source coordinate system, but resizing
|
||||
# width/height independently distorts the subject when the provider changes ratio.
|
||||
fitted = ImageOps.contain(generated, source.size, Image.Resampling.LANCZOS)
|
||||
offset = ((source.width - fitted.width) // 2, (source.height - fitted.height) // 2)
|
||||
aligned = Image.new("RGBA", source.size)
|
||||
aligned.paste(fitted, offset)
|
||||
coverage = Image.new("L", source.size)
|
||||
coverage.paste(255, (offset[0], offset[1], offset[0] + fitted.width, offset[1] + fitted.height))
|
||||
effective_mask = ImageChops.multiply(mask, coverage)
|
||||
return Image.composite(aligned, source, effective_mask), False
|
||||
|
||||
|
||||
def _rss_mb() -> float:
|
||||
"""Current process RSS in MB (from /proc; -1.0 when unavailable)."""
|
||||
try:
|
||||
@@ -324,23 +305,6 @@ def _b64_bytes(value: str, *, label: str = "image") -> bytes:
|
||||
raise HTTPException(400, f"Invalid {label}") from exc
|
||||
|
||||
|
||||
async def _json_object(request: Request) -> dict:
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Invalid JSON body")
|
||||
if not isinstance(data, dict):
|
||||
raise HTTPException(400, "JSON object body required")
|
||||
return data
|
||||
|
||||
|
||||
async def _image_ids(request: Request) -> list:
|
||||
ids = (await _json_object(request)).get("image_ids", [])
|
||||
if not isinstance(ids, list) or not all(isinstance(i, str) for i in ids):
|
||||
raise HTTPException(400, "image_ids must be a list of image ids")
|
||||
return ids
|
||||
|
||||
|
||||
def _pil_image_to_b64(img, *, fmt: str = "PNG") -> str:
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format=fmt)
|
||||
@@ -604,7 +568,6 @@ def _is_openai_api_base(url: str) -> bool:
|
||||
|
||||
|
||||
_GALLERY_ENDPOINT_PATHS = frozenset({
|
||||
"/chat/completions",
|
||||
"/images",
|
||||
"/images/edits",
|
||||
"/images/generations",
|
||||
@@ -1221,18 +1184,15 @@ def setup_gallery_routes() -> APIRouter:
|
||||
)
|
||||
q = _owner_filter(q, user)
|
||||
|
||||
# Per-term AND search: a single `%foo bar%` LIKE only matched the
|
||||
# exact phrase, so "red car" missed "a car that is red".
|
||||
# LIKE wildcards are escaped per token.
|
||||
# Search filter (prompt + tags + ai_tags)
|
||||
if search:
|
||||
term = f"%{search}%"
|
||||
from sqlalchemy import or_
|
||||
for tok in search.split():
|
||||
term = f"%{escape_like_pattern(tok)}%"
|
||||
q = q.filter(or_(
|
||||
GalleryImage.prompt.ilike(term, escape="\\"),
|
||||
GalleryImage.tags.ilike(term, escape="\\"),
|
||||
GalleryImage.ai_tags.ilike(term, escape="\\"),
|
||||
))
|
||||
q = q.filter(or_(
|
||||
GalleryImage.prompt.ilike(term),
|
||||
GalleryImage.tags.ilike(term),
|
||||
GalleryImage.ai_tags.ilike(term),
|
||||
))
|
||||
|
||||
# Tag filter. The UI stacks multiple tag pills by passing them
|
||||
# comma-separated — each tag adds a separate AND-filter so the
|
||||
@@ -1243,10 +1203,9 @@ def setup_gallery_routes() -> APIRouter:
|
||||
for one in (t.strip() for t in tag.split(",")):
|
||||
if not one:
|
||||
continue
|
||||
safe_tag = f"%{escape_like_pattern(one)}%"
|
||||
q = q.filter(_or(
|
||||
GalleryImage.tags.ilike(safe_tag, escape="\\"),
|
||||
GalleryImage.ai_tags.ilike(safe_tag, escape="\\"),
|
||||
GalleryImage.tags.ilike(f"%{one}%"),
|
||||
GalleryImage.ai_tags.ilike(f"%{one}%"),
|
||||
))
|
||||
|
||||
# Model filter
|
||||
@@ -1786,17 +1745,6 @@ def setup_gallery_routes() -> APIRouter:
|
||||
if not ok:
|
||||
raise HTTPException(400, f"Rejected endpoint URL: {reason}")
|
||||
chosen_model = (body.pop("_model", "") or "").strip()
|
||||
if body.pop("_use_image_settings", False) is True:
|
||||
from src.editor_image_settings import editor_image_models
|
||||
from src.ai_interaction import _resolve_model
|
||||
chosen_model = editor_image_models(owner=user)["edit"]
|
||||
if not chosen_model:
|
||||
raise HTTPException(400, "Choose an image edit model in AI settings")
|
||||
try:
|
||||
edit_url, chosen_model, _ = await asyncio.to_thread(_resolve_model, chosen_model, owner=user)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
requested_base = edit_url.removesuffix("/chat/completions").rstrip("/")
|
||||
# Optional client-supplied id so GET /api/image/edit-progress can poll the
|
||||
# provider's step progress for this op. Ignored by providers that do not
|
||||
# implement the progress contract.
|
||||
@@ -1914,30 +1862,14 @@ def setup_gallery_routes() -> APIRouter:
|
||||
mask_png.save(reference_mask, format="PNG")
|
||||
references = [src_buf.getvalue(), reference_mask.getvalue()]
|
||||
payload = {
|
||||
"model": oa_model, "n": 1,
|
||||
"model": oa_model, "size": size, "n": 1,
|
||||
"output_format": "png",
|
||||
"prompt": "Edit the first image. The second image is a mask: white marks the region to edit, black marks the region to preserve. Keep composition and framing unchanged. Requested edit: " + str(body.get("prompt", "")),
|
||||
"input_references": [{"type": "image_url", "image_url": {
|
||||
"url": "data:image/png;base64," + base64.b64encode(value).decode(),
|
||||
}} for value in references],
|
||||
}
|
||||
from src.image_model_ids import image_aspect_ratio
|
||||
payload["aspect_ratio"] = image_aspect_ratio(source_png.width, source_png.height)
|
||||
from src.ai_interaction import _uses_openrouter_chat_images
|
||||
if _uses_openrouter_chat_images(oa_model):
|
||||
payload = {
|
||||
"model": oa_model,
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "text", "text": payload["prompt"]},
|
||||
*[{"type": "image_url", "image_url": ref["image_url"]}
|
||||
for ref in payload["input_references"]],
|
||||
]}],
|
||||
"modalities": ["image", "text"], "stream": False,
|
||||
"image_config": {"aspect_ratio": payload["aspect_ratio"]},
|
||||
}
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/chat/completions"), headers=headers, json=payload)
|
||||
else:
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images"), headers=headers, json=payload)
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images"), headers=headers, json=payload)
|
||||
else:
|
||||
r = await client.post(_join_checked_gallery_endpoint(base, "/images/edits"), headers=headers, data=data, files=files)
|
||||
if r.status_code != 200:
|
||||
@@ -1953,12 +1885,6 @@ def setup_gallery_routes() -> APIRouter:
|
||||
pass
|
||||
raise HTTPException(r.status_code, f"{_detail} (provider HTTP {r.status_code})")
|
||||
result = r.json()
|
||||
from src.ai_interaction import _chat_image_results, _image_response_error
|
||||
provider_error = _image_response_error(result, oa_model)
|
||||
if provider_error:
|
||||
raise HTTPException(400 if "deprecated" in provider_error.lower() else 502, provider_error)
|
||||
if is_openrouter and _uses_openrouter_chat_images(oa_model):
|
||||
result = {"data": _chat_image_results(result)}
|
||||
raw_b64 = None
|
||||
if result.get("data"):
|
||||
item = result["data"][0]
|
||||
@@ -1978,13 +1904,17 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# the masked region actually changes.
|
||||
try:
|
||||
generated = Image.open(io.BytesIO(base64.b64decode(raw_b64))).convert("RGBA")
|
||||
blended, full_frame = _compose_cloud_image_edit(
|
||||
source_png, generated, mask_png,
|
||||
instruction=body.get("mode") == "instruction",
|
||||
)
|
||||
# Match the generated image to the source dims.
|
||||
if generated.size != source_png.size:
|
||||
generated = generated.resize(source_png.size, Image.LANCZOS)
|
||||
# mask_png: white = regenerate (use generated),
|
||||
# black = keep (use source).
|
||||
# Composite: result = source * (1 - mask_norm) + generated * mask_norm
|
||||
# Image.composite does exactly that with `mask`.
|
||||
blended = Image.composite(generated, source_png, mask_png)
|
||||
out_buf = io.BytesIO()
|
||||
blended.save(out_buf, format="PNG")
|
||||
return {"image": base64.b64encode(out_buf.getvalue()).decode(), "model": oa_model, "full_frame": full_frame}
|
||||
return {"image": base64.b64encode(out_buf.getvalue()).decode(), "model": oa_model}
|
||||
except Exception as comp_err:
|
||||
# Never return a full-image edit when masking fails.
|
||||
logger.warning(f"Inpaint compose failed: {comp_err}")
|
||||
@@ -2641,9 +2571,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
height = int((body or {}).get("height") or 0)
|
||||
except (TypeError, ValueError):
|
||||
width = height = 0
|
||||
if width > 0 and height > 0:
|
||||
size = f"{width}x{height}"
|
||||
elif height > width:
|
||||
if height > width:
|
||||
size = "1024x1536"
|
||||
elif width > height:
|
||||
size = "1536x1024"
|
||||
@@ -2656,9 +2584,6 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# The editor's model picker may choose a specific (cloud) image model;
|
||||
# forward it so settings defaults only apply when none is selected.
|
||||
model_choice = str((body or {}).get("model") or "").strip()
|
||||
if body.get("_use_image_settings") is True:
|
||||
from src.editor_image_settings import editor_image_models
|
||||
model_choice = editor_image_models(owner=user)["generation"]
|
||||
payload = {"prompt": prompt, "size": size}
|
||||
if model_choice:
|
||||
payload["model"] = model_choice
|
||||
@@ -3014,22 +2939,14 @@ def setup_gallery_routes() -> APIRouter:
|
||||
@router.put("/api/gallery/albums/{album_id}")
|
||||
async def update_album(request: Request, album_id: str):
|
||||
user = get_current_user(request)
|
||||
data = await _json_object(request)
|
||||
name = data.get("name")
|
||||
if name is not None:
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise HTTPException(400, "Album name required")
|
||||
name = name.strip()
|
||||
description = data.get("description")
|
||||
if description is not None and not isinstance(description, str):
|
||||
raise HTTPException(400, "Album description must be a string")
|
||||
data = await request.json()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
album = _get_or_404_album(db, album_id, user)
|
||||
if name is not None:
|
||||
album.name = name
|
||||
if description is not None:
|
||||
album.description = description
|
||||
if data.get("name") is not None:
|
||||
album.name = data["name"]
|
||||
if data.get("description") is not None:
|
||||
album.description = data["description"]
|
||||
if data.get("cover_id") is not None:
|
||||
cover_id = data["cover_id"] or None
|
||||
if cover_id:
|
||||
@@ -3059,7 +2976,8 @@ def setup_gallery_routes() -> APIRouter:
|
||||
@router.post("/api/gallery/albums/{album_id}/add")
|
||||
async def add_to_album(request: Request, album_id: str):
|
||||
user = get_current_user(request)
|
||||
ids = await _image_ids(request)
|
||||
data = await request.json()
|
||||
ids = data.get("image_ids", [])
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_get_or_404_album(db, album_id, user)
|
||||
@@ -3076,7 +2994,8 @@ def setup_gallery_routes() -> APIRouter:
|
||||
@router.post("/api/gallery/albums/{album_id}/remove")
|
||||
async def remove_from_album(request: Request, album_id: str):
|
||||
user = get_current_user(request)
|
||||
ids = await _image_ids(request)
|
||||
data = await request.json()
|
||||
ids = data.get("image_ids", [])
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_get_or_404_album(db, album_id, user)
|
||||
|
||||
@@ -471,16 +471,10 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
role = body.get("role", "assistant")
|
||||
content = body.get("content", "")
|
||||
if role not in ("user", "assistant"):
|
||||
raise HTTPException(400, "role must be 'user' or 'assistant'")
|
||||
if not content:
|
||||
raise HTTPException(400, "content is required")
|
||||
if not isinstance(content, str):
|
||||
raise HTTPException(400, "content must be a string")
|
||||
metadata = sanitize_client_message_metadata(body.get("metadata"))
|
||||
_reserve_message_uploads(request, content, metadata)
|
||||
msg = ChatMessage(role=role, content=content, metadata=metadata)
|
||||
@@ -831,23 +825,6 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
try:
|
||||
body = await request.json()
|
||||
keep_count = body.get("keep_count", 0)
|
||||
# A message id is exact; keep_count from the client counts only the
|
||||
# messages loaded in the page. before_msg_id forks before that
|
||||
# message, through_msg_id includes it.
|
||||
before_msg_id = str(body.get("before_msg_id") or "").strip()
|
||||
through_msg_id = str(body.get("through_msg_id") or "").strip()
|
||||
if before_msg_id or through_msg_id:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
all_db_messages = db.query(DbChatMessage).filter(
|
||||
DbChatMessage.session_id == session_id
|
||||
).order_by(DbChatMessage.timestamp).all()
|
||||
finally:
|
||||
db.close()
|
||||
resolved = _keep_count_before_message(all_db_messages, before_msg_id or through_msg_id)
|
||||
if resolved is None:
|
||||
raise HTTPException(404, "Message not found")
|
||||
keep_count = resolved + (0 if before_msg_id else 1)
|
||||
|
||||
# Get the source session. keep_count indexes into source.history,
|
||||
# so this must go through get_session — reading the cache directly
|
||||
|
||||
+5
-31
@@ -418,7 +418,6 @@ _HOST_TO_CURATED = (
|
||||
("nvidia.com", "nvidia"),
|
||||
("openrouter.ai", "openrouter"),
|
||||
("ollama.com", "ollama"),
|
||||
("conifer.build", "openrouter"),
|
||||
)
|
||||
|
||||
|
||||
@@ -660,7 +659,7 @@ _NON_CHAT_CONTAINS = (
|
||||
"gliner", "riva", "-parse", "-embedqa", "-nemoretriever",
|
||||
"topic-control", "calibration",
|
||||
"ai-synthetic-video", "cosmos-reason2",
|
||||
"bge", "llama-guard", "-embed", "rerank",
|
||||
"bge", "llama-guard",
|
||||
)
|
||||
_NON_CHAT_EXACT_PREFIXES = (
|
||||
"gpt-audio", # gpt-audio, gpt-audio-mini etc. (not gpt-4o-audio-preview which is chat)
|
||||
@@ -2847,12 +2846,11 @@ def setup_model_routes(model_discovery):
|
||||
return {"endpoint_id": "", "endpoint_url": "", "model": ""}
|
||||
base = _normalize_base(ep.base_url)
|
||||
chat_url = build_chat_url(base)
|
||||
if model and not _is_chat_model(model):
|
||||
model = ""
|
||||
if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)):
|
||||
try:
|
||||
visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None))
|
||||
model = next((mid for mid in visible if _is_chat_model(mid)), "")
|
||||
if visible:
|
||||
model = visible[0]
|
||||
except Exception:
|
||||
pass
|
||||
return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model}
|
||||
@@ -3045,44 +3043,20 @@ def setup_model_routes(model_discovery):
|
||||
return {"dependents": _settings_using_endpoint(ep_id)}
|
||||
|
||||
@router.delete("/model-endpoints/{ep_id}")
|
||||
def delete_model_endpoint(
|
||||
ep_id: str,
|
||||
request: Request,
|
||||
expected_cookbook_serve_session_id: Optional[str] = None,
|
||||
):
|
||||
def delete_model_endpoint(ep_id: str, request: Request):
|
||||
require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
||||
if not ep or not _chatgpt_endpoint_visible(ep, request):
|
||||
raise HTTPException(404, "Endpoint not found")
|
||||
if expected_cookbook_serve_session_id is not None:
|
||||
# The DELETE itself checks ownership. An old serve must never
|
||||
# mutate the settings of an endpoint reused by another serve.
|
||||
if (
|
||||
not ep_id.startswith(("local-", "img-"))
|
||||
or not expected_cookbook_serve_session_id
|
||||
):
|
||||
raise HTTPException(409, "Cookbook endpoint ownership mismatch")
|
||||
deleted = (
|
||||
db.query(ModelEndpoint)
|
||||
.filter(
|
||||
ModelEndpoint.id == ep_id,
|
||||
ModelEndpoint.cookbook_serve_session_id
|
||||
== expected_cookbook_serve_session_id,
|
||||
)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(409, "Cookbook endpoint ownership changed")
|
||||
# Clean up any settings that reference this endpoint
|
||||
cleared = _clear_settings_for_endpoint(ep_id)
|
||||
cleared_user_preferences = _clear_user_prefs_for_endpoint(ep_id)
|
||||
cleared_sessions = _clear_sessions_for_endpoint(db, ep.base_url, ep.id, ep.owner)
|
||||
cleared_loaded_sessions = _clear_loaded_sessions_for_endpoint(ep.base_url, ep.id, ep.owner)
|
||||
auth_id = getattr(ep, "provider_auth_id", None)
|
||||
if expected_cookbook_serve_session_id is None:
|
||||
db.delete(ep)
|
||||
db.delete(ep)
|
||||
cleared_provider_auth = _delete_orphaned_provider_auth(db, auth_id, exclude_ep_id=ep_id)
|
||||
db.commit()
|
||||
_invalidate_models_cache()
|
||||
|
||||
@@ -118,12 +118,8 @@ def setup_prefs_routes():
|
||||
async def set_pref(request: Request, key: str, body: dict):
|
||||
user = get_current_user(request)
|
||||
prefs = _load_for_user(user)
|
||||
val = body.get("value")
|
||||
if val is None:
|
||||
prefs.pop(key, None)
|
||||
else:
|
||||
prefs[key] = val
|
||||
prefs[key] = body.get("value")
|
||||
_save_for_user(user, prefs)
|
||||
return {"key": key, "value": prefs.get(key)}
|
||||
return {"key": key, "value": prefs[key]}
|
||||
|
||||
return router
|
||||
|
||||
@@ -183,7 +183,6 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
|
||||
OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty.
|
||||
"""
|
||||
from src.endpoint_resolver import (
|
||||
_endpoint_enabled_models,
|
||||
build_chat_url,
|
||||
build_headers,
|
||||
resolve_endpoint_runtime as resolve_model_endpoint_runtime,
|
||||
@@ -198,7 +197,7 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
|
||||
ep_model = (model or "").strip()
|
||||
if not ep_model:
|
||||
try:
|
||||
models = _endpoint_enabled_models(ep)
|
||||
models = json.loads(ep.cached_models) if ep.cached_models else []
|
||||
if models:
|
||||
ep_model = _first_chat_model(models)
|
||||
except Exception:
|
||||
@@ -825,13 +824,23 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
if not ep_url or not ep_model:
|
||||
# Last resort: this user's enabled endpoint, plus legacy shared rows.
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = _owned_enabled_endpoint(db, user)
|
||||
if ep:
|
||||
resolved = _resolve_endpoint_runtime(ep, owner=user)
|
||||
if resolved:
|
||||
_merge(*resolved)
|
||||
base = normalize_base(ep.base_url)
|
||||
fallback_url = build_chat_url(base)
|
||||
fallback_headers = build_headers(ep.api_key, base)
|
||||
fallback_model = ""
|
||||
if ep.cached_models:
|
||||
try:
|
||||
models = json.loads(ep.cached_models)
|
||||
if models:
|
||||
fallback_model = _first_chat_model(models)
|
||||
except Exception:
|
||||
pass
|
||||
_merge(fallback_url, fallback_model, fallback_headers)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.memory.skill_format import slugify
|
||||
from services.memory.skills import SkillsManager
|
||||
from src.auth_helpers import get_current_user
|
||||
from services.skills import evaluation as _evaluation
|
||||
@@ -837,10 +836,6 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
||||
updates = body.dict(exclude_none=True)
|
||||
if not updates:
|
||||
return {"ok": True}
|
||||
if "name" in updates:
|
||||
new_name = slugify(updates["name"] or match.get("name"))
|
||||
if new_name != match.get("name") and skills_manager.has_skill(new_name, owner=user):
|
||||
raise HTTPException(409, f"A skill named '{new_name}' already exists")
|
||||
ok = skills_manager.update_skill(match.get("name"), updates, owner=user)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
|
||||
@@ -289,15 +289,15 @@ def _resolve_run_endpoint(db, task: ScheduledTask, run: TaskRun) -> str:
|
||||
|
||||
try:
|
||||
from core.database import ModelEndpoint
|
||||
from src.auth_helpers import owner_filter
|
||||
from src.endpoint_resolver import _endpoint_enabled_models
|
||||
|
||||
eps = owner_filter(
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
|
||||
ModelEndpoint, getattr(task, "owner", None),
|
||||
).all()
|
||||
eps = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
||||
for ep in eps:
|
||||
if model in _endpoint_enabled_models(ep):
|
||||
cached = []
|
||||
if ep.cached_models:
|
||||
try:
|
||||
cached = json.loads(ep.cached_models) or []
|
||||
except Exception:
|
||||
cached = []
|
||||
if model in cached:
|
||||
return ep.base_url or ""
|
||||
except Exception:
|
||||
pass
|
||||
@@ -340,7 +340,6 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
max_tokens=20,
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
thinking_mode="off",
|
||||
)
|
||||
title = result.strip().strip('"\'').strip()
|
||||
return title[:60] if title else prompt[:50].strip()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -492,25 +492,6 @@ VARIABLE_NOTES: dict[str, tuple[str, str, str]] = {
|
||||
"tools that execute code, mutate state or cause external side effects need "
|
||||
"a separate approval. Set to 0 to opt out.",
|
||||
),
|
||||
"ODYSSEUS_LOCAL_MCP_READ_TOOLS": (
|
||||
"Agent loop and tool execution", USER,
|
||||
"Security-relevant. Comma-separated exact MCP read-tool names "
|
||||
"on configured stdio MCP servers. Only stdio servers qualify. "
|
||||
"Declarations do not override built-in tool effects. Output remains "
|
||||
"untrusted to prevent privilege escalation, but allows continuation to "
|
||||
"declared write tools on the same stdio server without external taint. "
|
||||
"Stdio transport does not guarantee network isolation. Automatic "
|
||||
"same-store writes carry residual prompt-injection risk.",
|
||||
),
|
||||
"ODYSSEUS_LOCAL_MCP_WRITE_TOOLS": (
|
||||
"Agent loop and tool execution", USER,
|
||||
"Security-relevant. Comma-separated exact MCP write-tool names "
|
||||
"on configured stdio MCP servers. Only stdio servers qualify. "
|
||||
"Declarations do not override built-in tool effects. Allowed to execute "
|
||||
"after declared local reads on the same stdio server, but blocked once "
|
||||
"external untrusted context enters the run. Authorization must still "
|
||||
"originate from the trusted request grant.",
|
||||
),
|
||||
"ODYSSEUS_MCP_ALLOWED_COMMANDS": (
|
||||
"Agent loop and tool execution", USER,
|
||||
"Security-relevant. Comma-separated allowlist of MCP launcher basenames the "
|
||||
|
||||
+4
-12
@@ -27,7 +27,6 @@ from pathlib import Path
|
||||
|
||||
try:
|
||||
from core.database import SessionLocal, Document, DocumentVersion
|
||||
from src.session_search import escape_like_pattern
|
||||
quiet_logs()
|
||||
except ModuleNotFoundError as e:
|
||||
sys.stderr.write(f"error: {e}\nhint: run from repo root with venv active.\n")
|
||||
@@ -140,17 +139,10 @@ def cmd_export(args):
|
||||
def cmd_search(args):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Per-term AND search: a single `%foo bar%` LIKE only matched the
|
||||
# exact phrase, so "machine learning" missed docs containing both
|
||||
# words apart. LIKE wildcards are escaped per token.
|
||||
q = db.query(Document)
|
||||
for tok in args.query.split():
|
||||
term = f"%{escape_like_pattern(tok)}%"
|
||||
q = q.filter(
|
||||
Document.title.ilike(term, escape="\\")
|
||||
| Document.current_content.ilike(term, escape="\\")
|
||||
)
|
||||
rows = q.order_by(Document.updated_at.desc()).limit(args.limit).all()
|
||||
like = f"%{args.query}%"
|
||||
rows = db.query(Document).filter(
|
||||
(Document.title.ilike(like)) | (Document.current_content.ilike(like))
|
||||
).order_by(Document.updated_at.desc()).limit(args.limit).all()
|
||||
emit([_serialize(d) for d in rows], args)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -77,13 +77,14 @@ def sanitize_body_text(text: str) -> str:
|
||||
|
||||
# 1. Try JSON
|
||||
trimmed = text.strip()
|
||||
if (trimmed.startswith("{") and trimmed.endswith("}")) or (trimmed.startswith("[") and trimmed.endswith("]")):
|
||||
if trimmed.startswith(("{", "[")):
|
||||
try:
|
||||
parsed = json.loads(trimmed)
|
||||
sanitized = sanitize_structured_obj(parsed)
|
||||
return json.dumps(sanitized, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
if SENSITIVE_KEY_PATTERN.search(trimmed) or any(p in trimmed.lower() for p in SENSITIVE_PARAM_NAMES):
|
||||
raise ValueError(f"Malformed sensitive JSON request body: {exc}") from exc
|
||||
|
||||
# 2. Try URL-encoded form data
|
||||
if "=" in trimmed and not trimmed.startswith("<"):
|
||||
@@ -98,8 +99,13 @@ def sanitize_body_text(text: str) -> str:
|
||||
else:
|
||||
out_pairs.append((k, v))
|
||||
return urllib.parse.urlencode(out_pairs)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
if SENSITIVE_KEY_PATTERN.search(trimmed):
|
||||
raise ValueError(f"Malformed sensitive form request body: {exc}") from exc
|
||||
|
||||
# If unparsed body text contains sensitive keys or tokens, fail closed
|
||||
if SENSITIVE_KEY_PATTERN.search(trimmed) or any(p in trimmed.lower() for p in SENSITIVE_PARAM_NAMES):
|
||||
raise ValueError("Malformed or unsupported sensitive request body in trace entry")
|
||||
|
||||
return text
|
||||
|
||||
@@ -207,7 +213,11 @@ def sanitize_structured_obj(obj: Any) -> Any:
|
||||
|
||||
|
||||
def sanitize_jsonl_content(data: bytes) -> bytes:
|
||||
"""Sanitize newline-delimited JSON Lines (.trace, .network, .stacks)."""
|
||||
"""Sanitize newline-delimited JSON Lines (.trace, .network, .stacks).
|
||||
|
||||
If any non-empty record cannot be decoded as valid UTF-8 or parsed as valid JSON,
|
||||
or is malformed, raise ValueError fail-closed so the affected archive is permanently removed.
|
||||
"""
|
||||
lines = data.split(b"\n")
|
||||
out_lines = []
|
||||
for line in lines:
|
||||
@@ -216,10 +226,8 @@ def sanitize_jsonl_content(data: bytes) -> bytes:
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(line.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
# Not valid JSON, keep as-is
|
||||
out_lines.append(line)
|
||||
continue
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as err:
|
||||
raise ValueError(f"Malformed or unsupported JSONL trace record: {err}")
|
||||
sanitized = sanitize_structured_obj(parsed)
|
||||
out_lines.append(json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
|
||||
return b"\n".join(out_lines)
|
||||
@@ -275,11 +283,10 @@ def sanitize_trace_archive(zp: str) -> bool:
|
||||
elif filename_lower.endswith((".json", ".har")):
|
||||
try:
|
||||
parsed = json.loads(content.decode("utf-8"))
|
||||
sanitized = sanitize_structured_obj(parsed)
|
||||
zout.writestr(item, json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
|
||||
except Exception:
|
||||
# If invalid JSON, treat as text line-by-line
|
||||
zout.writestr(item, sanitize_jsonl_content(content))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
raise ValueError(f"Malformed JSON in archive member {item.filename}: {exc}") from exc
|
||||
sanitized = sanitize_structured_obj(parsed)
|
||||
zout.writestr(item, json.dumps(sanitized, ensure_ascii=False).encode("utf-8"))
|
||||
else:
|
||||
# Unknown binary or text member: exclude to fail closed
|
||||
continue
|
||||
|
||||
@@ -660,8 +660,6 @@ def _detect_windows():
|
||||
"gpu_vram_gb": d.get("gpu_vram_gb"),
|
||||
"gpu_count": _as_int(d.get("gpu_count"), 0),
|
||||
"backend": d.get("gpu_backend", "cpu_x86"),
|
||||
"gpus": [],
|
||||
"gpu_groups": [],
|
||||
"homogeneous": True,
|
||||
"gpu_error": None,
|
||||
"platform": "windows",
|
||||
@@ -824,14 +822,12 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
if result:
|
||||
result = _attach_probe_context(result, host=host)
|
||||
_remote_host = None
|
||||
_remote_port = None
|
||||
_remote_platform = None
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
return result
|
||||
# SSH may work while the PowerShell hardware probe still fails.
|
||||
result = {"error": f"Windows hardware probe failed for {host}", "host": host}
|
||||
_remote_host = None
|
||||
_remote_port = None
|
||||
_remote_platform = None
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
return result
|
||||
@@ -856,7 +852,6 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
result = {"error": f"Cannot connect to {host}", "host": host}
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
_remote_host = None
|
||||
_remote_port = None
|
||||
_remote_platform = None
|
||||
return result
|
||||
available_ram = round(_get_available_ram_gb(), 1)
|
||||
@@ -898,9 +893,6 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
"gpu_name": None,
|
||||
"gpu_vram_gb": None,
|
||||
"gpu_count": 0,
|
||||
"gpus": [],
|
||||
"gpu_groups": [],
|
||||
"homogeneous": True,
|
||||
"backend": backend,
|
||||
# Set when nvidia-smi exists but failed (e.g. driver/library
|
||||
# version mismatch) — lets the UI say "GPU driver error" instead
|
||||
@@ -910,7 +902,6 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
|
||||
result = _attach_probe_context(result, host=host)
|
||||
_remote_host = None
|
||||
_remote_port = None
|
||||
_remote_platform = None
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
return result
|
||||
|
||||
@@ -130,15 +130,10 @@ def _parse_scalar(raw: str, *, string_only: bool = False) -> Any:
|
||||
def _split_top_level(s: str, sep: str) -> List[str]:
|
||||
"""Split `s` on `sep` ignoring separators inside [] or quotes."""
|
||||
out, buf, depth, quote = [], [], 0, None
|
||||
escaped = False
|
||||
for ch in s:
|
||||
if quote:
|
||||
buf.append(ch)
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif ch == "\\" and quote == '"':
|
||||
escaped = True
|
||||
elif ch == quote:
|
||||
if ch == quote:
|
||||
quote = None
|
||||
continue
|
||||
if ch in ("'", '"'):
|
||||
|
||||
@@ -500,19 +500,6 @@ class SkillsManager:
|
||||
sk.path = self._skill_file(cat, nm)
|
||||
return sk.to_dict()
|
||||
|
||||
def has_skill(self, name: str, owner: Optional[str] = None) -> bool:
|
||||
"""Whether `owner` already has a skill called `name`, in any category.
|
||||
|
||||
Update and delete find a skill by name within its owner, so two of
|
||||
one owner's skills sharing a name would make them act on whichever
|
||||
file is walked first.
|
||||
"""
|
||||
for path in self._iter_skill_files():
|
||||
sk = self._read_skill(path)
|
||||
if sk and sk.name == name and (sk.owner or "") == (owner or ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_skill(self, skill_id: str, updates: Dict, owner: Optional[str] = None) -> bool:
|
||||
"""`skill_id` is the slug name. Allows updating any field plus
|
||||
renames if `name` changes (file is moved on disk).
|
||||
@@ -564,9 +551,6 @@ class SkillsManager:
|
||||
# Rename
|
||||
new_name = slugify(updates.get("name") or sk.name)
|
||||
if new_name != sk.name:
|
||||
if self.has_skill(new_name, owner=owner):
|
||||
logger.warning(f"Skill rename target name in use: {new_name}")
|
||||
return False
|
||||
sk.name = new_name
|
||||
|
||||
# Write to potentially new path
|
||||
|
||||
+1
-16
@@ -64,21 +64,6 @@ _SHELL_COMMAND = (
|
||||
r"nvcc|docker|systemctl|journalctl|tmux|git)"
|
||||
)
|
||||
_BENCHMARK_COMMAND = r"(?:[a-z][a-z0-9_-]*bench(?:mark)?s?|bench(?:mark)?s?)"
|
||||
# "on <word>" and "from <word>" alone describe people, companies, and topics
|
||||
# ("Jensen Huang from NVIDIA", "a book on Rome") as often as hosts, so a target
|
||||
# only counts as a computer next to wording that describes work on a machine.
|
||||
_NAMED_COMPUTER = (
|
||||
r"(?!this\b|my\b|the\b|a\b|an\b|that\b|it\b|same\b|current\b)"
|
||||
r"[a-z][a-z0-9_.-]{1,31}\b"
|
||||
)
|
||||
NAMED_COMPUTER_TASK = (
|
||||
r"\b(?:disks?|uptime|processes|logs?|files?|folders?|director(?:y|ies)|running|installed|"
|
||||
r"(?:storage|memory|ram|vram|cpu|gpu)\s+(?:usage|space|utili[sz]ation|load|temp(?:erature)?))\b"
|
||||
rf"[^.?!\n]{{0,80}}\bon\s+{_NAMED_COMPUTER}"
|
||||
r"|\b(?:copy|fetch|get|grab|pull|download|scp|rsync|sync|read|show|list|tail|check)\b"
|
||||
r"[^.?!\n]{0,60}\b(?:files?|logs?|folders?|director(?:y|ies)|configs?|checkpoints?|backups?)\b"
|
||||
rf"[^.?!\n]{{0,40}}\bfrom\s+{_NAMED_COMPUTER}"
|
||||
)
|
||||
_CODE_ACTION = r"(?:write|create|add|edit|modify|code|program|implement|build)"
|
||||
_CODE_ARTIFACT = (
|
||||
r"(?:code|function|class|script|module|component|snippet|program|app|feature|file|"
|
||||
@@ -179,7 +164,7 @@ _ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple(
|
||||
("workspace", "file/code inspection request", rf"{_PLEASE}(?:find|inspect|look\s+at|open|read|check)\b.{{0,120}}\b(?:file|folder|directory|repo|repository|code|source|logs?|trace|stack|diff)\b"),
|
||||
("workspace", "server/process debugging request", rf"{_PLEASE}(?:check|debug|fix|restart|start|stop|kill|tail|inspect)\b.{{0,120}}\b(?:server|service|process|port|docker|container|tmux|endpoint|logs?)\b"),
|
||||
("workspace", "local computer task request", r"\b(?:on|from|in|using|with)\s+(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b|\b(?:local|host)\s+(?:computer|machine|files?|system)\b"),
|
||||
("workspace", "named computer task request", NAMED_COMPUTER_TASK),
|
||||
("workspace", "named computer task request", r"\b(?:on|from)\s+(?!this\b|my\b|the\b|a\b|an\b|that\b|it\b|same\b|current\b)(?:[a-z][a-z0-9_.-]{1,31})\b"),
|
||||
("workspace", "terminal workspace request", rf"\b(?:terminal|shell|workspace|tmux|docker|container|git|branch|commit|diff|pytest|stacktrace|traceback|{_BENCHMARK_COMMAND}|eval(?:uation)?s?)\b"),
|
||||
|
||||
# Shell / remote-host intent.
|
||||
|
||||
+16
-41
@@ -14,12 +14,12 @@ import csv
|
||||
import difflib
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import time
|
||||
import logging
|
||||
import hashlib
|
||||
from src.web_recovery import WebRecoveryBudget
|
||||
from itertools import count
|
||||
@@ -44,7 +44,6 @@ from src.model_profiles import (
|
||||
is_odysseus_merged_tools_model,
|
||||
tool_schema_profile,
|
||||
)
|
||||
from src.action_intents import NAMED_COMPUTER_TASK
|
||||
from src.agent_evidence import (
|
||||
EvidenceLedger,
|
||||
command_has_mutation_effect,
|
||||
@@ -8765,7 +8764,7 @@ _LOCAL_COMPUTER_REFERENCE_RE = re.compile(
|
||||
r"\b(?:on|from|in|using|with)\s+(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b"
|
||||
r"|\b(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b"
|
||||
r"|\b(?:local|host)\s+(?:computer|machine|files?|system)\b"
|
||||
rf"|{NAMED_COMPUTER_TASK}",
|
||||
r"|\b(?:on|from)\s+(?!this\b|my\b|the\b|a\b|an\b)(?:[a-z][a-z0-9_.-]{1,31})\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_LOCAL_NETWORK_REFERENCE_RE = re.compile(
|
||||
@@ -16995,20 +16994,6 @@ def _detect_runaway_call(call_freq, threshold=15):
|
||||
sig = next((s for s, n in call_freq.items() if n >= threshold), None)
|
||||
return sig.split(":", 1)[0] if sig else None
|
||||
|
||||
def _tool_approval_gate_bypassed(
|
||||
exact_approval: Optional[ExactToolApproval] = None,
|
||||
) -> bool:
|
||||
"""Return whether the post-external-context tool approval gate is bypassed.
|
||||
|
||||
``tool_approval_mode=off`` (admin setting, Settings → Agent Tools) bypasses
|
||||
the gate on every run, the same effect as ``ODYSSEUS_TOOL_APPROVAL_GATE=0``.
|
||||
A chat-session grant bypasses it only within the scope of its own task.
|
||||
"""
|
||||
return (
|
||||
get_setting("tool_approval_mode", "ask") == "off"
|
||||
or bool(exact_approval and exact_approval.allow_remaining_actions)
|
||||
)
|
||||
|
||||
|
||||
def _tool_result_signature(tool_result_records: list[dict]) -> str:
|
||||
"""Return a bounded fingerprint of the observable result of a tool batch.
|
||||
@@ -21452,7 +21437,9 @@ async def stream_agent_loop(
|
||||
)
|
||||
or messages_contain_external_untrusted_context(messages)
|
||||
),
|
||||
approval_gate_bypassed=_tool_approval_gate_bypassed(exact_approval),
|
||||
approval_gate_bypassed=bool(
|
||||
exact_approval and exact_approval.allow_remaining_actions
|
||||
),
|
||||
delegated_credential=bool(delegated_credential),
|
||||
)
|
||||
_has_tui_host_bridge = _tui_host_bridge_is_usable(client_runtime_context)
|
||||
@@ -24206,14 +24193,11 @@ async def stream_agent_loop(
|
||||
budget_is_explicit,
|
||||
hard_max=hard_max,
|
||||
)
|
||||
if 0 < candidate_context <= 8192:
|
||||
if candidate_context <= 8192:
|
||||
# Small local servers often tokenize chat wrappers and tool
|
||||
# results much more generously than our rough estimator. Keep
|
||||
# substantial headroom for those wrappers and generation so a
|
||||
# follow-up tool round cannot exceed the server's n_ctx.
|
||||
# 0 means the window is unknown, which already gets the
|
||||
# conservative default budget; clamping it as well cut every
|
||||
# request to 1200 tokens and dropped open documents (#6678).
|
||||
effective_budget = min(
|
||||
effective_budget,
|
||||
max(1200, int(candidate_context * 0.40)),
|
||||
@@ -27849,25 +27833,16 @@ async def stream_agent_loop(
|
||||
if _streamed_tool_markup or _streamed_tool_markup_starts(_delta_text):
|
||||
_streamed_tool_markup += _delta_text
|
||||
if not _streamed_tool_markup_complete(_streamed_tool_markup):
|
||||
if _streamed_tool_markup_starts(_streamed_tool_markup):
|
||||
continue
|
||||
# False alarm: a bare "<" (e.g. ``x < 2``) looked like
|
||||
# a tag prefix, but the buffer can no longer become a
|
||||
# tool tag. Release it as prose instead of holding the
|
||||
# rest of the answer forever.
|
||||
_delta_text = _streamed_tool_markup
|
||||
_streamed_tool_markup = ""
|
||||
data["delta"] = _delta_text
|
||||
else:
|
||||
_visible_markup_tail = strip_tool_blocks(
|
||||
_streamed_tool_markup,
|
||||
skip_fenced=True,
|
||||
).strip()
|
||||
_streamed_tool_markup = ""
|
||||
if not _visible_markup_tail:
|
||||
continue
|
||||
_delta_text = _visible_markup_tail
|
||||
data["delta"] = _delta_text
|
||||
continue
|
||||
_visible_markup_tail = strip_tool_blocks(
|
||||
_streamed_tool_markup,
|
||||
skip_fenced=True,
|
||||
).strip()
|
||||
_streamed_tool_markup = ""
|
||||
if not _visible_markup_tail:
|
||||
continue
|
||||
_delta_text = _visible_markup_tail
|
||||
data["delta"] = _delta_text
|
||||
full_response += _delta_text
|
||||
if not _suppress_unavailable_web_delta:
|
||||
_qwen_buffered_route = bool(
|
||||
|
||||
@@ -371,101 +371,6 @@ def interpret_request(request_text, *, history=(), workspace=None, active_docume
|
||||
return SemanticIntent(families, selected, required_read_operation_for_request(request_text, history))
|
||||
|
||||
|
||||
|
||||
def _trusted_local_mcp_request_tools(request_text: str, intent: SemanticIntent) -> set[str]:
|
||||
"""Seal local MCP operations from operator declarations and trusted request intent.
|
||||
|
||||
Discovery validates an operator-declared tool but cannot grant it. For the
|
||||
notes family, only an explicit request for local notes admits matching
|
||||
read operations and explicitly requested non-destructive write verbs. Other
|
||||
MCP domains require the user to name the qualified operation explicitly.
|
||||
"""
|
||||
from src.tool_capabilities import (
|
||||
LOCAL_MCP_READ_TOOLS_ENV, LOCAL_MCP_WRITE_TOOLS_ENV,
|
||||
_local_mcp_tools, _is_declared_local_mcp_read_tool,
|
||||
_is_declared_local_mcp_write_tool, capabilities_for_tool, ToolEffect,
|
||||
)
|
||||
from src.tool_utils import get_mcp_manager
|
||||
|
||||
manager = get_mcp_manager()
|
||||
if manager is None:
|
||||
return set()
|
||||
|
||||
request = request_text.casefold()
|
||||
local_notes = (
|
||||
intent.selected_tools is None
|
||||
and intent.required_read is None
|
||||
and "notes" in intent.capabilities
|
||||
and re.search(r"\b(?:local|on my computer|on this machine)\b", request)
|
||||
)
|
||||
write_verbs = {
|
||||
"create": r"\b(?:create|add|make)\b",
|
||||
"add": r"\b(?:add|create)\b",
|
||||
"write": r"\bwrite\b",
|
||||
"save": r"\bsave\b",
|
||||
"update": r"\b(?:update|edit|modify|change)\b",
|
||||
"edit": r"\b(?:edit|update|modify|change)\b",
|
||||
"append": r"\bappend\b",
|
||||
"modify": r"\b(?:modify|edit|update|change)\b",
|
||||
}
|
||||
negative_write = re.search(
|
||||
r"\b(?:do not|don't|never|without|no)\s+(?:\w+\s+){0,2}"
|
||||
r"(?:create|add|make|write|save|update|edit|append|modify)\b",
|
||||
request,
|
||||
)
|
||||
declared = _local_mcp_tools(LOCAL_MCP_READ_TOOLS_ENV) | _local_mcp_tools(LOCAL_MCP_WRITE_TOOLS_ENV)
|
||||
admitted = set()
|
||||
for name in sorted(declared):
|
||||
parts = name.split("__", 2)
|
||||
if len(parts) != 3 or parts[0] != "mcp":
|
||||
continue
|
||||
_, server, bare = parts
|
||||
if manager.is_builtin(server):
|
||||
continue
|
||||
conn = manager.get_server_status(server)
|
||||
if conn.get("status") != "connected" or conn.get("transport") != "stdio":
|
||||
continue
|
||||
resource = manager.resource_identity(name)
|
||||
if resource is None or resource.namespace != "mcp" or resource.endpoint_id != f"stdio:{server}":
|
||||
continue
|
||||
rows = manager._tools.get(server, [])
|
||||
row = next((item for item in rows if item.get("name") == bare), None)
|
||||
if row is None:
|
||||
continue
|
||||
read_ok = _is_declared_local_mcp_read_tool(name)
|
||||
write_ok = _is_declared_local_mcp_write_tool(name)
|
||||
if not (read_ok or write_ok):
|
||||
continue
|
||||
caps = capabilities_for_tool(name)
|
||||
if not caps.known or caps.effects != frozenset({
|
||||
ToolEffect.READ_PRIVATE if read_ok else ToolEffect.WRITE_PRIVATE
|
||||
}):
|
||||
continue
|
||||
ann = row.get("annotations") or {}
|
||||
destructive = ann.get("destructiveHint") if isinstance(ann, dict) else getattr(ann, "destructiveHint", None)
|
||||
if destructive is True:
|
||||
continue
|
||||
if write_ok and re.match(r"^(?:delete|remove|erase|destroy|drop|purge)(?:_|$)", bare, re.I):
|
||||
continue
|
||||
|
||||
# Exact qualified names in the trusted request permit any configured
|
||||
# and validated local operation. Model/tool content is not an input.
|
||||
explicitly_named = bool(re.search(
|
||||
r"(?<![A-Za-z0-9_])" + re.escape(name.casefold()) + r"(?![A-Za-z0-9_])",
|
||||
request,
|
||||
))
|
||||
if not explicitly_named:
|
||||
if not local_notes or not re.search(r"(?:^|_)notes?(?:_|$)", bare, re.I):
|
||||
continue
|
||||
if write_ok:
|
||||
action = bare.split("_", 1)[0].casefold()
|
||||
if action not in write_verbs or not re.search(write_verbs[action], request):
|
||||
continue
|
||||
if negative_write:
|
||||
continue
|
||||
admitted.add(name)
|
||||
return admitted
|
||||
|
||||
def create_request_authority(request_text, *, owner=None, session_id=None, workspace=None,
|
||||
history=(), policy=None, active_document=False,
|
||||
image_attachment=False, capabilities=None, client_runtime_context=None):
|
||||
@@ -488,7 +393,6 @@ def create_request_authority(request_text, *, owner=None, session_id=None, works
|
||||
tools = {canonical_tool(operation.tool)}
|
||||
else:
|
||||
operation = None
|
||||
tools.update(_trusted_local_mcp_request_tools(request_text, intent))
|
||||
grants = []
|
||||
for name in sorted(tools | {"ask_user", "update_plan"}):
|
||||
name = canonical_tool(name)
|
||||
|
||||
@@ -47,7 +47,6 @@ EFFECTS_DIR = os.path.join(DATA_DIR, "effects")
|
||||
_RUN_ID = re.compile(r"[a-f0-9]{32}")
|
||||
_TYPES = {"claim": EffectClaim, "outcome": EffectOutcome, "observation": Observation}
|
||||
_VERSION = 1
|
||||
_O_BINARY = getattr(os, "O_BINARY", 0) # Windows: raw os.open() is text-mode without it
|
||||
|
||||
|
||||
def _fsync_directory(directory: str | os.PathLike) -> None:
|
||||
@@ -244,7 +243,7 @@ class EffectLog:
|
||||
"""Hold the file exclusively with every durable record merged."""
|
||||
assert self.path is not None
|
||||
_ensure_directory(self.path.parent)
|
||||
flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY
|
||||
flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
|
||||
descriptor = os.open(self.path, flags, 0o600)
|
||||
try:
|
||||
if fcntl is not None:
|
||||
@@ -359,7 +358,7 @@ class EffectLog:
|
||||
with self._lock:
|
||||
try:
|
||||
descriptor = os.open(self.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
| getattr(os, "O_CLOEXEC", 0) | _O_BINARY)
|
||||
| getattr(os, "O_CLOEXEC", 0))
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError:
|
||||
@@ -395,7 +394,7 @@ class EffectLog:
|
||||
log = cls(run_id, directory=directory)
|
||||
assert log.path is not None
|
||||
try:
|
||||
descriptor = os.open(log.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY)
|
||||
descriptor = os.open(log.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0))
|
||||
except FileNotFoundError:
|
||||
return log
|
||||
except OSError as error:
|
||||
@@ -461,7 +460,7 @@ class EffectLog:
|
||||
temporary = target.with_suffix(".tmp")
|
||||
data = json.dumps({"run_id": self.run_id, "effect_id": effect_id}, sort_keys=True).encode()
|
||||
_ensure_directory(target.parent)
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
|
||||
descriptor = os.open(temporary, flags, 0o600)
|
||||
try:
|
||||
view = memoryview(data)
|
||||
@@ -479,7 +478,7 @@ class EffectLog:
|
||||
return None
|
||||
base = Path(directory) if directory is not None else effects_dir()
|
||||
try:
|
||||
descriptor = os.open(base / f"launch-{generation}.json", os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | _O_BINARY)
|
||||
descriptor = os.open(base / f"launch-{generation}.json", os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||
with os.fdopen(descriptor, "rb") as stream:
|
||||
if os.fstat(stream.fileno()).st_nlink != 1:
|
||||
return None
|
||||
|
||||
@@ -4,7 +4,7 @@ import html
|
||||
import difflib
|
||||
import logging
|
||||
import re
|
||||
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS
|
||||
from src.constants import MAX_READ_CHARS
|
||||
from src.tool_approvals import document_content_digest
|
||||
from src.tool_utils import _parse_tool_args, get_upload_handler
|
||||
from src.upload_handler import reserve_upload_references
|
||||
@@ -1175,23 +1175,14 @@ class ManageDocumentTool:
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
offset = min(offset, len(body))
|
||||
anchor = f"[{doc.title}](#document-{doc.id})"
|
||||
header = f"{anchor} — click to open in editor.\n\n```{doc.language or ''}\n"
|
||||
# Reserve the closing fence and the longest possible paging
|
||||
# notice before selecting the page. The common output cap must
|
||||
# never hide content that next_offset claims was delivered.
|
||||
notice = f"\n... (truncated, {len(body)} chars total; next_offset={len(body)})"
|
||||
page_budget = MAX_OUTPUT_CHARS - len(header) - len(notice) - len("\n```")
|
||||
if page_budget < 1:
|
||||
return {"error": "Document title or language is too long to display a read page", "exit_code": 1}
|
||||
preview_limit = min(preview_limit, page_budget)
|
||||
end = min(offset + preview_limit, len(body))
|
||||
truncated = end < len(body)
|
||||
preview = body[offset:end]
|
||||
if truncated:
|
||||
preview += f"\n... (truncated, {len(body)} chars total; next_offset={end})"
|
||||
anchor = f"[{doc.title}](#document-{doc.id})"
|
||||
return {
|
||||
"response": f"{header}{preview}\n```",
|
||||
"response": f"{anchor} — click to open in editor.\n\n```{doc.language or ''}\n{preview}\n```",
|
||||
"document": {
|
||||
"id": doc.id,
|
||||
"title": doc.title,
|
||||
|
||||
@@ -191,7 +191,7 @@ def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[
|
||||
db.close()
|
||||
|
||||
|
||||
async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None, native_size: bool = False) -> bool:
|
||||
async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None) -> bool:
|
||||
params: Dict[str, Any] = {
|
||||
"dataUrl": data_url,
|
||||
"name": name,
|
||||
@@ -199,8 +199,6 @@ async def _place_layer(data_url: str, name: str, owner: Optional[str], session:
|
||||
"blend": blend or "normal",
|
||||
"opacity": opacity,
|
||||
}
|
||||
if native_size:
|
||||
params["nativeSize"] = True
|
||||
# Forward the edit mode so the client can build a difference mask for
|
||||
# instruction-mode results: the whole image was regenerated under an
|
||||
# instruction prompt, so untouched pixels must stay pixel-identical (the
|
||||
@@ -508,8 +506,6 @@ class AiEditImageTool:
|
||||
}
|
||||
_t["image"] = time.perf_counter()
|
||||
payload: Dict[str, Any] = {"image": image}
|
||||
if args.get("_use_image_settings") is True:
|
||||
payload["_use_image_settings"] = True
|
||||
model_choice = str(args.get("model") or "").strip()
|
||||
mask_used = False
|
||||
place_mode: Optional[str] = None
|
||||
@@ -613,11 +609,10 @@ class AiEditImageTool:
|
||||
f"AI {op}",
|
||||
owner,
|
||||
session,
|
||||
masked=mask_used and data.get("full_frame") is not True,
|
||||
masked=mask_used,
|
||||
blend=str(args.get("blend") or "normal"),
|
||||
opacity=float(args.get("opacity") or 100),
|
||||
mode=place_mode,
|
||||
native_size=data.get("full_frame") is True,
|
||||
)
|
||||
info["placed"] = placed
|
||||
if placed:
|
||||
@@ -692,7 +687,6 @@ class GenerateImageLayerTool:
|
||||
str(args.get("name") or "Generated image"),
|
||||
owner,
|
||||
session,
|
||||
native_size=True,
|
||||
masked=bool(args.get("mask")),
|
||||
blend=str(args.get("blend") or "normal"),
|
||||
opacity=float(args.get("opacity") or 100),
|
||||
|
||||
+17
-130
@@ -179,13 +179,8 @@ def _resolve_model(spec: str, owner: Optional[str] = None, model_type: Optional[
|
||||
# served internal id. Include pinned/cached ids in the match set
|
||||
# so chat sessions using the HF repo id still resolve. Do not use
|
||||
# stale cached aliases when the endpoint itself is unreachable.
|
||||
if endpoint_reachable:
|
||||
# Gateway image models may live in a dedicated catalogue,
|
||||
# so explicitly pinned IDs are valid on mixed endpoints too.
|
||||
extras = _json_list(getattr(ep, "pinned_models", None))
|
||||
if model_type == "image":
|
||||
extras += _json_list(getattr(ep, "cached_models", None))
|
||||
for extra in extras:
|
||||
if model_type == "image" and endpoint_reachable:
|
||||
for extra in _json_list(getattr(ep, "pinned_models", None)) + _json_list(getattr(ep, "cached_models", None)):
|
||||
if extra not in model_ids:
|
||||
model_ids.append(extra)
|
||||
|
||||
@@ -1181,9 +1176,9 @@ def image_editing_provider_available(owner: Optional[str] = None) -> bool:
|
||||
request on this so it never falls back to deterministic approximations.
|
||||
"""
|
||||
try:
|
||||
from src.settings import get_user_setting, load_settings
|
||||
from src.settings import load_settings
|
||||
|
||||
if not load_settings().get("image_gen_enabled", True) or not get_user_setting("image_gen_enabled", owner=owner or "", default=True):
|
||||
if not load_settings().get("image_gen_enabled", True):
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1205,39 +1200,6 @@ def image_editing_provider_available(owner: Optional[str] = None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _uses_openrouter_chat_images(model_id: str) -> bool:
|
||||
# GPT-5 Image is an LLM wrapper, unlike native gpt-image-* image models.
|
||||
leaf = model_id_leaf(model_id).lower()
|
||||
return leaf.startswith("gpt-") and "-image" in leaf and not leaf.startswith("gpt-image-")
|
||||
|
||||
|
||||
def _chat_image_results(data: dict) -> list:
|
||||
images = []
|
||||
for choice in data.get("choices") or []:
|
||||
message = choice.get("message") or {}
|
||||
for item in message.get("images") or []:
|
||||
url = (item.get("image_url") or {}).get("url")
|
||||
if not isinstance(url, str):
|
||||
continue
|
||||
if url.startswith("data:image/") and ";base64," in url:
|
||||
images.append({"b64_json": url.split(",", 1)[1]})
|
||||
elif url.startswith(("https://", "http://")):
|
||||
images.append({"url": url})
|
||||
return images
|
||||
|
||||
|
||||
def _image_response_error(data: dict, model_id: str) -> Optional[str]:
|
||||
errors = [data.get("error")]
|
||||
errors.extend(choice.get("error") for choice in data.get("choices") or [])
|
||||
for error in errors:
|
||||
if not error:
|
||||
continue
|
||||
message = str(error.get("message") or "Image provider failed") if isinstance(error, dict) else str(error)
|
||||
hint = " Choose a currently supported image model in AI settings." if "deprecated" in message.lower() else ""
|
||||
return f"Image provider failed for '{model_id}': {message}{hint}"
|
||||
return None
|
||||
|
||||
|
||||
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None, *, incognito: bool = False) -> Dict:
|
||||
"""Generate an image using an image-capable model (e.g. gpt-image-1).
|
||||
|
||||
@@ -1274,27 +1236,21 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
if not prompt:
|
||||
return {"error": "Image prompt is required (line 1)"}
|
||||
|
||||
# Load settings for defaults
|
||||
# Load admin settings for defaults
|
||||
try:
|
||||
from src.settings import get_user_setting, load_settings
|
||||
from src.settings import load_settings
|
||||
_settings = load_settings()
|
||||
except Exception:
|
||||
_settings = {}
|
||||
def get_user_setting(key, owner="", default=None):
|
||||
return default
|
||||
|
||||
# The administrator's global image-generation disable switch is authoritative.
|
||||
# Non-admin preferences cannot re-enable generation when disabled globally.
|
||||
if not _settings.get("image_gen_enabled", True):
|
||||
return {"error": "Image generation is disabled by the administrator."}
|
||||
if not get_user_setting("image_gen_enabled", owner=owner or "", default=True):
|
||||
return {"error": "Image generation is disabled by user preference."}
|
||||
|
||||
# Use user- or admin-configured model/quality if not specified by the tool call
|
||||
# Use admin-configured model/quality if not specified by the tool call
|
||||
if not model_spec:
|
||||
model_spec = get_user_setting("image_model", owner=owner, default="") or ""
|
||||
model_spec = _settings.get("image_model", "")
|
||||
if not quality:
|
||||
quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
|
||||
quality = _settings.get("image_quality") or "medium"
|
||||
|
||||
# Auto-detect best available image model if still not set
|
||||
if not model_spec:
|
||||
@@ -1386,19 +1342,8 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
|
||||
images_url = base_url + "/images/generations"
|
||||
from src.model_capability_readers.base import detect_vendor
|
||||
chat_images = detect_vendor(url) == "openrouter" and _uses_openrouter_chat_images(model_id)
|
||||
if detect_vendor(url) == "openrouter":
|
||||
images_url = base_url + "/images"
|
||||
if chat_images:
|
||||
images_url = base_url + "/chat/completions"
|
||||
|
||||
# Capture the requested geometry before older OpenAI size restrictions.
|
||||
from src.image_model_ids import image_aspect_ratio
|
||||
try:
|
||||
requested_width, requested_height = (int(value) for value in size.lower().split("x"))
|
||||
requested_ratio = image_aspect_ratio(requested_width, requested_height)
|
||||
except (TypeError, ValueError):
|
||||
requested_ratio = "1:1"
|
||||
|
||||
# Validate size for cloud image models (local diffusion accepts any WxH)
|
||||
valid_gpt_sizes = {"1024x1024", "1024x1536", "1536x1024", "auto"}
|
||||
@@ -1414,9 +1359,6 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
"n": 1,
|
||||
"size": size,
|
||||
}
|
||||
if detect_vendor(url) == "openrouter":
|
||||
payload.pop("size", None)
|
||||
payload["aspect_ratio"] = requested_ratio
|
||||
|
||||
# GPT image models and local diffusion support quality; DALL-E does not
|
||||
if is_gpt_image or is_local_diffusion:
|
||||
@@ -1430,16 +1372,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
try:
|
||||
# GPT image models can take 30-120s+ depending on quality
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)) as client:
|
||||
request_payload = payload
|
||||
if chat_images:
|
||||
request_payload = {
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"modalities": ["image", "text"],
|
||||
"stream": False,
|
||||
"image_config": {"aspect_ratio": requested_ratio},
|
||||
}
|
||||
resp = await client.post(images_url, json=request_payload, headers=headers)
|
||||
resp = await client.post(images_url, json=payload, headers=headers)
|
||||
|
||||
if resp.status_code != 200:
|
||||
error_text = resp.text[:500]
|
||||
@@ -1454,15 +1387,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
}
|
||||
|
||||
data = resp.json()
|
||||
provider_error = _image_response_error(data, model_id)
|
||||
if provider_error:
|
||||
return {"error": provider_error, "untrusted_content": True}
|
||||
images = _chat_image_results(data) if chat_images else data.get("data", [])
|
||||
images = data.get("data", [])
|
||||
if not images:
|
||||
return {"error": (
|
||||
f"Image model '{model_id}' returned no image. "
|
||||
"Choose another image generation model in AI settings."
|
||||
)}
|
||||
return {"error": "No images returned from API"}
|
||||
|
||||
img = images[0]
|
||||
image_url = None
|
||||
@@ -1575,27 +1502,21 @@ async def do_edit_image(
|
||||
return {"error": "Attached image file was not found"}
|
||||
|
||||
try:
|
||||
from src.settings import get_user_setting, load_settings
|
||||
from src.settings import load_settings
|
||||
_settings = load_settings()
|
||||
except Exception:
|
||||
_settings = {}
|
||||
def get_user_setting(key, owner="", default=None):
|
||||
return default
|
||||
|
||||
if not model_spec:
|
||||
model_spec = get_user_setting("image_edit_model", owner=owner) or get_user_setting("image_model", owner=owner, default="")
|
||||
model_spec = _settings.get("image_model", "")
|
||||
if quality is None:
|
||||
quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
|
||||
quality = _settings.get("image_quality") or "medium"
|
||||
if not model_spec:
|
||||
return {"error": "No image model selected for image editing"}
|
||||
|
||||
try:
|
||||
try:
|
||||
def _call():
|
||||
try:
|
||||
return _resolve_model(model_spec, owner=owner)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return _resolve_model(model_spec, owner=owner, model_type="image")
|
||||
except TypeError as exc:
|
||||
@@ -1608,7 +1529,6 @@ async def do_edit_image(
|
||||
except ValueError:
|
||||
return {"error": f"No endpoint found with image model '{model_spec}'."}
|
||||
|
||||
requested_edit_size = size
|
||||
if not size or size == "auto":
|
||||
from PIL import Image
|
||||
from src.image_model_ids import image_edit_size
|
||||
@@ -1784,35 +1704,7 @@ async def do_edit_image(
|
||||
"image_url": {"url": f"data:{mime};base64,{image_b64}"},
|
||||
}],
|
||||
}
|
||||
from src.image_model_ids import image_aspect_ratio
|
||||
from PIL import Image as ReferenceImage
|
||||
with ReferenceImage.open(path) as reference:
|
||||
reference_width, reference_height = reference.size
|
||||
if reference.getexif().get(274) in {5, 6, 7, 8}:
|
||||
reference_width, reference_height = reference_height, reference_width
|
||||
if requested_edit_size and requested_edit_size != "auto":
|
||||
try:
|
||||
reference_width, reference_height = (int(value) for value in requested_edit_size.lower().split("x"))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
edit_payload.pop("size", None)
|
||||
edit_payload["aspect_ratio"] = image_aspect_ratio(reference_width, reference_height)
|
||||
if _uses_openrouter_chat_images(model_id):
|
||||
edit_payload = {
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "image_url", "image_url": {
|
||||
"url": f"data:{mime};base64,{image_b64}",
|
||||
}},
|
||||
]}],
|
||||
"modalities": ["image", "text"],
|
||||
"stream": False,
|
||||
"image_config": {"aspect_ratio": edit_payload["aspect_ratio"]},
|
||||
}
|
||||
resp = await client.post(base_url + "/chat/completions", json=edit_payload, headers=headers)
|
||||
else:
|
||||
resp = await client.post(base_url + "/images", json=edit_payload, headers=headers)
|
||||
resp = await client.post(base_url + "/images", json=edit_payload, headers=headers)
|
||||
else:
|
||||
with path.open("rb") as f:
|
||||
files = {"image": (path.name, f, mime)}
|
||||
@@ -1855,12 +1747,7 @@ async def do_edit_image(
|
||||
}
|
||||
|
||||
data = resp.json()
|
||||
provider_error = _image_response_error(data, model_id)
|
||||
if provider_error:
|
||||
return {"error": provider_error, "untrusted_content": True}
|
||||
images = (_chat_image_results(data)
|
||||
if is_openrouter and _uses_openrouter_chat_images(model_id)
|
||||
else data.get("data", []))
|
||||
images = data.get("data", [])
|
||||
if not images:
|
||||
return {"error": "No image returned from edit API"}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ def compact_browser_observation(value, budget=8000):
|
||||
# Snapshot text already contains labels and refs in DOM order.
|
||||
# The refs mapping repeats them and buries menus in raw JSON.
|
||||
lines = [line for line in snapshot.splitlines()
|
||||
if not re.fullmatch(r'\s*(?:-\s*)?generic(?:\s+\[ref=e\d+\])?:?\s*', line)]
|
||||
if not re.fullmatch(r'\s*-?\s*generic(?:\s+\[ref=e\d+\])?:?\s*', line)]
|
||||
pages.append(('URL: ' + str(url) + '\n' if url else '') + '\n'.join(lines))
|
||||
elif url:
|
||||
notices.append('URL: ' + str(url))
|
||||
@@ -37,8 +37,7 @@ def compact_browser_observation(value, budget=8000):
|
||||
parsed = json.loads(item)
|
||||
except (ValueError, TypeError):
|
||||
# CLI status text precedes the JSON post-interaction state.
|
||||
# Labels are one line; stopping at \n keeps the split linear.
|
||||
parts = re.split(r'\n\n\[(?:post-[^\]\n]+|page state after failed [^\]\n]+)\]\n', item)
|
||||
parts = re.split(r'\n\n\[(?:post-[^\]]+|page state after failed [^\]]+)\]\n', item)
|
||||
if len(parts) > 1:
|
||||
for part in parts:
|
||||
visit(part)
|
||||
|
||||
+5
-37
@@ -10,7 +10,7 @@ import os
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
from typing import Tuple
|
||||
|
||||
from src.auth_helpers import owner_filter
|
||||
@@ -1957,23 +1957,6 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo
|
||||
return str(e), False
|
||||
|
||||
|
||||
def _daily_brief_utc_boundary(local_dt, tz=None):
|
||||
"""Convert a local wall-clock boundary using the offset at that boundary."""
|
||||
if local_dt.tzinfo is None and tz is not None:
|
||||
local_dt = local_dt.replace(tzinfo=tz)
|
||||
return local_dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _daily_brief_local_start(event, tz=None):
|
||||
"""Keep date-only/floating rows local; convert only stored UTC instants."""
|
||||
start = event.dtstart
|
||||
if event.is_utc and not event.all_day:
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=timezone.utc)
|
||||
return start.astimezone(tz).replace(tzinfo=None)
|
||||
return start
|
||||
|
||||
|
||||
async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
"""Build a short morning digest: today's calendar events, unread email count
|
||||
+ top-N senders/subjects, active todos."""
|
||||
@@ -1983,13 +1966,10 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
|
||||
from core.database import SessionLocal, CalendarEvent, CalendarCal, Note
|
||||
from routes.email_helpers import _imap_connect, _decode_header
|
||||
from sqlalchemy import and_, or_
|
||||
|
||||
# ----- Calendar: today's events -----
|
||||
today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
tomorrow = today + _td(days=1)
|
||||
utc_today = _daily_brief_utc_boundary(today)
|
||||
utc_tomorrow = _daily_brief_utc_boundary(tomorrow)
|
||||
# v2 review HIGH-12: gate the OR-null branch on single-user
|
||||
# (unconfigured) deploys only. In a multi-user deploy, one
|
||||
# user's daily brief must not include another user's notes or
|
||||
@@ -2002,25 +1982,13 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ev_q = db.query(CalendarEvent).join(CalendarCal).filter(
|
||||
or_(
|
||||
and_(
|
||||
CalendarEvent.is_utc.is_(True),
|
||||
CalendarEvent.all_day.is_not(True),
|
||||
CalendarEvent.dtstart < utc_tomorrow,
|
||||
CalendarEvent.dtend > utc_today,
|
||||
),
|
||||
and_(
|
||||
or_(CalendarEvent.is_utc.is_not(True), CalendarEvent.all_day.is_(True)),
|
||||
CalendarEvent.dtstart < tomorrow,
|
||||
CalendarEvent.dtend > today,
|
||||
),
|
||||
),
|
||||
CalendarEvent.dtstart < tomorrow,
|
||||
CalendarEvent.dtend > today,
|
||||
CalendarEvent.status != "cancelled",
|
||||
)
|
||||
if owner:
|
||||
ev_q = owner_filter(ev_q, CalendarCal, owner, include_shared=_allow_null)
|
||||
events = ev_q.all()
|
||||
events.sort(key=_daily_brief_local_start)
|
||||
events = ev_q.order_by(CalendarEvent.dtstart).all()
|
||||
# ----- Notes: pinned + non-archived todos with at least one undone item -----
|
||||
n_q = db.query(Note).filter(Note.archived == False) # noqa: E712
|
||||
if owner:
|
||||
@@ -2090,7 +2058,7 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
if events:
|
||||
plain.append("Calendar:")
|
||||
for e in events:
|
||||
t = _daily_brief_local_start(e).strftime("%H:%M") if not e.all_day else "all day"
|
||||
t = e.dtstart.strftime("%H:%M") if not e.all_day else "all day"
|
||||
loc = f" @ {e.location}" if e.location else ""
|
||||
plain.append(f" {t} {e.summary}{loc}")
|
||||
plain.append("")
|
||||
|
||||
+1
-7
@@ -282,13 +282,7 @@ class ChatHandler:
|
||||
_sync_upload_vision_to_gallery(file_info, owner, vl_desc)
|
||||
except Exception:
|
||||
pass
|
||||
enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n"
|
||||
if vl_desc:
|
||||
# A separate vision call wrote this, but the chat model sees
|
||||
# one message and answers the prose as if the user wrote it
|
||||
# (#5573). Label it below the marker line — the client strips
|
||||
# the block by matching `[Image: <name>]\n`.
|
||||
enhanced_message += f"[Image description — context only, not part of the user's message]:\n{vl_desc}"
|
||||
enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n{vl_desc}"
|
||||
# Surface the description to the client live so it renders as a
|
||||
# collapsible "image description" on the user bubble (not just
|
||||
# after a refresh that re-parses the stored message).
|
||||
|
||||
+11
-60
@@ -9,61 +9,9 @@ from src.chat_helpers import extract_urls
|
||||
from src.youtube_handler import is_youtube_url
|
||||
from src.search import comprehensive_web_search, fetch_webpage_content
|
||||
from src.prompt_security import UNTRUSTED_CONTEXT_POLICY, untrusted_context_message
|
||||
from src.text_helpers import strip_think
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SEARCH_QUERY_HISTORY_TURNS = 4
|
||||
_SEARCH_QUERY_HISTORY_TURN_CHARS = 500
|
||||
|
||||
|
||||
def _search_query_messages(message: str, history: List[Any]) -> List[Dict[str, str]]:
|
||||
"""Build the query extraction prompt, with recent turns to resolve references.
|
||||
|
||||
A follow-up such as "Does he work with Jane?" has no searchable subject on
|
||||
its own. The current turn is already the last history entry when the session
|
||||
persists it, so a trailing user entry is dropped rather than repeated.
|
||||
"""
|
||||
turns = [m for m in history if getattr(m, "role", "") in {"user", "assistant"}]
|
||||
if turns and turns[-1].role == "user":
|
||||
turns.pop()
|
||||
|
||||
lines = []
|
||||
for turn in turns[-_SEARCH_QUERY_HISTORY_TURNS:]:
|
||||
content = turn.content
|
||||
if isinstance(content, list):
|
||||
content = " ".join(
|
||||
str(part.get("text") or "") for part in content if isinstance(part, dict)
|
||||
)
|
||||
text = " ".join(strip_think(str(content or ""), prose=False, prompt_echo=False).split())
|
||||
if text:
|
||||
lines.append(f"{turn.role}: {text[:_SEARCH_QUERY_HISTORY_TURN_CHARS]}")
|
||||
|
||||
if not lines:
|
||||
return [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Extract a concise search query from the user's message. Reply ONLY with the query.",
|
||||
},
|
||||
{"role": "user", "content": message},
|
||||
]
|
||||
conversation = "\n".join(lines)
|
||||
return [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Extract a concise search query from the user's latest message. "
|
||||
"Use the earlier conversation only to resolve references such as pronouns, "
|
||||
"and leave out earlier topics the latest message does not refer to. "
|
||||
"Reply ONLY with the query."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"Earlier conversation:\n{conversation}\n\nLatest message:\n{message}",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _clean_search_query(query: str, max_len: int = 200) -> str:
|
||||
"""Strip fenced code blocks from a search query while preserving inline
|
||||
@@ -474,18 +422,21 @@ class ChatProcessor:
|
||||
generated_query = llm_call(
|
||||
t_url,
|
||||
t_model,
|
||||
_search_query_messages(
|
||||
message,
|
||||
[] if incognito else getattr(session, "history", None) or [],
|
||||
),
|
||||
[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Extract a concise search query from the user's message. "
|
||||
"Reply ONLY with the query."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": message},
|
||||
],
|
||||
headers=t_headers,
|
||||
temperature=0.1,
|
||||
max_tokens=50,
|
||||
timeout=15,
|
||||
thinking_mode="off",
|
||||
)
|
||||
# Models without a reasoning parser put thinking in the content.
|
||||
generated_query = strip_think(generated_query, prose=False, prompt_echo=False).strip()
|
||||
).strip()
|
||||
|
||||
if generated_query:
|
||||
# LLM successfully generated a non-empty query -> use the generated query
|
||||
|
||||
@@ -1229,7 +1229,7 @@ def tasks_terminal_response(raw, *, user_text='', max_items=20):
|
||||
text = str(payload or '').strip()
|
||||
rows = []
|
||||
for line in text.splitlines():
|
||||
match = re.match(r'^\s*\d+\.\s(.*?)\s\(([^()]+)\)\s+—\s+(.+)$', line)
|
||||
match = re.match(r'^\s*\d+\.\s+(.*?)\s+\(([^)]+)\)\s+—\s+(.+)$', line)
|
||||
if match:
|
||||
details = match[3].strip()
|
||||
rows.append({
|
||||
@@ -1346,7 +1346,7 @@ def skills_terminal_response(raw, *, user_text='', max_items=20):
|
||||
continue
|
||||
match = re.match(r'^\s*-\s+\*\*([^*]+)\*\*(?:\s+\(([^)]+)\))?', line)
|
||||
if not match:
|
||||
match = re.match(r'^\s*-\s+([^\s:][^:]*?)(?:\s\(([^()]+)\)(?:\s*:|$)|:|$)', line)
|
||||
match = re.match(r'^\s*-\s+([^:]+?)(?:\s+\(([^)]+)\))?(?:\s*:|$)', line)
|
||||
if match and status:
|
||||
rows.append((status, match.group(1).strip(), (match.group(2) or '').strip()))
|
||||
if not rows:
|
||||
@@ -5065,7 +5065,7 @@ def email_search_result_empty(value):
|
||||
try:
|
||||
decoded = json.loads(value)
|
||||
except (TypeError, ValueError):
|
||||
return bool(re.fullmatch(r'\s*No emails matched [^\n]+(?:\n\s*)?', value, re.I))
|
||||
return bool(re.fullmatch(r'\s*No emails matched [^\n]+\.?\s*', value, re.I))
|
||||
if decoded == value:
|
||||
return False
|
||||
value = decoded
|
||||
@@ -5339,16 +5339,6 @@ def private_browser_effective_url(result):
|
||||
return urls[-1] if urls else ''
|
||||
|
||||
|
||||
def is_google_sorry_url(url):
|
||||
"""Recognize Google's rate-limit interstitial."""
|
||||
# Matching the host separately keeps this linear; one combined pattern
|
||||
# rescans the host from every dot.
|
||||
return any(
|
||||
re.fullmatch(r'(?:[^/]+\.)?google\.[^/]+', host[1], re.I)
|
||||
for host in re.finditer(r'https?://([^/]+)/sorry/', url, re.I)
|
||||
)
|
||||
|
||||
|
||||
def browser_observation_page_missing(raw):
|
||||
"""Recognize a rendered error page, rather than an article mentioning 404."""
|
||||
text = str(raw or '')
|
||||
@@ -8223,7 +8213,11 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
|
||||
browser_search_url = requested_browser_url or effective_browser_url
|
||||
is_search_engine_navigation = contains_search_engine_navigation(
|
||||
browser_search_url
|
||||
) or is_google_sorry_url(effective_browser_url)
|
||||
) or bool(re.search(
|
||||
r'https?://(?:[^/]+\.)?google\.[^/]+/sorry/',
|
||||
effective_browser_url,
|
||||
re.I,
|
||||
))
|
||||
has_native_search = any(
|
||||
canonical(schema['function']['name']) == 'web_search'
|
||||
for schema in offered
|
||||
|
||||
@@ -175,7 +175,6 @@ AGENT_ISOLATED_TMP_DIRNAME = ".tmp"
|
||||
|
||||
# Auth policy
|
||||
PASSWORD_MIN_LENGTH = 8
|
||||
USERNAME_MAX_LENGTH = 64
|
||||
|
||||
# Default parameters
|
||||
DEFAULT_TEMPERATURE = 1.0
|
||||
|
||||
+13
-89
@@ -42,15 +42,6 @@ def _content_as_text(content: Any) -> str:
|
||||
_MULTIMODAL_IMAGE_TYPES = {"image_url", "input_image", "image"}
|
||||
|
||||
|
||||
def _has_preservable_content(content: Any) -> bool:
|
||||
"""Retain nonblank text or nonempty multimodal blocks after tool-call removal."""
|
||||
if isinstance(content, str):
|
||||
return bool(content.strip())
|
||||
if isinstance(content, list):
|
||||
return bool(content)
|
||||
return False
|
||||
|
||||
|
||||
def prune_multimodal_images(
|
||||
messages: List[Dict],
|
||||
*,
|
||||
@@ -231,81 +222,22 @@ def normalize_compaction_summary(summary: str) -> str:
|
||||
|
||||
|
||||
def _sanitize_tool_messages(msgs: List[Dict]) -> List[Dict]:
|
||||
"""Normalize tool-call message ordering so strict providers accept the transcript.
|
||||
"""Drop orphaned `tool` messages and dangling assistant `tool_calls`.
|
||||
|
||||
OpenAI's API requires every `role:"tool"` message to immediately
|
||||
follow an assistant message that carries `tool_calls` (or another
|
||||
tool message in the same batch). Gemini and some other providers
|
||||
additionally require a function call turn to come immediately after
|
||||
a user turn or a function response turn. Trimming the history can
|
||||
break either rule, which triggers errors such as "messages with role
|
||||
'tool' must be a response to a preceding message with 'tool_calls'"
|
||||
and "Please ensure that function call turn comes immediately after
|
||||
a user turn or after a function response turn."
|
||||
|
||||
The transcript is normalized against its own effective output: each
|
||||
pass repairs the violations it can see and the passes repeat until
|
||||
the result is stable, so the returned transcript itself satisfies
|
||||
the ordering contract and sanitizing it again is a no-op. A
|
||||
completed call/result batch whose predecessor is an adjacent
|
||||
assistant text turn keeps its calls by merging that text into the
|
||||
call message instead of dropping executed-tool evidence; a batch
|
||||
that cannot get a user/tool predecessor (e.g. a transcript that
|
||||
starts mid-history) is the only one dropped.
|
||||
tool message in the same batch). Front-trimming the history can cut
|
||||
the assistant `tool_calls` parent while keeping its tool responses,
|
||||
which triggers: "messages with role 'tool' must be a response to a
|
||||
preceding message with 'tool_calls'". This pass repairs that:
|
||||
- drops `tool` messages with no valid preceding tool_calls
|
||||
- drops assistant `tool_calls` messages whose tool responses were
|
||||
all trimmed away (some providers reject unanswered tool_calls)
|
||||
"""
|
||||
work = list(msgs)
|
||||
for _ in range(2 * len(work) + 2):
|
||||
normalized = _normalize_tool_messages_once(work)
|
||||
if normalized == work:
|
||||
break
|
||||
work = normalized
|
||||
return work
|
||||
|
||||
|
||||
def _normalize_tool_messages_once(msgs: List[Dict]) -> List[Dict]:
|
||||
"""One normalization pass over an already-trimmed message list."""
|
||||
# Merge the run of assistant text turns directly before an assistant
|
||||
# tool_calls turn into a single assistant message carrying both, so the
|
||||
# batch inherits the text run's predecessor (a user or tool message)
|
||||
# instead of losing its tool_calls. Only plain-text content is merged;
|
||||
# multimodal content stays untouched and falls through to the strip logic.
|
||||
merged: List[Dict] = []
|
||||
for m in msgs:
|
||||
cur_text = m.get("content")
|
||||
if (
|
||||
m.get("role") == "assistant"
|
||||
and m.get("tool_calls")
|
||||
and (cur_text is None or isinstance(cur_text, str))
|
||||
):
|
||||
text_run: List[Dict] = []
|
||||
while (
|
||||
merged
|
||||
and merged[-1].get("role") == "assistant"
|
||||
and not merged[-1].get("tool_calls")
|
||||
and isinstance(merged[-1].get("content"), str)
|
||||
):
|
||||
text_run.append(merged.pop())
|
||||
if text_run:
|
||||
text_parts = [
|
||||
msg["content"].strip()
|
||||
for msg in reversed(text_run)
|
||||
if msg["content"].strip()
|
||||
]
|
||||
if isinstance(cur_text, str) and cur_text.strip():
|
||||
text_parts.append(cur_text.strip())
|
||||
merged_msg = dict(m)
|
||||
if text_parts:
|
||||
merged_msg["content"] = "\n\n".join(text_parts)
|
||||
merged.append(merged_msg)
|
||||
continue
|
||||
merged.append(m)
|
||||
|
||||
# Strip tool_calls from assistant messages whose predecessor is not a
|
||||
# user or tool message, then drop orphan tool messages and dangling
|
||||
# tool_calls whose responses are gone.
|
||||
# Pass 1: drop orphan tool messages.
|
||||
cleaned: List[Dict] = []
|
||||
in_batch = False # are we right after an assistant tool_calls (or mid-batch)?
|
||||
for m in merged:
|
||||
for m in msgs:
|
||||
role = m.get("role")
|
||||
if role == "tool":
|
||||
if in_batch:
|
||||
@@ -313,21 +245,13 @@ def _normalize_tool_messages_once(msgs: List[Dict]) -> List[Dict]:
|
||||
# else: orphan — drop
|
||||
continue
|
||||
if role == "assistant" and m.get("tool_calls"):
|
||||
prev_role = cleaned[-1].get("role") if cleaned else None
|
||||
if prev_role not in ("user", "tool"):
|
||||
# Invalid predecessor — strip tool_calls to avoid provider
|
||||
# errors; keep the message only if it still carries content.
|
||||
m = {k: v for k, v in m.items() if k != "tool_calls"}
|
||||
in_batch = False
|
||||
if not _has_preservable_content(m.get("content")):
|
||||
continue # nothing left worth keeping
|
||||
cleaned.append(m)
|
||||
continue
|
||||
in_batch = True
|
||||
else:
|
||||
in_batch = False
|
||||
cleaned.append(m)
|
||||
|
||||
# Pass 2: drop assistant tool_calls messages that have NO following
|
||||
# tool response (dangling) — walk backwards so we know what follows.
|
||||
out: List[Dict] = []
|
||||
for i, m in enumerate(cleaned):
|
||||
if m.get("role") == "assistant" and m.get("tool_calls"):
|
||||
@@ -337,7 +261,7 @@ def _normalize_tool_messages_once(msgs: List[Dict]) -> List[Dict]:
|
||||
# tool_calls so it's a plain assistant turn (preserves any
|
||||
# text content the model produced alongside the calls).
|
||||
m = {k: v for k, v in m.items() if k != "tool_calls"}
|
||||
if not _has_preservable_content(m.get("content")):
|
||||
if not (m.get("content") or "").strip():
|
||||
continue # nothing left worth keeping
|
||||
out.append(m)
|
||||
return out
|
||||
|
||||
@@ -31,43 +31,63 @@ def _internal_headers() -> dict:
|
||||
|
||||
|
||||
async def _delete_endpoint_for_task(task: dict) -> None:
|
||||
"""Remove a stopped serve's endpoint using the normal deletion contract."""
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
"""Drop the auto-registered model endpoint for a scheduled-stop serve.
|
||||
|
||||
endpoint_id = str(task.get("_endpointId") or task.get("endpointId") or "").strip()
|
||||
serve_session_id = str(task.get("sessionId") or task.get("id") or "").strip()
|
||||
if (
|
||||
not re.fullmatch(r"(?:local|img)-[0-9a-f]{8}", endpoint_id)
|
||||
or not serve_session_id
|
||||
):
|
||||
logger.info("cookbook_serve_lifecycle: missing endpoint/session identity")
|
||||
Without this, killing the tmux session leaves the endpoint sitting in
|
||||
the picker (probe goes offline; chats still try to route there) and
|
||||
the user has to delete it by hand in Settings -> Endpoints.
|
||||
"""
|
||||
endpoint_id = (task.get("_endpointId") or task.get("endpointId") or "").strip()
|
||||
if not endpoint_id:
|
||||
logger.info(
|
||||
"cookbook_serve_lifecycle: task %s has no endpoint id; skipping endpoint deletion",
|
||||
task.get("sessionId") or task.get("id") or "",
|
||||
)
|
||||
return
|
||||
|
||||
import re as _re
|
||||
payload = task.get("payload") or {}
|
||||
cmd = str(payload.get("_cmd") or "")
|
||||
remote = task.get("remoteHost") or ""
|
||||
# Build host the same way _auto_register_llm_endpoint does so URL match wins.
|
||||
if remote:
|
||||
host = remote.split("@")[-1] if "@" in remote else remote
|
||||
else:
|
||||
host = "host.docker.internal"
|
||||
port_match = _re.search(r"--port\s+(\d+)", cmd)
|
||||
ollama_host_match = _re.search(r"OLLAMA_HOST=[^\s]*?:(\d+)", cmd)
|
||||
if port_match:
|
||||
port = int(port_match.group(1))
|
||||
elif ollama_host_match:
|
||||
port = int(ollama_host_match.group(1))
|
||||
elif "ollama" in cmd:
|
||||
port = 11434
|
||||
else:
|
||||
port = 8080
|
||||
base_url = f"http://{host}:{port}/v1"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8) as client:
|
||||
response = await client.delete(
|
||||
f"{internal_api_base()}/api/model-endpoints/{quote(endpoint_id, safe='')}",
|
||||
params={"expected_cookbook_serve_session_id": serve_session_id},
|
||||
r = await client.get(
|
||||
f"{internal_api_base()}/api/model-endpoints",
|
||||
headers=_internal_headers(),
|
||||
)
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
"cookbook_serve_lifecycle: deleted endpoint %s for serve %s",
|
||||
endpoint_id, serve_session_id,
|
||||
)
|
||||
elif response.status_code in (404, 409):
|
||||
logger.info(
|
||||
"cookbook_serve_lifecycle: endpoint %s absent or reassigned; keeping it",
|
||||
endpoint_id,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"cookbook_serve_lifecycle: endpoint %s deletion returned HTTP %s",
|
||||
endpoint_id, response.status_code,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("cookbook_serve_lifecycle: cleanup failed: %s", exc)
|
||||
if r.status_code >= 400:
|
||||
return
|
||||
eps = r.json() if r.content else []
|
||||
# Delete only the endpoint created by this scheduled serve. URL
|
||||
# matching is unsafe because a later scheduled serve can reuse the
|
||||
# same host:port after an older task has gone stale.
|
||||
ep = next((e for e in eps if e.get("id") == endpoint_id), None)
|
||||
if ep:
|
||||
await client.delete(
|
||||
f"{internal_api_base()}/api/model-endpoints/{ep['id']}",
|
||||
headers=_internal_headers(),
|
||||
)
|
||||
logger.info(
|
||||
f"cookbook_serve_lifecycle: deleted endpoint {ep.get('id')} "
|
||||
f"({ep.get('base_url')}) after scheduled stop"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"cookbook_serve_lifecycle: endpoint delete failed: {e}")
|
||||
|
||||
|
||||
async def _stop_serve(session_id: str, remote_host: str = "", ssh_port: str = "") -> bool:
|
||||
|
||||
@@ -625,8 +625,7 @@ class DeepResearcher:
|
||||
# LLM helper
|
||||
# ------------------------------------------------------------------
|
||||
async def _llm(self, messages: List[Dict], temperature: float = 0.3,
|
||||
max_tokens: int = 4096, timeout: int = 60,
|
||||
thinking_mode: Optional[str] = None) -> str:
|
||||
max_tokens: int = 4096, timeout: int = 60) -> str:
|
||||
"""Call the LLM asynchronously and strip thinking tags."""
|
||||
from src.llm_core import llm_call_async
|
||||
response = await llm_call_async(
|
||||
@@ -637,7 +636,6 @@ class DeepResearcher:
|
||||
max_tokens=max_tokens,
|
||||
headers=self.llm_headers,
|
||||
timeout=timeout,
|
||||
thinking_mode=thinking_mode,
|
||||
)
|
||||
return strip_thinking(response)
|
||||
|
||||
@@ -698,7 +696,7 @@ class DeepResearcher:
|
||||
try:
|
||||
result = await self._llm(
|
||||
[{"role": "user", "content": prompt}],
|
||||
temperature=0, max_tokens=20, timeout=15, thinking_mode="off",
|
||||
temperature=0, max_tokens=20, timeout=15,
|
||||
)
|
||||
cat = (result or "").strip().lower()
|
||||
# Clean one-word answer first.
|
||||
|
||||
@@ -142,10 +142,7 @@ def _process_pdf(
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
temp_img_path = tmp.name
|
||||
try:
|
||||
image = img.image
|
||||
if image.mode == "CMYK":
|
||||
image = image.convert("RGB")
|
||||
image.save(temp_img_path, "PNG")
|
||||
img.image.save(temp_img_path, "PNG") # pypdf -> PIL image
|
||||
ocr_text = analyze_image_with_vl(temp_img_path, owner=owner)
|
||||
if ocr_text and "unavailable" not in ocr_text.lower():
|
||||
pdf_text += f"\n\n[Page {page_num + 1} image {img_index + 1} text]: {ocr_text}"
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
"""The shared model defaults used by Quick Edit's image operations."""
|
||||
from src.settings import get_user_setting, load_settings
|
||||
|
||||
|
||||
def editor_image_models(owner: str = "") -> dict[str, str]:
|
||||
settings = load_settings()
|
||||
generation = ""
|
||||
edit_model = ""
|
||||
if owner:
|
||||
generation = str(get_user_setting("image_model", owner=owner, default="") or "").strip()
|
||||
edit_model = str(get_user_setting("image_edit_model", owner=owner, default="") or "").strip()
|
||||
if not generation:
|
||||
generation = str(settings.get("image_model") or "").strip()
|
||||
if not edit_model:
|
||||
edit_model = str(settings.get("image_edit_model") or generation).strip()
|
||||
return {
|
||||
"generation": generation,
|
||||
"edit": edit_model or generation,
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
"""Shared MIME text decoding for the web email helpers and MCP server."""
|
||||
|
||||
|
||||
def decode_text_part(part) -> str:
|
||||
payload = part.get_payload(decode=True)
|
||||
if not payload:
|
||||
return ""
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
try:
|
||||
return payload.decode(charset, errors="replace")
|
||||
except (LookupError, ValueError):
|
||||
# Broken senders can label valid UTF-8 with an unsupported codec.
|
||||
return payload.decode("utf-8", errors="replace")
|
||||
@@ -40,15 +40,6 @@ def image_edit_size(model_id: str, width: int, height: int) -> str:
|
||||
return f"{width}x{height}"
|
||||
|
||||
|
||||
def image_aspect_ratio(width: int, height: int) -> str:
|
||||
"""Choose the nearest common Image API ratio, without distorting pixels."""
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("Image dimensions must be positive")
|
||||
ratios = ((1, 1), (16, 9), (9, 16), (3, 2), (2, 3), (4, 3), (3, 4), (21, 9))
|
||||
w, h = min(ratios, key=lambda ratio: abs(math.log((ratio[0] / ratio[1]) / (width / height))))
|
||||
return f"{w}:{h}"
|
||||
|
||||
|
||||
def looks_like_image_generation_model(model_id: str) -> bool:
|
||||
"""Return True when a model id should use image generation routes.
|
||||
|
||||
|
||||
+35
-62
@@ -422,18 +422,7 @@ def _stream_delta_event(text: str, *, thinking: bool = False) -> str:
|
||||
return f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
|
||||
_WORD_CHARS = r"A-Za-z0-9_\u0370-\u03ff\u0400-\u04ff"
|
||||
_DELIM_RE = re.compile(rf"[^{_WORD_CHARS}+\-.]")
|
||||
_NUMERIC_PATTERN = rf"(?<![{_WORD_CHARS}])[+-]?(?:[0-9]+\.[0-9]+|\.[0-9]+|[0-9]+)(?:[eE][+-]?[0-9]+)?(?![{_WORD_CHARS}])"
|
||||
_NUMERIC_FULLMATCH_RE = re.compile(rf"^[+-]?(?:[0-9]+\.[0-9]+|\.[0-9]+|[0-9]+)(?:[eE][+-]?[0-9]+)?$")
|
||||
_DEGENERATE_WORD_RE = re.compile(rf"[{_WORD_CHARS}]+")
|
||||
_TOKEN_RE = re.compile(rf"(?P<NUM>{_NUMERIC_PATTERN})|(?P<WORD>[{_WORD_CHARS}]+)")
|
||||
|
||||
|
||||
def _is_numeric_token(token: str) -> bool:
|
||||
if token.isdigit():
|
||||
return True
|
||||
return _NUMERIC_FULLMATCH_RE.fullmatch(token) is not None
|
||||
_DEGENERATE_WORD_RE = re.compile(r"[A-Za-z0-9_\u0370-\u03ff\u0400-\u04ff]+")
|
||||
|
||||
|
||||
class _DegenerateStreamGuard:
|
||||
@@ -455,38 +444,24 @@ class _DegenerateStreamGuard:
|
||||
self.recent_tokens: List[str] = []
|
||||
self.periodic_tokens: List[str] = []
|
||||
self.total_chars = 0
|
||||
self._buffer = ""
|
||||
|
||||
def check(self, text: str) -> Optional[str]:
|
||||
if not text:
|
||||
return None
|
||||
self.total_chars += len(text)
|
||||
self._buffer += text
|
||||
|
||||
m_delims = list(_DELIM_RE.finditer(self._buffer))
|
||||
if m_delims:
|
||||
split_idx = m_delims[-1].end()
|
||||
head = self._buffer[:split_idx]
|
||||
self._buffer = self._buffer[split_idx:]
|
||||
elif len(self._buffer) >= 256:
|
||||
head = self._buffer
|
||||
self._buffer = ""
|
||||
else:
|
||||
return None
|
||||
|
||||
tokens = []
|
||||
for m in _TOKEN_RE.finditer(head):
|
||||
tokens.append((m.group(0).lower(), m.lastgroup == "NUM"))
|
||||
# Keep one-character tokens. Structured output often varies through
|
||||
# numeric selectors or coordinates; dropping those tokens can make
|
||||
# valid CSS/SVG rows look like an exact repeated phrase.
|
||||
tokens = [t.lower() for t in _DEGENERATE_WORD_RE.findall(text)]
|
||||
if not tokens:
|
||||
return None
|
||||
|
||||
for token, is_num in tokens:
|
||||
for token in tokens:
|
||||
if token == self.last_token:
|
||||
self.same_run += 1
|
||||
else:
|
||||
self.last_token = token
|
||||
self.same_run = 1
|
||||
self.numeric_run = self.numeric_run + 1 if is_num else 0
|
||||
self.numeric_run = self.numeric_run + 1 if token.isdigit() else 0
|
||||
self.recent_tokens.append(token)
|
||||
self.periodic_tokens.append(token)
|
||||
if len(self.recent_tokens) > 96:
|
||||
@@ -495,19 +470,19 @@ class _DegenerateStreamGuard:
|
||||
self.periodic_tokens = self.periodic_tokens[-2048:]
|
||||
|
||||
reason = None
|
||||
if self.same_run >= 28 and self.total_chars >= 100 and not _is_numeric_token(self.last_token):
|
||||
if self.same_run >= 28 and self.total_chars >= 100 and not self.last_token.isdigit():
|
||||
reason = f"repeated '{self.last_token}' {self.same_run} times"
|
||||
elif len(self.recent_tokens) >= 72:
|
||||
top = max(set(self.recent_tokens), key=self.recent_tokens.count)
|
||||
count = self.recent_tokens.count(top)
|
||||
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78 and not _is_numeric_token(top):
|
||||
if count >= 60 and count / max(len(self.recent_tokens), 1) >= 0.78 and not top.isdigit():
|
||||
reason = f"repeated '{top}' {count}/{len(self.recent_tokens)} recent tokens"
|
||||
if (
|
||||
not reason
|
||||
and len(self.recent_tokens) >= 48
|
||||
# A window that is mostly numbers is matrix/table data; its
|
||||
# periodicity is real. Mixed loops still hit the exact-block check.
|
||||
and sum(_is_numeric_token(t) for t in self.recent_tokens) * 2 < len(self.recent_tokens)
|
||||
and sum(t.isdigit() for t in self.recent_tokens) * 2 < len(self.recent_tokens)
|
||||
):
|
||||
# Detect a periodic suffix instead of counting repeated n-grams.
|
||||
# Reused property names are normal in CSS/SVG/JSON; a collapsed
|
||||
@@ -543,9 +518,9 @@ class _DegenerateStreamGuard:
|
||||
if history[previous:previous + anchor_size] != anchor:
|
||||
continue
|
||||
period = current_anchor - previous
|
||||
if period < 24 or len(history) < period * 2 or self.numeric_run >= period:
|
||||
if period < 24 or len(history) < period * 2:
|
||||
continue
|
||||
if history[-2 * period:-period] == history[-period:] and not all(_is_numeric_token(t) for t in history[-period:]):
|
||||
if history[-2 * period:-period] == history[-period:] and not all(t.isdigit() for t in history[-period:]):
|
||||
reason = f"repeated an exact {period}-token block twice"
|
||||
break
|
||||
|
||||
@@ -2321,25 +2296,6 @@ def _apply_explicit_nonstream_reasoning_effort(
|
||||
payload["reasoning_effort"] = effort
|
||||
|
||||
|
||||
def _nonstream_openai_response_text(msg: Dict, thinking_mode: Optional[str]) -> str:
|
||||
"""Return the answer text of an OpenAI-compatible non-streaming message.
|
||||
|
||||
With thinking off the caller wants only the answer, so an empty answer
|
||||
stays empty even when the model spent its whole budget on reasoning.
|
||||
Otherwise reasoning stands in for a missing answer, and Mistral's
|
||||
structured thinking is kept ahead of its text.
|
||||
"""
|
||||
thinking_off = _normalize_thinking_mode(thinking_mode) == "off"
|
||||
reasoning = "" if thinking_off else msg.get("reasoning_content")
|
||||
content = msg.get("content")
|
||||
if not isinstance(content, list):
|
||||
return content or reasoning or ""
|
||||
text_part, thinking_part = _normalize_mistral_content(content)
|
||||
if thinking_part and not thinking_off:
|
||||
return thinking_part + "\n\n" + (text_part or "")
|
||||
return text_part or reasoning or ""
|
||||
|
||||
|
||||
def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE,
|
||||
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
||||
timeout: int = LLMConfig.DEFAULT_TIMEOUT, prompt_type: Optional[str] = None,
|
||||
@@ -2431,9 +2387,17 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
||||
elif provider == "ollama":
|
||||
response = _parse_ollama_response(data)
|
||||
else:
|
||||
response = _nonstream_openai_response_text(
|
||||
data["choices"][0]["message"], thinking_mode
|
||||
)
|
||||
msg = data["choices"][0]["message"]
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
# Mistral structured content — extract thinking + text
|
||||
text_part, thinking_part = _normalize_mistral_content(content)
|
||||
if thinking_part:
|
||||
response = thinking_part + "\n\n" + (text_part or "")
|
||||
else:
|
||||
response = text_part or msg.get("reasoning_content") or ""
|
||||
else:
|
||||
response = content or msg.get("reasoning_content") or ""
|
||||
_set_cached_response(cache_key, response)
|
||||
return response
|
||||
except Exception:
|
||||
@@ -2871,9 +2835,18 @@ async def llm_call_async(
|
||||
elif provider == "ollama":
|
||||
response = _parse_ollama_response(data)
|
||||
else:
|
||||
response = _nonstream_openai_response_text(
|
||||
data["choices"][0]["message"], thinking_mode
|
||||
)
|
||||
msg = data["choices"][0]["message"]
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
# Mistral structured content — extract thinking + text
|
||||
# (same contract as llm_call / stream_llm; see #5435).
|
||||
text_part, thinking_part = _normalize_mistral_content(content)
|
||||
if thinking_part:
|
||||
response = thinking_part + "\n\n" + (text_part or "")
|
||||
else:
|
||||
response = text_part or msg.get("reasoning_content") or ""
|
||||
else:
|
||||
response = content or msg.get("reasoning_content") or ""
|
||||
_set_cached_response(
|
||||
cache_key,
|
||||
response,
|
||||
|
||||
+1
-4
@@ -770,11 +770,8 @@ class McpManager:
|
||||
qualified.add(f"mcp__{server_id}__{tool['name']}")
|
||||
return disabled_map, qualified
|
||||
|
||||
@staticmethod
|
||||
def is_builtin(server_id: str) -> bool:
|
||||
def is_builtin(self, server_id: str) -> bool:
|
||||
"""Check if a server is a built-in (auto-registered) server."""
|
||||
if not isinstance(server_id, str):
|
||||
return False
|
||||
return server_id.startswith("builtin_") or server_id in {
|
||||
"image_gen",
|
||||
"memory",
|
||||
|
||||
+26
-15
@@ -364,32 +364,32 @@ class MemoryManager:
|
||||
return []
|
||||
|
||||
# Define keyword categories for semantic matching
|
||||
identity_words = ["name", "called", "identity", "myself"]
|
||||
identity_words = ["name", "who", "i", "am", "called", "identity", "myself", "me", "my"]
|
||||
contact_words = ["phone", "email", "address", "contact", "number", "where", "located", "reach"]
|
||||
preference_words = ["like", "prefer", "favorite", "want", "love", "hate", "dislike", "enjoy", "interested"]
|
||||
task_words = ["todo", "task", "remind", "meeting", "appointment", "schedule", "deadline"]
|
||||
fact_words = ["what", "when", "where", "how", "why", "explain", "describe", "information", "know"]
|
||||
|
||||
query_lower = query.lower()
|
||||
query_words = set(re.findall(r'\w+', query_lower))
|
||||
query_tokens = set(tokenize(query_lower))
|
||||
|
||||
# Determine query type based on keywords
|
||||
query_type = None
|
||||
if query_words.intersection(identity_words) or re.search(r'\bwho\s+am\s+i\b', query_lower):
|
||||
if any(word in query_lower for word in identity_words):
|
||||
query_type = "identity"
|
||||
elif query_words.intersection(contact_words):
|
||||
elif any(word in query_lower for word in contact_words):
|
||||
query_type = "contact"
|
||||
elif query_words.intersection(preference_words):
|
||||
elif any(word in query_lower for word in preference_words):
|
||||
query_type = "preference"
|
||||
elif query_words.intersection(task_words):
|
||||
elif any(word in query_lower for word in task_words):
|
||||
query_type = "task"
|
||||
elif query_words.intersection(fact_words):
|
||||
elif any(word in query_lower for word in fact_words):
|
||||
query_type = "fact"
|
||||
|
||||
relevant = []
|
||||
# Every memory participates in the same ranking and threshold check.
|
||||
# Query type is a scoring hint, not an unconditional insertion path.
|
||||
identity_memories = []
|
||||
other_memories = []
|
||||
|
||||
# Separate identity memories from others
|
||||
for memory in memories:
|
||||
memory_text = memory["text"].lower()
|
||||
# Check if this is an identity memory (contains name patterns or identity indicators)
|
||||
@@ -397,7 +397,22 @@ class MemoryManager:
|
||||
re.search(r'\b[A-Z][a-z]+ [A-Z][a-z]+\b', memory["text"]),
|
||||
any(word in memory_text for word in ["name is", "i'm", "i am", "called", "my name", "named", "call me"])
|
||||
])
|
||||
if is_identity:
|
||||
identity_memories.append(memory)
|
||||
else:
|
||||
other_memories.append(memory)
|
||||
|
||||
# For identity queries, include all identity memories regardless of similarity
|
||||
if query_type == "identity" and identity_memories:
|
||||
# Give them high scores to ensure they're included first
|
||||
for memory in identity_memories:
|
||||
relevant.append((0.9, memory)) # High score for identity memories in identity queries
|
||||
|
||||
# Process other memories with similarity scoring
|
||||
for memory in other_memories:
|
||||
memory_text = memory["text"].lower()
|
||||
memory_tokens = set(tokenize(memory_text))
|
||||
query_tokens = set(tokenize(query_lower))
|
||||
|
||||
# Calculate base Jaccard similarity
|
||||
if not query_tokens or not memory_tokens:
|
||||
@@ -407,11 +422,7 @@ class MemoryManager:
|
||||
final_score = base_similarity
|
||||
|
||||
# Apply boosts based on semantic matching
|
||||
if query_type == "identity" and is_identity:
|
||||
# Keep explicit name/identity recall useful without outranking
|
||||
# a closer textual match or bypassing the caller's threshold.
|
||||
final_score = max(final_score * 1.3, 0.6)
|
||||
elif query_type == "contact":
|
||||
if query_type == "contact":
|
||||
# Boost memories with contact information
|
||||
has_contact_info = any(word in memory_text for word in ["@gmail.com", "@", ".com",
|
||||
"phone", "number", "address",
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
"""Preserve report TeX for markdown.js's existing deferred KaTeX renderer."""
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from markdown.blockprocessors import BlockProcessor
|
||||
from markdown.extensions import Extension
|
||||
from markdown.inlinepatterns import InlineProcessor
|
||||
from markdown.util import AtomicString
|
||||
|
||||
|
||||
# Keep delimiter and currency rules aligned with mathBlocks in markdown.js.
|
||||
_ESCAPED_MATH = r"\\\[([\s\S]*?)\\\]|\\\(([^\n]*?)\\\)"
|
||||
_DOLLAR_MATH = (
|
||||
r"\$\$([\s\S]*?)\$\$|"
|
||||
r"(?<![\$\d])\$(?!\$)(?=\S)([^\$\n]+?)(?<=\S)\$(?!\$|\d)"
|
||||
)
|
||||
|
||||
|
||||
def _placeholder(tex, display):
|
||||
node = ElementTree.Element("span", {
|
||||
"class": "ody-math-pending", "data-display": str(display).lower(),
|
||||
})
|
||||
node.text = AtomicString(tex)
|
||||
return node
|
||||
|
||||
|
||||
class _HtmlContext(HTMLParser):
|
||||
"""Inline HTML is still raw when math runs, including tag attributes."""
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.protected = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag in {"code", "pre", "script", "style", "textarea"}:
|
||||
self.protected.append(tag)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in self.protected:
|
||||
del self.protected[self.protected.index(tag):]
|
||||
|
||||
|
||||
class _InlineMath(InlineProcessor):
|
||||
def handleMatch(self, match, data):
|
||||
prefix = data[:match.start()]
|
||||
if "<" in prefix:
|
||||
context = _HtmlContext()
|
||||
context.feed(prefix)
|
||||
if context.protected or re.match(r"<(?:/?[A-Za-z]|[!?])", context.rawdata.lstrip()):
|
||||
return None, None, None
|
||||
# Backticks run before this processor. Preserve a backslash-escaped
|
||||
# dollar too; math must run before Markdown consumes \( and \[.
|
||||
if prefix.endswith("\\") and (len(prefix) - len(prefix.rstrip("\\"))) % 2:
|
||||
return None, None, None
|
||||
tex = next(group for group in match.groups() if group is not None)
|
||||
display = match.group(1) is not None
|
||||
return _placeholder(tex, display), match.start(), match.end()
|
||||
|
||||
|
||||
class _DisplayMath(BlockProcessor):
|
||||
def test(self, parent, block):
|
||||
return re.match(r"^ {0,3}(\$\$|\\\[)", block) is not None
|
||||
|
||||
def run(self, parent, blocks):
|
||||
opening = re.match(r"^ {0,3}(\$\$|\\\[)", blocks[0])
|
||||
closing = "$$" if opening.group(1) == "$$" else "\\]"
|
||||
source = blocks[0][opening.end():]
|
||||
consumed = 1
|
||||
while closing not in source:
|
||||
if consumed == len(blocks):
|
||||
return False # Leave incomplete input to ordinary Markdown.
|
||||
source += "\n\n" + blocks[consumed]
|
||||
consumed += 1
|
||||
tex, _, tail = source.partition(closing)
|
||||
del blocks[:consumed]
|
||||
if tail.strip():
|
||||
blocks.insert(0, tail)
|
||||
parent.append(_placeholder(tex, True))
|
||||
|
||||
|
||||
class ReportMathExtension(Extension):
|
||||
def extendMarkdown(self, md):
|
||||
# Backticks (190) and indented code (80) retain precedence.
|
||||
md.inlinePatterns.register(_InlineMath(_ESCAPED_MATH, md), "report_math_escaped", 185)
|
||||
# Parse links before dollar math so URL destinations stay literal.
|
||||
md.inlinePatterns.register(_InlineMath(_DOLLAR_MATH, md), "report_math_dollar", 155)
|
||||
md.parser.blockprocessors.register(_DisplayMath(md.parser), "report_math", 79)
|
||||
+8
-63
@@ -76,23 +76,9 @@ _ERROR_DETAIL = {
|
||||
"auth_or_protocol_error": "authentication or protocol error",
|
||||
"no_models": "endpoint returned no models",
|
||||
"no_host": "no host configured",
|
||||
"config_source_error": "configuration source could not be read",
|
||||
"error": "probe failed",
|
||||
}
|
||||
|
||||
# Config/inventory source behind each network subsystem. `_gather_inputs()`
|
||||
# fails soft, so a source that could not be read hands its probe an empty
|
||||
# inventory — indistinguishable from "nothing configured", which every probe
|
||||
# answers with `disabled`. The collector therefore reports the source failure
|
||||
# instead of running a probe whose verdict would be a lie.
|
||||
CONFIG_SOURCE_ERROR = "config_source_error"
|
||||
_SUBSYSTEM_SOURCE = {
|
||||
"searxng": "settings",
|
||||
"ntfy": "integrations",
|
||||
"email": "accounts",
|
||||
"providers": "endpoints",
|
||||
}
|
||||
|
||||
|
||||
def _svc(name: str, status: str, detail: str, **meta: Any) -> Dict[str, Any]:
|
||||
return {"name": name, "status": status, "detail": detail, "meta": dict(meta)}
|
||||
@@ -425,33 +411,26 @@ def _gather_inputs() -> Dict[str, Any]:
|
||||
"""Pull live config/account/endpoint lists from the app's data sources.
|
||||
|
||||
Each lookup fails soft: a broken source yields an empty/neutral value so a
|
||||
single failure can't take down the whole health report. The failure is also
|
||||
*recorded* under `failed` (source -> controlled category), because the empty
|
||||
value alone cannot be told apart from a source that loaded and is genuinely
|
||||
empty — and the probes read empty as `disabled`.
|
||||
single failure can't take down the whole health report.
|
||||
"""
|
||||
settings: Dict[str, Any] = {}
|
||||
integrations: List[Dict[str, Any]] = []
|
||||
accounts: List[Dict[str, Any]] = []
|
||||
endpoints: List[Dict[str, Any]] = []
|
||||
failed: Dict[str, str] = {}
|
||||
try:
|
||||
from src.settings import load_settings
|
||||
settings = load_settings() or {}
|
||||
except Exception as e:
|
||||
failed["settings"] = _classify_error(e)
|
||||
logger.debug(f"service_health: settings load failed: {e}")
|
||||
try:
|
||||
from src.integrations import load_integrations
|
||||
integrations = load_integrations() or []
|
||||
except Exception as e:
|
||||
failed["integrations"] = _classify_error(e)
|
||||
logger.debug(f"service_health: integrations load failed: {e}")
|
||||
try:
|
||||
from routes.email_helpers import _list_email_accounts
|
||||
accounts = _list_email_accounts() or []
|
||||
except Exception as e:
|
||||
failed["accounts"] = _classify_error(e)
|
||||
logger.debug(f"service_health: email accounts load failed: {e}")
|
||||
try:
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
@@ -464,10 +443,9 @@ def _gather_inputs() -> Dict[str, Any]:
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
failed["endpoints"] = _classify_error(e)
|
||||
logger.debug(f"service_health: endpoint load failed: {e}")
|
||||
return {"settings": settings, "integrations": integrations,
|
||||
"accounts": accounts, "endpoints": endpoints, "failed": failed}
|
||||
"accounts": accounts, "endpoints": endpoints}
|
||||
|
||||
|
||||
async def _run_subsystem(name: str, fn: Callable, *args: Any) -> Dict[str, Any]:
|
||||
@@ -486,24 +464,6 @@ async def _run_subsystem(name: str, fn: Callable, *args: Any) -> Dict[str, Any]:
|
||||
return _svc(name, DOWN, _detail_for(category), error=category)
|
||||
|
||||
|
||||
async def _source_unavailable(name: str, source: str,
|
||||
category: str) -> Dict[str, Any]:
|
||||
"""Controlled entry for a subsystem whose config source could not be read.
|
||||
|
||||
Deliberately not `disabled`: the configured state is *unknown*, not known to
|
||||
be off. `degraded` rather than `down` because the failure is in reading the
|
||||
inventory, not a probe that reached the subsystem and failed — but it still
|
||||
carries non-zero severity, so a report containing one can never roll up to
|
||||
`ok`. `meta` names the source and the controlled category of the underlying
|
||||
exception; never its text.
|
||||
"""
|
||||
return _svc(name, DEGRADED,
|
||||
f"Could not read configuration "
|
||||
f"({_detail_for(CONFIG_SOURCE_ERROR)}).",
|
||||
error=CONFIG_SOURCE_ERROR, source=source,
|
||||
source_error=category)
|
||||
|
||||
|
||||
async def collect_service_health(rag_manager: Any = None,
|
||||
memory_vector: Any = None) -> Dict[str, Any]:
|
||||
"""Run every probe and return {overall, services, timestamp}.
|
||||
@@ -512,10 +472,6 @@ async def collect_service_health(rag_manager: Any = None,
|
||||
four network subsystems run concurrently, each under `_SUBSYSTEM_DEADLINE`,
|
||||
with an overall `_AGGREGATE_DEADLINE` backstop. Per-item probes inside
|
||||
providers/email are themselves bounded by `_FANOUT_BUDGET`.
|
||||
|
||||
A subsystem whose config/inventory source failed to load is reported via
|
||||
`_source_unavailable()` instead of being probed with an empty inventory,
|
||||
which every probe would otherwise report as `disabled`.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -526,23 +482,12 @@ async def collect_service_health(rag_manager: Any = None,
|
||||
chroma = chromadb_health(rag_manager, memory_vector)
|
||||
|
||||
names = ["searxng", "ntfy", "email", "providers"]
|
||||
probes = {
|
||||
"searxng": (searxng_health, (settings,)),
|
||||
"ntfy": (ntfy_health, (inputs["integrations"], settings)),
|
||||
"email": (email_health, (inputs["accounts"],)),
|
||||
"providers": (providers_health, (inputs["endpoints"],)),
|
||||
}
|
||||
# A subsystem whose inventory could not be read is reported as such. The
|
||||
# others are still probed normally — one broken source degrades one entry.
|
||||
failed = inputs.get("failed") or {}
|
||||
coros = []
|
||||
for name in names:
|
||||
source = _SUBSYSTEM_SOURCE[name]
|
||||
if source in failed:
|
||||
coros.append(_source_unavailable(name, source, failed[source]))
|
||||
else:
|
||||
fn, args = probes[name]
|
||||
coros.append(_run_subsystem(name, fn, *args))
|
||||
coros = [
|
||||
_run_subsystem("searxng", searxng_health, settings),
|
||||
_run_subsystem("ntfy", ntfy_health, inputs["integrations"], settings),
|
||||
_run_subsystem("email", email_health, inputs["accounts"]),
|
||||
_run_subsystem("providers", providers_health, inputs["endpoints"]),
|
||||
]
|
||||
try:
|
||||
results = await asyncio.wait_for(asyncio.gather(*coros),
|
||||
timeout=_AGGREGATE_DEADLINE)
|
||||
|
||||
+5
-33
@@ -57,17 +57,8 @@ def _message_to_context(msg: DBChatMessage) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def escape_like_pattern(value: str) -> str:
|
||||
"""Escape SQL LIKE wildcards (``%``, ``_``) and the escape char itself.
|
||||
|
||||
Public shared helper for building ``ilike(f"%{...}%", escape="\\\\")``
|
||||
patterns from user input. Must escape backslash first.
|
||||
"""
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _escape_like(value: str) -> str:
|
||||
return escape_like_pattern(value)
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _snippet(content: str, query: str, radius: int = 60) -> str:
|
||||
@@ -76,34 +67,15 @@ def _snippet(content: str, query: str, radius: int = 60) -> str:
|
||||
if not query:
|
||||
return content[: radius * 2]
|
||||
|
||||
match_start = -1
|
||||
match_length = 0
|
||||
lowered_content = content.lower()
|
||||
for candidate in _query_terms(query):
|
||||
idx = lowered_content.find(candidate.lower())
|
||||
if idx != -1 and (match_start == -1 or idx < match_start):
|
||||
match_start = idx
|
||||
match_length = len(candidate)
|
||||
|
||||
if match_start == -1:
|
||||
idx = content.lower().find(query.lower())
|
||||
if idx == -1:
|
||||
return content[: radius * 2]
|
||||
|
||||
start = max(0, match_start - radius)
|
||||
end = min(len(content), match_start + match_length + radius)
|
||||
start = max(0, idx - radius)
|
||||
end = min(len(content), idx + len(query) + radius)
|
||||
return ("..." if start > 0 else "") + content[start:end] + ("..." if end < len(content) else "")
|
||||
|
||||
|
||||
def _query_terms(query: str) -> list[str]:
|
||||
"""Return meaningful quoted phrases and tokens in user-entered order."""
|
||||
terms: list[str] = []
|
||||
for match in re.finditer(r'"([^"]+)"|[\w][\w._-]*', query, flags=re.UNICODE):
|
||||
term = match.group(1) if match.group(1) is not None else match.group(0).strip("._-")
|
||||
term = term.strip()
|
||||
if term:
|
||||
terms.append(term)
|
||||
return terms
|
||||
|
||||
|
||||
def _sanitize_fts_query(query: str) -> str | None:
|
||||
"""Convert free text into a conservative FTS5 MATCH query.
|
||||
|
||||
|
||||
+1
-17
@@ -46,7 +46,6 @@ DEFAULT_SETTINGS = {
|
||||
"agent_email_confirm": True,
|
||||
"image_gen_enabled": False,
|
||||
"image_model": "",
|
||||
"image_edit_model": "",
|
||||
"image_quality": "medium",
|
||||
# Optional editor planner/caption endpoints. Empty uses configured chat
|
||||
# fallback without probing a developer-specific host.
|
||||
@@ -120,7 +119,6 @@ DEFAULT_SETTINGS = {
|
||||
# Tune via Settings or by editing data/settings.json.
|
||||
"research_run_timeout_seconds": 1800,
|
||||
"agent_max_tool_calls": 0,
|
||||
"tool_approval_mode": "ask",
|
||||
"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"
|
||||
@@ -296,7 +294,7 @@ def is_setting_overridden(key: str) -> bool:
|
||||
# resolved by FastAPI deps; an empty/None owner falls through to the global.
|
||||
_PER_USER_KEYS = {
|
||||
"vision_model", "vision_enabled", "vision_model_fallbacks",
|
||||
"image_model", "image_edit_model", "image_gen_enabled", "image_quality",
|
||||
"image_model", "image_gen_enabled", "image_quality",
|
||||
# Default chat endpoint / model — without per-user resolution every new
|
||||
# account inherited whatever the most-recent admin picked, which then
|
||||
# got injected into the chat composer on first open.
|
||||
@@ -315,20 +313,6 @@ def get_user_setting(key: str, owner: str = "", default: Any = None) -> Any:
|
||||
Falls back gracefully if the prefs module can't be imported (cycle/early
|
||||
boot) — admin-global settings keep working.
|
||||
"""
|
||||
if key == "image_gen_enabled":
|
||||
global_val = get_setting("image_gen_enabled", default if default is not None else False)
|
||||
if not global_val:
|
||||
return False
|
||||
if owner:
|
||||
try:
|
||||
from routes.prefs_routes import _load_for_user
|
||||
prefs = _load_for_user(owner) or {}
|
||||
if "image_gen_enabled" in prefs and prefs["image_gen_enabled"] is not None:
|
||||
return bool(prefs["image_gen_enabled"])
|
||||
except Exception:
|
||||
pass
|
||||
return bool(global_val)
|
||||
|
||||
if owner and key in _PER_USER_KEYS:
|
||||
try:
|
||||
from routes.prefs_routes import _load_for_user
|
||||
|
||||
@@ -334,142 +334,6 @@ _BROWSER_MCP_READ_TOOLS = frozenset(
|
||||
)
|
||||
|
||||
|
||||
LOCAL_MCP_READ_TOOLS_ENV = "ODYSSEUS_LOCAL_MCP_READ_TOOLS"
|
||||
LOCAL_MCP_WRITE_TOOLS_ENV = "ODYSSEUS_LOCAL_MCP_WRITE_TOOLS"
|
||||
|
||||
_LOCAL_MCP_READ_CAPABILITIES = _capabilities(
|
||||
ToolEffect.READ_PRIVATE,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_LOCAL_MCP_WRITE_CAPABILITIES = _capabilities(
|
||||
ToolEffect.WRITE_PRIVATE,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
|
||||
|
||||
def _local_mcp_tools(variable: str) -> frozenset[str]:
|
||||
"""Tool names a deployment declares as touching only the local store.
|
||||
|
||||
Tools served by a user-configured MCP server are absent from the registry
|
||||
above, so they are classified as unknown and fail high. For a server with
|
||||
both a read and a write tool that is unusable: the read result arms the
|
||||
post-external gate, because an unknown result is not SYSTEM integrity, and
|
||||
the write that follows is refused until separately authorized. Every
|
||||
second call needs an approval, for a server that never leaves the host.
|
||||
|
||||
Declaring the tools resolves that without widening the gate:
|
||||
|
||||
ODYSSEUS_LOCAL_MCP_READ_TOOLS=mcp__<server>__search_notes,mcp__<server>__read_note
|
||||
ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=mcp__<server>__create_note
|
||||
|
||||
Read tools become READ_PRIVATE with EXTERNAL_UNTRUSTED integrity. Their
|
||||
results remain untrusted and still arm the gate, preventing prompt
|
||||
injections from reaching shell, network, or unrelated tools. However,
|
||||
declared write tools on the exact same MCP server receive a narrowly
|
||||
scoped continuation exception as long as no external or error taint has
|
||||
entered the run. Once genuinely external content has entered the run,
|
||||
approvals remain required.
|
||||
"""
|
||||
raw = os.getenv(variable, "") or ""
|
||||
return frozenset(name.strip() for name in raw.split(",") if name.strip())
|
||||
|
||||
|
||||
def _current_mcp_manager():
|
||||
"""Retrieve the current McpManager instance safely without hard circular binds."""
|
||||
try:
|
||||
from src import tool_execution
|
||||
if hasattr(tool_execution, "get_mcp_manager"):
|
||||
mgr = tool_execution.get_mcp_manager()
|
||||
if mgr is not None:
|
||||
return mgr
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from src.tool_utils import get_mcp_manager
|
||||
return get_mcp_manager()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _is_declared_local_mcp_tool(
|
||||
tool_name: Any, server_id: str | None = None
|
||||
) -> bool:
|
||||
"""Return whether tool_name is an exact, well-formed declared local MCP tool."""
|
||||
if not isinstance(tool_name, str) or not tool_name:
|
||||
return False
|
||||
parts = tool_name.split("__", 2)
|
||||
if not (
|
||||
len(parts) == 3
|
||||
and parts[0] == "mcp"
|
||||
and bool(parts[1])
|
||||
and bool(parts[2])
|
||||
and not any(char.isspace() for part in parts[1:] for char in part)
|
||||
):
|
||||
return False
|
||||
sid = parts[1]
|
||||
if server_id is not None and sid != server_id:
|
||||
return False
|
||||
from src.mcp_manager import McpManager
|
||||
if McpManager.is_builtin(sid):
|
||||
return False
|
||||
if tool_name in TOOL_CAPABILITIES:
|
||||
return False
|
||||
declared_read = tool_name in _local_mcp_tools(LOCAL_MCP_READ_TOOLS_ENV)
|
||||
declared_write = tool_name in _local_mcp_tools(LOCAL_MCP_WRITE_TOOLS_ENV)
|
||||
return (declared_read or declared_write) and (declared_read != declared_write)
|
||||
|
||||
|
||||
def _is_declared_local_mcp_read_tool(
|
||||
tool_name: Any, server_id: str | None = None
|
||||
) -> bool:
|
||||
if not _is_declared_local_mcp_tool(tool_name, server_id):
|
||||
return False
|
||||
declared_read = tool_name in _local_mcp_tools(LOCAL_MCP_READ_TOOLS_ENV)
|
||||
declared_write = tool_name in _local_mcp_tools(LOCAL_MCP_WRITE_TOOLS_ENV)
|
||||
if not (declared_read and not declared_write):
|
||||
return False
|
||||
parts = tool_name.split("__", 2)
|
||||
mgr = _current_mcp_manager()
|
||||
if mgr is not None:
|
||||
tools = mgr._tools.get(parts[1], [])
|
||||
tool_row = next((r for r in tools if r.get("name") == parts[2]), None)
|
||||
if tool_row is not None:
|
||||
from src.mcp_manager import mcp_tool_is_readonly
|
||||
if not mcp_tool_is_readonly(tool_row):
|
||||
return False
|
||||
else:
|
||||
from src.mcp_manager import _MCP_READONLY_VERBS
|
||||
if not parts[2].lower().startswith(_MCP_READONLY_VERBS):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _is_declared_local_mcp_write_tool(
|
||||
tool_name: Any, server_id: str | None = None
|
||||
) -> bool:
|
||||
if not _is_declared_local_mcp_tool(tool_name, server_id):
|
||||
return False
|
||||
declared_read = tool_name in _local_mcp_tools(LOCAL_MCP_READ_TOOLS_ENV)
|
||||
declared_write = tool_name in _local_mcp_tools(LOCAL_MCP_WRITE_TOOLS_ENV)
|
||||
if not (declared_write and not declared_read):
|
||||
return False
|
||||
parts = tool_name.split("__", 2)
|
||||
mgr = _current_mcp_manager()
|
||||
if mgr is not None:
|
||||
tools = mgr._tools.get(parts[1], [])
|
||||
tool_row = next((r for r in tools if r.get("name") == parts[2]), None)
|
||||
if tool_row is not None:
|
||||
ann = tool_row.get("annotations") or {}
|
||||
destructive = (
|
||||
ann.get("destructiveHint")
|
||||
if isinstance(ann, dict)
|
||||
else getattr(ann, "destructiveHint", None)
|
||||
)
|
||||
if destructive is True:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def capabilities_for_tool(tool_name: Any) -> ToolCapabilities:
|
||||
"""Return deterministic capabilities; malformed and unknown tools fail high."""
|
||||
if not isinstance(tool_name, str) or not tool_name:
|
||||
@@ -484,10 +348,6 @@ def capabilities_for_tool(tool_name: Any) -> ToolCapabilities:
|
||||
return capabilities
|
||||
if tool_name in _BROWSER_MCP_READ_TOOLS:
|
||||
return _BROWSER_MCP_READ_CAPABILITIES
|
||||
if _is_declared_local_mcp_read_tool(tool_name):
|
||||
return _LOCAL_MCP_READ_CAPABILITIES
|
||||
if _is_declared_local_mcp_write_tool(tool_name):
|
||||
return _LOCAL_MCP_WRITE_CAPABILITIES
|
||||
return _UNKNOWN_CAPABILITIES
|
||||
|
||||
|
||||
@@ -925,149 +785,6 @@ def delegated_tool_is_blocked(tool_name: Any, content: Any = None) -> bool:
|
||||
)
|
||||
|
||||
|
||||
_SERVER_OWNED_CONTROL_PLANE_SOURCES = frozenset(
|
||||
{
|
||||
"skills",
|
||||
"client runtime context",
|
||||
"backend runtime context",
|
||||
"integrations",
|
||||
"mcp tools",
|
||||
"agents.md",
|
||||
}
|
||||
)
|
||||
|
||||
_PROHIBITED_CONTINUATION_EFFECTS = frozenset(
|
||||
{
|
||||
ToolEffect.NETWORK_EGRESS,
|
||||
ToolEffect.EXTERNAL_SIDE_EFFECT,
|
||||
ToolEffect.EXECUTE_CODE,
|
||||
ToolEffect.ADMIN_CHANGE,
|
||||
ToolEffect.UI_SIDE_EFFECT,
|
||||
ToolEffect.DESTRUCTIVE,
|
||||
ToolEffect.READ_WORKSPACE,
|
||||
ToolEffect.WRITE_WORKSPACE,
|
||||
ToolEffect.BROKERED_NETWORK_READ,
|
||||
ToolEffect.READ_PUBLIC,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_authorized_local_mcp_continuation(
|
||||
tool_name: Any,
|
||||
external_sources: Iterable[str],
|
||||
content: Any = None,
|
||||
) -> bool:
|
||||
"""Evaluate narrowly scoped continuation for declared local MCP tools.
|
||||
|
||||
Allows a declared local MCP write or read tool when the tool is verified
|
||||
against an explicitly configured and connected stdio MCP server, and the
|
||||
only untrusted sources observed in the run are declared local MCP tools
|
||||
belonging to the exact same MCP server (plus server-owned control plane
|
||||
metadata).
|
||||
"""
|
||||
if not isinstance(tool_name, str) or not tool_name:
|
||||
return False
|
||||
parts = tool_name.split("__", 2)
|
||||
if len(parts) != 3 or parts[0] != "mcp" or not parts[1] or not parts[2]:
|
||||
return False
|
||||
server_id = parts[1]
|
||||
|
||||
from src.mcp_manager import McpManager
|
||||
if McpManager.is_builtin(server_id):
|
||||
return False
|
||||
|
||||
if not _is_declared_local_mcp_tool(tool_name, server_id=server_id):
|
||||
return False
|
||||
|
||||
capabilities = capabilities_for_action(tool_name, content)
|
||||
if not capabilities.known:
|
||||
return False
|
||||
if capabilities.effects & _PROHIBITED_CONTINUATION_EFFECTS:
|
||||
return False
|
||||
if capabilities.effects - {ToolEffect.WRITE_PRIVATE, ToolEffect.READ_PRIVATE}:
|
||||
return False
|
||||
|
||||
manager = _current_mcp_manager()
|
||||
if manager is None:
|
||||
return False
|
||||
if manager.is_builtin(server_id):
|
||||
return False
|
||||
|
||||
conn = manager.get_server_status(server_id)
|
||||
if conn.get("status") != "connected":
|
||||
return False
|
||||
if conn.get("transport") != "stdio":
|
||||
return False
|
||||
|
||||
resource_id = manager.resource_identity(tool_name)
|
||||
if resource_id is None or resource_id.namespace != "mcp":
|
||||
return False
|
||||
if resource_id.endpoint_id != f"stdio:{server_id}":
|
||||
return False
|
||||
|
||||
tools_list = manager._tools.get(server_id, [])
|
||||
tool_row = next((r for r in tools_list if r.get("name") == parts[2]), None)
|
||||
if not tool_row:
|
||||
return False
|
||||
ann = tool_row.get("annotations") or {}
|
||||
destructive = (
|
||||
ann.get("destructiveHint")
|
||||
if isinstance(ann, dict)
|
||||
else getattr(ann, "destructiveHint", None)
|
||||
)
|
||||
if destructive is True:
|
||||
return False
|
||||
|
||||
from src.agent_runtime.authority import active_request_authority, ExactOperation, RequestAuthority
|
||||
authority = active_request_authority()
|
||||
if not isinstance(authority, RequestAuthority):
|
||||
return False
|
||||
if authority is not None:
|
||||
try:
|
||||
operation_input = content if isinstance(content, str) else "{}"
|
||||
operation = ExactOperation.normalize(tool_name, operation_input)
|
||||
if not authority.permits(operation):
|
||||
return False
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
if resource_id not in authority.backend_resources:
|
||||
return False
|
||||
|
||||
sources = list(external_sources or ())
|
||||
if not sources:
|
||||
return False
|
||||
|
||||
from src.mcp_manager import mcp_tool_is_readonly
|
||||
has_local_tool = False
|
||||
for source in sources:
|
||||
if source in _SERVER_OWNED_CONTROL_PLANE_SOURCES:
|
||||
continue
|
||||
if not _is_declared_local_mcp_tool(source, server_id=server_id):
|
||||
return False
|
||||
source_caps = capabilities_for_tool(source)
|
||||
if not source_caps.known or (source_caps.effects & _PROHIBITED_CONTINUATION_EFFECTS):
|
||||
return False
|
||||
if source_caps.effects - {ToolEffect.READ_PRIVATE, ToolEffect.WRITE_PRIVATE}:
|
||||
return False
|
||||
|
||||
source_resource = manager.resource_identity(source)
|
||||
if source_resource is None or source_resource.endpoint_id != resource_id.endpoint_id:
|
||||
return False
|
||||
if not str(source_resource.endpoint_id).startswith("stdio:"):
|
||||
return False
|
||||
|
||||
source_parts = source.split("__", 2)
|
||||
source_row = next((r for r in tools_list if r.get("name") == source_parts[2]), None)
|
||||
if not source_row:
|
||||
return False
|
||||
if _is_declared_local_mcp_read_tool(source, server_id=server_id) and not mcp_tool_is_readonly(source_row):
|
||||
return False
|
||||
|
||||
has_local_tool = True
|
||||
|
||||
return has_local_tool
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolRunSecurityContext:
|
||||
"""Server-owned integrity state for one agent run."""
|
||||
@@ -1087,19 +804,6 @@ class ToolRunSecurityContext:
|
||||
# Driven by a bearer API token, not a person at a browser. Privileged
|
||||
# tools are refused outright and no approval can lift that.
|
||||
delegated_credential: bool = False
|
||||
explicit_untrusted_seen: bool = False
|
||||
prompt_external_untrusted_seen: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.external_untrusted_context_seen and (
|
||||
not self.external_sources
|
||||
or any(
|
||||
not _is_declared_local_mcp_tool(s)
|
||||
and s not in _SERVER_OWNED_CONTROL_PLANE_SOURCES
|
||||
for s in self.external_sources
|
||||
)
|
||||
):
|
||||
self.prompt_external_untrusted_seen = True
|
||||
|
||||
def observe_messages(self, messages: Iterable[dict]) -> None:
|
||||
"""Apply server-owned chat scope and promote untrusted prompt context."""
|
||||
@@ -1130,10 +834,8 @@ class ToolRunSecurityContext:
|
||||
if source and source not in self.external_sources:
|
||||
self.external_sources.append(source)
|
||||
self.external_untrusted_context_seen = True
|
||||
self.prompt_external_untrusted_seen = True
|
||||
if messages_contain_external_untrusted_context(message_list):
|
||||
self.external_untrusted_context_seen = True
|
||||
self.prompt_external_untrusted_seen = True
|
||||
|
||||
def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision:
|
||||
# Checked before the bypasses below, because neither may lift it, and
|
||||
@@ -1156,20 +858,6 @@ class ToolRunSecurityContext:
|
||||
if not self.external_untrusted_context_seen:
|
||||
return ToolGateDecision(True)
|
||||
|
||||
# Scoped continuation exception for explicitly declared local MCP tools:
|
||||
# A declared write or read tool on the same MCP server is allowed if
|
||||
# the run's only untrusted context was introduced by declared local
|
||||
# tools on that server, without any explicit/error taint or external
|
||||
# message context.
|
||||
if (
|
||||
not self.prompt_external_untrusted_seen
|
||||
and not self.explicit_untrusted_seen
|
||||
and _is_authorized_local_mcp_continuation(
|
||||
tool_name, self.external_sources, content=content
|
||||
)
|
||||
):
|
||||
return ToolGateDecision(True)
|
||||
|
||||
# Skills and other server-owned descriptors are control-plane metadata
|
||||
# rather than external result content; web/document/tool-result taint
|
||||
# still gates actions that can mutate state or execute code.
|
||||
@@ -1218,11 +906,6 @@ class ToolRunSecurityContext:
|
||||
if not tool_result_should_arm_gate(tool_name, result, content):
|
||||
return
|
||||
self.external_untrusted_context_seen = True
|
||||
if isinstance(result, dict) and (
|
||||
result.get("untrusted_content") is True
|
||||
or not tool_result_is_successful(result)
|
||||
):
|
||||
self.explicit_untrusted_seen = True
|
||||
if isinstance(tool_name, str) and tool_name not in self.external_sources:
|
||||
self.external_sources.append(tool_name)
|
||||
|
||||
|
||||
+4
-21
@@ -1917,17 +1917,10 @@ async def execute_tool_block(
|
||||
)
|
||||
|
||||
if isinstance(security_context, ToolRunSecurityContext) and not approval_claimed:
|
||||
if isinstance(authority, RequestAuthority):
|
||||
with bind_request_authority(authority):
|
||||
decision = security_context.decision_for(
|
||||
getattr(block, "tool_type", None),
|
||||
getattr(block, "content", None),
|
||||
)
|
||||
else:
|
||||
decision = security_context.decision_for(
|
||||
getattr(block, "tool_type", None),
|
||||
getattr(block, "content", None),
|
||||
)
|
||||
decision = security_context.decision_for(
|
||||
getattr(block, "tool_type", None),
|
||||
getattr(block, "content", None),
|
||||
)
|
||||
if not decision.allowed:
|
||||
logger.warning(
|
||||
"External-context policy blocked tool=%r",
|
||||
@@ -2562,16 +2555,6 @@ def _compact_binary_like_output(value: object) -> str:
|
||||
|
||||
def format_tool_result(description: str, result: Dict) -> str:
|
||||
"""Format a tool result into text for feeding back to the LLM."""
|
||||
if (
|
||||
description.startswith("manage_documents:")
|
||||
and isinstance(result.get("document"), dict)
|
||||
and "response" in result
|
||||
):
|
||||
# Document reads already budget their complete page, anchor and
|
||||
# next_offset notice. A heading or a second JSON copy of the content
|
||||
# would exhaust that budget and obscure the continuation again.
|
||||
return _truncate(result["response"])
|
||||
|
||||
parts = [f"### {description}"]
|
||||
|
||||
if "stdout" in result:
|
||||
|
||||
@@ -6440,12 +6440,6 @@ def resolve_turn_contract(*, capabilities: Iterable[str], schemas: Iterable[dict
|
||||
if 'search_emails' in selected:
|
||||
selected.update({'read_email', 'download_attachment', 'list_email_accounts'})
|
||||
selected.update(canonical_tool(n) for n in always_available_tools)
|
||||
# Offer only dynamic MCP operations already sealed by trusted request
|
||||
# admission. Tool discovery and model routing never create these grants.
|
||||
from src.agent_runtime.authority import active_request_authority
|
||||
sealed_authority = active_request_authority()
|
||||
if sealed_authority is not None:
|
||||
selected.update(g.tool for g in sealed_authority.grants if g.tool.startswith("mcp__"))
|
||||
selected.update(canonical_tool(n) for n in warm_tools if str(n or "").strip())
|
||||
# Controls are neutral; enabling Web is permission, never a requested family.
|
||||
if selected:
|
||||
|
||||
+3
-28
@@ -23,7 +23,6 @@ from typing import Dict, List, Optional, Tuple
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from src.research_utils import strip_thinking
|
||||
from src.report_math import ReportMathExtension
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import markdown
|
||||
@@ -48,7 +47,6 @@ for _t in ("td", "th"):
|
||||
_REPORT_ALLOWED_ATTRS.setdefault(_t, set()).add("align")
|
||||
_REPORT_ALLOWED_ATTRS.setdefault("a", set()).update({"href", "title", "target", "rel"})
|
||||
_REPORT_ALLOWED_ATTRS.setdefault("img", set()).update({"src", "alt", "title"})
|
||||
_REPORT_ALLOWED_ATTRS.setdefault("span", set()).add("data-display")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -81,7 +79,7 @@ def _md_to_html(md_text: str) -> str:
|
||||
md_text = _autolink_urls(md_text)
|
||||
result = markdown.markdown(
|
||||
md_text,
|
||||
extensions=["extra", "codehilite", "toc", "tables", "sane_lists", "md_in_html", ReportMathExtension()],
|
||||
extensions=["extra", "codehilite", "toc", "tables", "sane_lists", "md_in_html"],
|
||||
extension_configs={
|
||||
"codehilite": {"css_class": "code", "guess_lang": False},
|
||||
"toc": {"marker": "", "toc_depth": "2-3"},
|
||||
@@ -1023,8 +1021,6 @@ body[class^="standard-report-"] .report-section:nth-of-type(even) {{
|
||||
.content code {{ font-family: var(--font-mono); font-size: 0.86em; background: var(--bg-surface-alt); padding: 0.15em 0.4em; border-radius: 4px; }}
|
||||
.content pre {{ background: var(--bg-surface-alt); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem 1.5rem; overflow-x: auto; margin: 1.25rem 0; font-size: 0.86rem; line-height: 1.6; }}
|
||||
.content pre code {{ background: none; padding: 0; }}
|
||||
/* Match the chat renderer's handling of wide display equations. */
|
||||
.content .katex-display {{ overflow-x: auto; overflow-y: hidden; }}
|
||||
.content table {{ width: 100%; border-collapse: collapse; margin: 1.25rem 0; font-size: 0.9rem; border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-sm); }}
|
||||
.content th {{ text-align: left; padding: 0.7rem 1rem; background: var(--accent-bg); font-weight: 600; border-bottom: 2px solid var(--border-strong); }}
|
||||
.content td {{ padding: 0.6rem 1rem; border-bottom: 1px solid var(--border); vertical-align: top; }}
|
||||
@@ -1438,12 +1434,6 @@ body[class^="standard-report-"] .report-section:nth-of-type(even) {{
|
||||
|
||||
<script>
|
||||
(function() {{
|
||||
// Reuse the chat renderer and vendored assets only for reports with math.
|
||||
if (document.querySelector('.ody-math-pending')) {{
|
||||
import('/static/js/markdown.js').then(function(markdown) {{
|
||||
return markdown.renderMath(document);
|
||||
}}).catch(function(err) {{ console.warn('Report math render error:', err); }});
|
||||
}}
|
||||
// ESC closes the report tab. window.close() works when the tab was
|
||||
// opened via window.open() (which is how the panel launches it). If the
|
||||
// browser blocks self-close (rare — e.g. report opened by direct URL),
|
||||
@@ -2845,23 +2835,11 @@ def generate_visual_report(
|
||||
spare_images = section_pool[_consumed:]
|
||||
|
||||
# Build TOC
|
||||
math_labels = {}
|
||||
if 'ody-math-pending' in report_html:
|
||||
label_soup = BeautifulSoup(report_html, "html.parser")
|
||||
for heading in label_soup.select('h2[id], h3[id]'):
|
||||
if not heading.select_one('.ody-math-pending'):
|
||||
continue
|
||||
for child in list(heading.find_all(True)):
|
||||
if 'ody-math-pending' in child.get('class', []):
|
||||
child['data-display'] = 'false'
|
||||
else:
|
||||
child.unwrap()
|
||||
math_labels[heading['id']] = heading.decode_contents()
|
||||
toc_lines = []
|
||||
for h in headings:
|
||||
depth_class = f"depth-{h['level']}"
|
||||
toc_lines.append(
|
||||
f'<a href="#{h["slug"]}" class="{depth_class}">{math_labels.get(h["slug"], html.escape(h["text"]))}</a>'
|
||||
f'<a href="#{h["slug"]}" class="{depth_class}">{html.escape(h["text"])}</a>'
|
||||
)
|
||||
toc_html = "\n ".join(toc_lines) if toc_lines else ""
|
||||
|
||||
@@ -2870,11 +2848,8 @@ def generate_visual_report(
|
||||
options = []
|
||||
for h in headings:
|
||||
prefix = "\u2014 " if h["level"] == 3 else ""
|
||||
text = h["text"]
|
||||
if h["slug"] in math_labels:
|
||||
text = BeautifulSoup(math_labels[h["slug"]], "html.parser").get_text()
|
||||
options.append(
|
||||
f'<option value="{h["slug"]}">{prefix}{html.escape(text)}</option>'
|
||||
f'<option value="{h["slug"]}">{prefix}{html.escape(h["text"])}</option>'
|
||||
)
|
||||
mobile_toc_html = (
|
||||
'<div class="mobile-section-nav">'
|
||||
|
||||
+3
-13
@@ -561,21 +561,10 @@ function initializeEventListeners() {
|
||||
|
||||
// Export: PDF
|
||||
const exportPdfBtn = el('export-pdf-btn');
|
||||
let preparingPdf = false;
|
||||
if (exportPdfBtn) {
|
||||
exportPdfBtn.addEventListener('click', async (e) => {
|
||||
exportPdfBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
exportMenu.classList.remove('open');
|
||||
if (preparingPdf) return;
|
||||
preparingPdf = true;
|
||||
try {
|
||||
await sessionModule.prepareHistoryForPrint();
|
||||
} catch (error) {
|
||||
uiModule.showError(error?.message || 'Could not prepare the chat for PDF export.');
|
||||
return;
|
||||
} finally {
|
||||
preparingPdf = false;
|
||||
}
|
||||
const meta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
|
||||
const sessionName = meta ? meta.name : 'Odysseus Chat';
|
||||
const originalTitle = document.title;
|
||||
@@ -1319,7 +1308,8 @@ function initializeEventListeners() {
|
||||
const toolThemeBtn = el('tool-theme-btn');
|
||||
if (toolThemeBtn) {
|
||||
toolThemeBtn.addEventListener('click', () => {
|
||||
themeModule.openPopup();
|
||||
const tm = document.getElementById('theme-modal');
|
||||
if (tm) tm.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6481,13 +6481,6 @@ body.bg-pattern-ascii-fireflies {
|
||||
display:flex; justify-content:flex-end; gap:8px; padding-top:6px;
|
||||
border-top:1px solid var(--border); margin-top:4px;
|
||||
}
|
||||
/* The global `button { height:32px }` would hold a wrapped label (e.g.
|
||||
"Delete chat and images") to one line's height, so its second line
|
||||
spills out below the button. Let confirm buttons grow to fit; 32px
|
||||
stays the minimum, so single-line buttons look the same. */
|
||||
.styled-confirm-box .confirm-btn {
|
||||
height:auto; min-height:32px;
|
||||
}
|
||||
@media (max-width:768px) {
|
||||
.styled-confirm-box {
|
||||
width: 85vw;
|
||||
@@ -6523,8 +6516,10 @@ body.bg-pattern-ascii-fireflies {
|
||||
}
|
||||
.confirm-btn-secondary { background:var(--bg); color:var(--fg); }
|
||||
.confirm-btn-secondary:hover { background:var(--border); }
|
||||
#styled-confirm-ok .styled-confirm-btn-label,
|
||||
#styled-confirm-alt .styled-confirm-btn-label,
|
||||
#styled-confirm-ok {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
#styled-confirm-cancel .styled-confirm-cancel-label {
|
||||
position:relative;
|
||||
top:2px;
|
||||
|
||||
@@ -1701,7 +1701,6 @@
|
||||
|
||||
/* ── Print / PDF Export ── */
|
||||
@media print {
|
||||
html, body { height: auto !important; overflow: visible !important; }
|
||||
body { background: #fff !important; color: #000 !important; }
|
||||
#sidebar, .sidebar, #icon-rail, .hamburger-btn, #sidebar-backdrop, .chat-input-bar, .input-bar-wrapper,
|
||||
#welcome-screen, .chat-top-bar, .chat-meta-overlay, .msg-footer,
|
||||
|
||||
@@ -52,45 +52,6 @@
|
||||
gap: 8px;
|
||||
}
|
||||
.admin-user-name { font-size: 13px; font-weight: 500; }
|
||||
/* Inline endpoint rename: the input keeps the name's typography so the row doesn't shift */
|
||||
.adm-ep-rename-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0 2px;
|
||||
color: inherit;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.adm-ep-rename-btn:hover,
|
||||
.adm-ep-rename-btn:focus-visible { opacity: 0.9; }
|
||||
@media (hover: hover) {
|
||||
.adm-ep-rename-btn { opacity: 0; }
|
||||
.admin-user-row:hover .adm-ep-rename-btn { opacity: 0.45; }
|
||||
.admin-user-row .adm-ep-rename-btn:hover,
|
||||
.adm-ep-rename-btn:focus-visible { opacity: 0.9; }
|
||||
}
|
||||
.admin-user-row input.adm-ep-rename-input {
|
||||
width: auto;
|
||||
field-sizing: content;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
padding: 0 0 1px;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--red);
|
||||
border-radius: 0;
|
||||
outline: none;
|
||||
}
|
||||
.admin-user-info:has(.adm-ep-name, .adm-ep-rename-input) { min-width: 0; }
|
||||
.adm-ep-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Privilege panel */
|
||||
.admin-priv-panel {
|
||||
max-height: 600px;
|
||||
|
||||
@@ -264,42 +264,7 @@
|
||||
/* The brush controls (Color/Size/preset/caret) hug their content; only the
|
||||
trailing slider section grows, so the Opacity/Flow/Softness group sits
|
||||
right after the caret instead of leaving a wide gap. */
|
||||
.ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; flex-direction: row; align-items: center; gap: 8px; }
|
||||
.ge-controls--in-options #ge-brush-softness-row input[type="range"] { width: 88px; flex: 0 0 88px; }
|
||||
#ge-brush-softness-row {
|
||||
display: grid !important;
|
||||
grid-template-columns: 14px max-content 88px;
|
||||
column-gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
#ge-brush-softness-row:has(> .ge-slider-value) {
|
||||
grid-template-columns: 14px max-content 88px 6ch;
|
||||
}
|
||||
#ge-brush-softness-row label {
|
||||
flex: 0 0 auto;
|
||||
min-width: max-content;
|
||||
gap: 6px;
|
||||
}
|
||||
#ge-brush-softness-row #ge-brush-softness-label {
|
||||
position: static;
|
||||
min-width: 6ch;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ge-controls--in-options #ge-brush-controls .ge-brush-size-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(6ch, max-content) 96px auto;
|
||||
column-gap: 8px;
|
||||
}
|
||||
.ge-controls--in-options .ge-brush-size-row label,
|
||||
.ge-controls--in-options .ge-brush-size-row .ge-size-label {
|
||||
min-width: 6ch;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ge-controls--in-options #ge-brush-controls { flex: 0 0 auto; }
|
||||
.ge-controls--in-options .ge-control-row {
|
||||
margin: 0 !important;
|
||||
flex: 0 0 auto;
|
||||
@@ -4642,8 +4607,6 @@ body.ge-editor-open textarea.ge-direct-text-editor {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.ge-filter-modal {
|
||||
max-height: calc(100dvh - 40px);
|
||||
overflow-y: auto;
|
||||
width: 240px;
|
||||
background: color-mix(in srgb, var(--panel) 95%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
@@ -4668,9 +4631,6 @@ body.ge-editor-open textarea.ge-direct-text-editor {
|
||||
gap: 2px;
|
||||
}
|
||||
.ge-filter-row label {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
@@ -9772,28 +9732,6 @@ body.ge-editor-open .tour-hint {
|
||||
margin-left: auto;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.ge-submenu .dropdown-item-compact {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
max-height: 28px;
|
||||
padding: 0 10px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ge-submenu .dropdown-item-compact svg {
|
||||
flex-shrink: 0;
|
||||
max-height: 16px;
|
||||
}
|
||||
@media (max-width: 820px) {
|
||||
.ge-submenu .dropdown-item-compact {
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
}
|
||||
}
|
||||
.ge-submenu {
|
||||
display: none;
|
||||
position: absolute;
|
||||
|
||||
@@ -339,13 +339,6 @@ mark.doc-find-mark {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
/* The container now fills the body exactly, so its inline 2px top margin
|
||||
(spacing for the grid view) would push it 2px past the body and add a
|
||||
second, outer scrollbar beside the inspector's own. !important is needed
|
||||
to beat the inline style. */
|
||||
.gallery-images-container:has(> #gallery-detail[style*="flex"]) {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
}
|
||||
.gallery-detail-image:hover .gallery-detail-rotate { opacity: 0.85; }
|
||||
.gallery-detail-nav-disabled {
|
||||
|
||||
+23
-43
@@ -247,32 +247,32 @@
|
||||
real request is discarded and the font fetched a second time. -->
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
|
||||
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/02-compare.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/04-memory.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/05-documents.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/07-documents.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/08-skills.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/09-gallery.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/10-cookbook.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/11-tasks.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/12-gallery.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/13-image-editor.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/14-email.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/15-notes.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/16-calendar.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/17-research.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/02-compare.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/04-memory.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/05-documents.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/07-documents.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/08-skills.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/09-gallery.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/10-cookbook.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/11-tasks.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/12-gallery.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/13-image-editor.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/14-email.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/15-notes.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/16-calendar.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/17-research.css?v=20260929csssplit">
|
||||
<!-- Panel stylesheets split out of style.css for maintainability. They are
|
||||
eager and their order is load-bearing: the cascade is the concatenation of
|
||||
these files in this order, so they must stay in this order here and in the
|
||||
PRECACHE list in sw.js. None of them is lazy-loaded. -->
|
||||
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20261021confirmalign1">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20260929csssplit">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20260929csssplit">
|
||||
<link rel="modulepreload" href="/static/js/chat.js?v=20260917toolttft1">
|
||||
<link rel="modulepreload" href="/static/js/ui.js?v=20260916largetoolscroll1">
|
||||
<link rel="modulepreload" href="/static/js/sessions.js">
|
||||
@@ -1677,10 +1677,6 @@
|
||||
<label class="settings-label">Model</label>
|
||||
<select id="set-imgModelSelect" class="settings-select"><option value="">Auto-detect</option></select>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<label class="settings-label" for="set-imgEditModelSelect">Image edit model</label>
|
||||
<select id="set-imgEditModelSelect" class="settings-select"><option value="">Same as generation</option></select>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;">
|
||||
<label class="settings-label">Quality</label>
|
||||
<select id="set-imgQualitySelect" class="settings-select">
|
||||
@@ -2463,22 +2459,6 @@
|
||||
<label class="settings-label">Max steps per message</label>
|
||||
<input id="set-agentMaxRounds" type="text" inputmode="numeric" placeholder="20" class="settings-select" style="width:120px;">
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Tool approval</label>
|
||||
<select id="set-toolApprovalMode" class="settings-select">
|
||||
<option value="ask">Always ask</option>
|
||||
<option value="off">Off (YOLO)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="admin-toggle-sub" style="margin-top:4px;">
|
||||
Auto-approve turns off the tool approval gate (same control as the
|
||||
ODYSSEUS_TOOL_APPROVAL_GATE env var). Bash, host_shell, send_email and
|
||||
delete_email then run without a confirmation even after the agent has
|
||||
seen untrusted external content (email, web pages, documents), so a
|
||||
prompt-injected instruction can reach them directly.
|
||||
</div>
|
||||
<div id="set-toolApprovalEnvNote" class="admin-toggle-sub" style="margin-top:4px;display:none;color:var(--red);"></div>
|
||||
<div id="set-agentMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2709,7 +2689,7 @@
|
||||
<script type="module" src="/static/js/censor.js"></script>
|
||||
<script type="module" src="/static/js/settings.js?v=20260912writingstyle3"></script>
|
||||
<script type="module" src="/static/js/assistant.js?v=20260912firefoxjscleanup1"></script>
|
||||
<script type="module" src="/static/app.js?v=20261021confirmalign1"></script> <!-- app.js must be LAST -->
|
||||
<script type="module" src="/static/app.js?v=20260929csssplit"></script> <!-- app.js must be LAST -->
|
||||
<script type="module" src="/static/js/init.js?v=20260829chatstyle12"></script>
|
||||
<script type="module" src="/static/js/a11y.js"></script>
|
||||
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1').catch(()=>{});}</script>
|
||||
|
||||
+1
-66
@@ -1074,8 +1074,7 @@ async function loadEndpoints() {
|
||||
<span class="adm-check-dot adm-endpoint-select-dot" aria-hidden="true"></span>
|
||||
</label>` : ''}
|
||||
<span class="adm-ep-row-logo" style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;flex-shrink:0;opacity:0.9;">${providerLogoFromUrl(ep.base_url) || ''}</span>
|
||||
<span class="admin-user-name adm-ep-name" title="${esc(epTitle)}">${esc(epTitle)}</span>
|
||||
${_endpointSelectMode || (isChatgptAccount && ep.account_label) ? '' : `<button type="button" class="adm-ep-rename-btn" data-adm-rename-ep="${esc(ep.id)}" data-adm-ep-title="${esc(epTitle)}" title="Rename" aria-label="Rename endpoint"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></button>`}
|
||||
<span class="admin-user-name">${esc(epTitle)}</span>
|
||||
${ep.model_type === 'image' ? '<span class="admin-badge" style="background:color-mix(in srgb, var(--accent) 20%, transparent);color:var(--accent);">Image</span>' : ''}
|
||||
${kindLabel ? `<span class="admin-badge">${esc(kindLabel)}</span>` : ''}
|
||||
${statusBadge}
|
||||
@@ -1205,70 +1204,6 @@ async function loadEndpoints() {
|
||||
loadEndpoints();
|
||||
});
|
||||
});
|
||||
queryAll('[data-adm-rename-ep]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const nameEl = btn.previousElementSibling;
|
||||
if (!nameEl || !nameEl.classList.contains('admin-user-name')) return;
|
||||
const original = btn.dataset.admEpTitle || '';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = original;
|
||||
input.placeholder = 'Endpoint name';
|
||||
input.setAttribute('aria-label', 'Endpoint name');
|
||||
input.maxLength = 64;
|
||||
input.autocomplete = 'off';
|
||||
input.setAttribute('data-bwignore', '');
|
||||
input.className = 'admin-user-name adm-ep-rename-input';
|
||||
input.style.minWidth = `min(${nameEl.getBoundingClientRect().width}px, 100%)`;
|
||||
nameEl.replaceWith(input);
|
||||
btn.style.visibility = 'hidden';
|
||||
input.focus();
|
||||
input.select();
|
||||
let done = false;
|
||||
const restore = () => {
|
||||
input.replaceWith(nameEl);
|
||||
btn.style.visibility = '';
|
||||
};
|
||||
const commit = async () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
const name = input.value.trim();
|
||||
if (!name || name === original) { restore(); return; }
|
||||
const epId = btn.dataset.admRenameEp;
|
||||
if (data.some(ep => String(ep.id) !== epId && (ep.name || '').trim().toLowerCase() === name.toLowerCase())) {
|
||||
uiModule.showError(`An endpoint named "${name}" already exists`);
|
||||
done = false;
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
input.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`/api/model-endpoints/${encodeURIComponent(epId)}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to rename endpoint');
|
||||
}
|
||||
} catch (err) {
|
||||
uiModule.showError(err.message || 'Failed to rename endpoint');
|
||||
restore();
|
||||
return;
|
||||
}
|
||||
await _refreshAfterEndpointChange();
|
||||
loadEndpoints();
|
||||
};
|
||||
input.addEventListener('blur', commit);
|
||||
input.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); }
|
||||
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); done = true; restore(); }
|
||||
});
|
||||
});
|
||||
});
|
||||
queryAll('[data-adm-copy-url]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
+91
-16
@@ -400,13 +400,9 @@ function _eventsForDay(dateStr) {
|
||||
}
|
||||
// Multi-day timed events: show on each day they span
|
||||
const startDate = _localDateOf(e.dtstart);
|
||||
const start = new Date(e.dtstart);
|
||||
const end = new Date(e.dtend || e.dtstart);
|
||||
// DTEND is exclusive, including at local midnight and across DST.
|
||||
const endDate = end > start
|
||||
? _localDateOf(new Date(end.getTime() - 1).toISOString())
|
||||
: startDate;
|
||||
return startDate <= dateStr && endDate >= dateStr;
|
||||
const endDate = _localDateOf(e.dtend);
|
||||
if (startDate !== endDate) return startDate <= dateStr && endDate >= dateStr;
|
||||
return startDate === dateStr;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1162,25 +1158,51 @@ async function _renderMonth() {
|
||||
const dow = _weekStartSun ? first.getDay() : (first.getDay() + 6) % 7;
|
||||
const gs = new Date(y, m, 1 - dow);
|
||||
|
||||
// Use the existing inline event rows for every day an event covers.
|
||||
const multiDay = _events.filter(e => {
|
||||
if (!_eventVisible(e)) return false;
|
||||
const startD = new Date(e.dtstart), endD = new Date(e.dtend);
|
||||
return Math.round((endD - startD) / 86400000) > 1 || (!e.all_day && _localDateOf(e.dtstart) !== _localDateOf(e.dtend));
|
||||
});
|
||||
const multiUids = new Set(multiDay.map(e => e.uid));
|
||||
|
||||
// Render 6 week rows. Each row is a positioned container that holds
|
||||
// 7 day cells AND any multi-day bars that span the row, drawn as an
|
||||
// absolute overlay on top of the cells. This avoids the old "each
|
||||
// bar lives inside its start cell and gets clipped at the cell edge"
|
||||
// problem so a multi-day event reads as one continuous line across
|
||||
// every day it covers.
|
||||
for (let row = 0; row < 6; row++) {
|
||||
h += '<div class="cal-week-row" style="--bars:0">';
|
||||
// Count how many multi-day bars overlap any column in this row so
|
||||
// cells can reserve top padding for them — otherwise the bars
|
||||
// (drawn as absolute overlays) sit on top of the day-number and
|
||||
// single-event rows below.
|
||||
const rowStartCd0 = new Date(gs); rowStartCd0.setDate(gs.getDate() + row * 7);
|
||||
const rowEndCd0 = new Date(gs); rowEndCd0.setDate(gs.getDate() + row * 7 + 6);
|
||||
const rowStart0 = _ds(rowStartCd0);
|
||||
const rowEnd0 = _ds(rowEndCd0);
|
||||
const barsInRow = multiDay.filter(md => {
|
||||
const mdStart = _localDateOf(md.dtstart);
|
||||
const mdEnd = _localDateOf(md.dtend);
|
||||
return !(mdEnd < rowStart0 || mdStart > rowEnd0);
|
||||
}).length;
|
||||
h += `<div class="cal-week-row" style="--bars:${barsInRow}">`;
|
||||
// Day cells for this row
|
||||
for (let col = 0; col < 7; col++) {
|
||||
const i = row * 7 + col;
|
||||
const cd = new Date(gs); cd.setDate(gs.getDate() + i);
|
||||
const d = _ds(cd);
|
||||
const isOther = cd.getMonth() !== m;
|
||||
const dayEvents = _eventsForDay(d);
|
||||
const singles = _eventsForDay(d).filter(e => !multiUids.has(e.uid));
|
||||
const isWeekend = cd.getDay() === 0 || cd.getDay() === 6;
|
||||
const cls = 'cal-day' + (isOther ? ' cal-other' : '') + (isWeekend ? ' cal-weekend' : '') + (!dayEvents.length ? ' cal-empty-day' : '') + (d === today ? ' cal-today' : '') + (d === _selectedDay ? ' cal-selected' : '');
|
||||
const cls = 'cal-day' + (isOther ? ' cal-other' : '') + (isWeekend ? ' cal-weekend' : '') + (!singles.length ? ' cal-empty-day' : '') + (d === today ? ' cal-today' : '') + (d === _selectedDay ? ' cal-selected' : '');
|
||||
h += `<div class="${cls}" data-date="${d}"><span class="cal-day-num">${cd.getDate()}</span>`;
|
||||
// Reuse the same rows for single-day and continuing events.
|
||||
if (dayEvents.length) {
|
||||
// Single events — show up to 3 inline rows (multi-day events are
|
||||
// drawn separately as an overlay below).
|
||||
if (singles.length) {
|
||||
const maxInline = window.innerWidth <= 768 ? 2 : 3;
|
||||
const showInline = dayEvents.slice(0, maxInline);
|
||||
const showInline = singles.slice(0, maxInline);
|
||||
for (const ev of showInline) {
|
||||
const t = ev.all_day || _localDateOf(ev.dtstart) !== d ? '' : _fmtTime(ev.dtstart);
|
||||
const t = ev.all_day ? '' : _fmtTime(ev.dtstart);
|
||||
const _impMark = ev.importance === 'critical' ? '<span style="color:var(--red);margin-right:2px" title="critical">!!</span>'
|
||||
: ev.importance === 'high' ? '<span style="color:var(--orange,#e5a33a);margin-right:2px" title="high">!</span>' : '';
|
||||
const _typeBadge = ev.event_type ? `<span class="cal-event-type-badge" data-type="${_e(ev.event_type)}" title="${_e(ev.event_type)}"></span>` : '';
|
||||
@@ -1191,10 +1213,63 @@ async function _renderMonth() {
|
||||
<span class="cal-event-row-name">${_impMark}${_e(ev.summary)}${_eventReminderHtml(ev)}${_eventSourceHtml(ev)}</span>
|
||||
</div>`;
|
||||
}
|
||||
if (dayEvents.length > maxInline) h += `<div class="cal-event-more">+${dayEvents.length - maxInline} more</div>`;
|
||||
if (singles.length > maxInline) h += `<div class="cal-event-more">+${singles.length - maxInline} more</div>`;
|
||||
}
|
||||
h += '</div>';
|
||||
}
|
||||
// Multi-day overlay bars for this row. Stack each bar one slot below
|
||||
// the previous so two events on the same row don't overlap.
|
||||
let barSlot = 0;
|
||||
for (const md of multiDay) {
|
||||
const mdStart = _localDateOf(md.dtstart);
|
||||
const mdEnd = _localDateOf(md.dtend);
|
||||
// Compute the row's date range
|
||||
const rowStartCd = new Date(gs); rowStartCd.setDate(gs.getDate() + row * 7);
|
||||
const rowEndCd = new Date(gs); rowEndCd.setDate(gs.getDate() + row * 7 + 6);
|
||||
const rowStart = _ds(rowStartCd);
|
||||
const rowEnd = _ds(rowEndCd);
|
||||
if (mdEnd < rowStart || mdStart > rowEnd) continue; // not in this row
|
||||
// Column within the row where the bar starts and how many days it spans
|
||||
const startCol = mdStart < rowStart ? 0 : ((new Date(mdStart + 'T00:00:00') - rowStartCd) / 86400000);
|
||||
const endCol = mdEnd > rowEnd ? 6 : ((new Date(mdEnd + 'T00:00:00') - rowStartCd) / 86400000);
|
||||
const startColInt = Math.round(startCol);
|
||||
const endColInt = Math.round(endCol);
|
||||
const span = endColInt - startColInt + 1;
|
||||
// Proportional offsets for timed events that span across midnight
|
||||
// (e.g. 8 PM Mon → 5 AM Tue). Without this, an overnight serve
|
||||
// window visually fills the ENTIRE next day even when it only
|
||||
// covers a few hours. All-day events keep the full-day shape.
|
||||
// Bar visually spans from column (col+startFrac) to (col+span-1+endFrac),
|
||||
// so a 8 PM→5 AM run shows ~17% of day 1 + ~21% of day 2, not 200%.
|
||||
let startFrac = 0;
|
||||
let endFrac = 1;
|
||||
if (!md.all_day) {
|
||||
try {
|
||||
const sIso = md.dtstart || '';
|
||||
const eIso = md.dtend || '';
|
||||
const sDate = sIso ? new Date(sIso) : null;
|
||||
const eDate = eIso ? new Date(eIso) : null;
|
||||
// First-visible-day fraction (0 = midnight start). Clamp to 0
|
||||
// when the event started before this row, so the bar still
|
||||
// starts at the row's left edge.
|
||||
if (sDate && !isNaN(sDate) && mdStart >= rowStart) {
|
||||
const midnight = new Date(sDate); midnight.setHours(0, 0, 0, 0);
|
||||
startFrac = Math.max(0, Math.min(1, (sDate - midnight) / 86400000));
|
||||
}
|
||||
if (eDate && !isNaN(eDate) && mdEnd <= rowEnd) {
|
||||
const midnight = new Date(eDate); midnight.setHours(0, 0, 0, 0);
|
||||
endFrac = Math.max(0, Math.min(1, (eDate - midnight) / 86400000));
|
||||
// CalDAV end-times are exclusive: an event ending at exactly
|
||||
// 00:00 on day N really ended at end-of-day N-1, so endFrac=0
|
||||
// would visually paint a zero-width slice. Snap to a small
|
||||
// visible minimum (5% of a day) so the bar still registers.
|
||||
if (endFrac === 0) endFrac = 1;
|
||||
}
|
||||
} catch (_) { startFrac = 0; endFrac = 1; }
|
||||
}
|
||||
h += `<div class="cal-multiday" style="--col:${startColInt};--span:${span};--slot:${barSlot};--start-frac:${startFrac.toFixed(4)};--end-frac:${endFrac.toFixed(4)};background:${_calColor(md)};--cal-event-fg:${_calEventFg(md)}" draggable="true" data-uid="${_e(md.uid)}" title="${_e(md.summary)}">${_e(md.summary)}${_eventReminderHtml(md)}${_eventSourceHtml(md)}</div>`;
|
||||
barSlot++;
|
||||
}
|
||||
h += '</div>';
|
||||
}
|
||||
h += '</div>';
|
||||
|
||||
+51
-161
@@ -336,9 +336,6 @@ import { invalidateSettings } from './appConfig.js';
|
||||
let _sendInFlight = false; // covers the window from click → streaming start
|
||||
let _displayOverride = null; // Override visible user bubble text (hides injected prompts)
|
||||
let _hideUserBubble = false; // Skip user bubble entirely (e.g. continue after stop)
|
||||
// The user bubble a regenerate keeps on screen. Its resubmitted message is
|
||||
// a new DB row, so the saved id is attached here.
|
||||
let _regenKeptUserBubble = null;
|
||||
let _contextHeaderSeq = 0;
|
||||
let _contextHeaderData = null;
|
||||
let _contextHeaderBound = false;
|
||||
@@ -2588,7 +2585,6 @@ import { invalidateSettings } from './appConfig.js';
|
||||
currentHolder = null;
|
||||
|
||||
let abortCtrl = null;
|
||||
let _isAgent = false; // read by the catch block's timeout message
|
||||
let streamingTTS = false;
|
||||
try {
|
||||
// Re-enable auto-scroll when user sends a message
|
||||
@@ -2986,7 +2982,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
currentAbort = abortCtrl;
|
||||
|
||||
const _tState = Storage.loadToggleState();
|
||||
_isAgent = (_tState.mode || 'chat') === 'agent' || !!_tState.plan_mode || workspaceAgentIntent;
|
||||
const _isAgent = (_tState.mode || 'chat') === 'agent' || !!_tState.plan_mode || workspaceAgentIntent;
|
||||
|
||||
// Timeout: 6 min for research and agent mode, 3 min otherwise
|
||||
const timeoutMs = el('research-toggle').checked || _isAgent ? RESEARCH_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
|
||||
@@ -4999,15 +4995,6 @@ import { invalidateSettings } from './appConfig.js';
|
||||
refreshChatContextHeader('metrics');
|
||||
}
|
||||
|
||||
} else if (json.type === 'user_message_saved') {
|
||||
// The user bubble gets its DB id too, so regenerating or
|
||||
// editing it later cuts the chat by id, not page position.
|
||||
if (!_isBg && json.id) {
|
||||
if (_userMsgEl) _userMsgEl.dataset.dbId = json.id;
|
||||
else if (_regenKeptUserBubble) _regenKeptUserBubble.dataset.dbId = json.id;
|
||||
_regenKeptUserBubble = null;
|
||||
}
|
||||
|
||||
} else if (json.type === 'message_saved') {
|
||||
// Wire the persisted DB id onto the just-streamed bubble so it
|
||||
// can be edited/deleted immediately, without reloading the chat.
|
||||
@@ -7249,22 +7236,6 @@ import { invalidateSettings } from './appConfig.js';
|
||||
/**
|
||||
* Edit a user message: show an input, truncate to before it, resubmit the edited text.
|
||||
*/
|
||||
// Recover attached file IDs from rendered user bubbles, including older
|
||||
// image previews that were rendered before data-file-id was stamped.
|
||||
function _userMessageAttachmentIds(msgElement) {
|
||||
if (!msgElement) return [];
|
||||
const els = msgElement.querySelectorAll('[data-file-id]');
|
||||
const ids = Array.from(els).map(el => el.dataset.fileId).filter(Boolean);
|
||||
if (!ids.length) {
|
||||
const images = msgElement.querySelectorAll('.attach-image-preview img, .attach-card img');
|
||||
for (const image of images) {
|
||||
const match = (image.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
|
||||
if (match && match[1] && !ids.includes(match[1])) ids.push(match[1]);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function editUserMessage(userMsgElement) {
|
||||
const box = document.getElementById('chat-history');
|
||||
const allMsgs = Array.from(box.querySelectorAll('.msg'));
|
||||
@@ -7312,49 +7283,23 @@ import { invalidateSettings } from './appConfig.js';
|
||||
e.stopPropagation();
|
||||
const newText = editor.value.trim();
|
||||
if (!newText) return;
|
||||
const editAttachmentIds = _userMessageAttachmentIds(userMsgElement);
|
||||
|
||||
const sessionId = sessionModule.getCurrentSessionId();
|
||||
if (!sessionId) return;
|
||||
|
||||
// Everything after the original reply to this message would also be
|
||||
// destroyed. Cancelling leaves the editor open with the draft intact.
|
||||
const extraCount = allMsgs.length - msgIndex - 2;
|
||||
const choice = await _confirmTruncateIfDestructive(extraCount, 'edit');
|
||||
if (!choice) return;
|
||||
if (choice === 'fork') {
|
||||
// Branch just before this user turn and send the edit there; this
|
||||
// chat keeps the original message and everything after it.
|
||||
try {
|
||||
await _forkAndSend(msgIndex, userMsgElement.dataset.dbId || '', newText, editAttachmentIds);
|
||||
bodyEl.innerHTML = originalHTML;
|
||||
} catch (err) {
|
||||
console.error('Fork failed:', err);
|
||||
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const keepCount = msgIndex;
|
||||
// Cut at the message's database id: msgIndex counts only the messages
|
||||
// loaded in the page, so it is too small when older history has not
|
||||
// been scrolled in yet.
|
||||
const editBeforeId = userMsgElement.dataset.dbId || '';
|
||||
try {
|
||||
const editTruncate = await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(editBeforeId ? { before_msg_id: editBeforeId } : { keep_count: keepCount })
|
||||
body: JSON.stringify({ keep_count: keepCount })
|
||||
});
|
||||
if (!editTruncate.ok) throw new Error('Server error ' + editTruncate.status);
|
||||
|
||||
// Remove DOM elements from msgIndex onward
|
||||
for (let i = allMsgs.length - 1; i >= msgIndex; i--) {
|
||||
allMsgs[i].remove();
|
||||
}
|
||||
|
||||
// The edited user turn still refers to its original attachments.
|
||||
_pendingRegenAttachments = editAttachmentIds;
|
||||
// Submit the edited text
|
||||
const messageInput = uiModule.el('message');
|
||||
messageInput.value = newText;
|
||||
@@ -7378,59 +7323,6 @@ import { invalidateSettings } from './appConfig.js';
|
||||
});
|
||||
}
|
||||
|
||||
// Regenerate, resend and edit permanently delete (server-side truncate)
|
||||
// everything after the point clicked. Ask first unless nothing comes after
|
||||
// it (extraCount 0), offering a fork that leaves this chat untouched.
|
||||
// Resolves to 'proceed', 'fork', or null (cancelled).
|
||||
const _TRUNCATE_ACTIONS = {
|
||||
regenerate: { lead: 'Regenerating here', confirmText: 'Delete and regenerate' },
|
||||
resend: { lead: 'Resending this message', confirmText: 'Delete and resend' },
|
||||
edit: { lead: 'Sending this edit', confirmText: 'Delete and send' },
|
||||
};
|
||||
async function _confirmTruncateIfDestructive(extraCount, action = 'regenerate') {
|
||||
if (extraCount <= 0) return 'proceed';
|
||||
const { lead, confirmText } = _TRUNCATE_ACTIONS[action] || _TRUNCATE_ACTIONS.regenerate;
|
||||
const result = await uiModule.styledConfirm(
|
||||
`${lead} will also permanently delete the ${extraCount} ` +
|
||||
`message${extraCount === 1 ? '' : 's'} after it. This can't be undone. ` +
|
||||
`Fork from here keeps this chat as it is and continues in a new one instead.`,
|
||||
{ confirmText, alternateText: 'Fork from here', cancelText: 'Cancel', danger: true }
|
||||
);
|
||||
if (result === 'alternate') return 'fork';
|
||||
return result ? 'proceed' : null;
|
||||
}
|
||||
|
||||
// Copy the first `keepCount` messages of the current chat into a new chat
|
||||
// and switch to it. Returns the new session id; throws on failure so callers
|
||||
// that replay an action in the fork can stop before sending anything.
|
||||
// Fork by message id when the element has one (before it, or through it);
|
||||
// keepCount is only a fallback, since it counts loaded messages only.
|
||||
async function _forkSessionAt(keepCount, beforeMsgId = '', throughMsgId = '') {
|
||||
const sessionId = sessionModule.getCurrentSessionId();
|
||||
if (!sessionId) throw new Error('No active chat');
|
||||
const res = await fetch(`${API_BASE}/api/session/${sessionId}/fork`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keep_count: keepCount,
|
||||
...(beforeMsgId ? { before_msg_id: beforeMsgId } : throughMsgId ? { through_msg_id: throughMsgId } : {}) }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
await sessionModule.loadSessions();
|
||||
await sessionModule.selectSession(data.id);
|
||||
if (uiModule) uiModule.showToast(`Forked → ${data.name}`);
|
||||
return data.id;
|
||||
}
|
||||
|
||||
// Fork before a user message and send `text` there; this chat is untouched.
|
||||
// `attachments` (if given) are re-sent only once the fork has succeeded.
|
||||
async function _forkAndSend(keepCount, beforeMsgId, text, attachments) {
|
||||
await _forkSessionAt(keepCount, beforeMsgId);
|
||||
if (attachments !== undefined) _pendingRegenAttachments = attachments;
|
||||
uiModule.el('message').value = text;
|
||||
document.querySelector('.send-btn')?.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend a user message. By default this replaces from the selected user turn
|
||||
* so failed attempts do not remain in model context or SFT traces. Callers can
|
||||
@@ -7452,7 +7344,18 @@ import { invalidateSettings } from './appConfig.js';
|
||||
let text = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
|
||||
text = text.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
|
||||
|
||||
const _ids = _userMessageAttachmentIds(userMsgElement);
|
||||
// Collect file_ids attached to this user message so the resend re-carries
|
||||
// the photos / docs (and the chat handler picks up the user-edited OCR
|
||||
// text cached server-side under those file ids).
|
||||
const _attachEls = userMsgElement.querySelectorAll('[data-file-id]');
|
||||
let _ids = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean);
|
||||
if (!_ids.length) {
|
||||
const _imgs = userMsgElement.querySelectorAll('.attach-image-preview img, .attach-card img');
|
||||
for (const _im of _imgs) {
|
||||
const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
|
||||
if (_m && _m[1] && !_ids.includes(_m[1])) _ids.push(_m[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Rescue: legacy bubbles may have stored the filename as the message
|
||||
// content (artifact of earlier broken resends). Don't re-send that as
|
||||
@@ -7475,18 +7378,6 @@ import { invalidateSettings } from './appConfig.js';
|
||||
|
||||
try {
|
||||
if (replaceFromHere) {
|
||||
// Everything strictly after the immediate AI reply to this user
|
||||
// message would also be destroyed — that's the part a click on
|
||||
// Resend / "Regenerate message" doesn't expect to lose.
|
||||
const extraCount = allMsgs.length - msgIndex - 2;
|
||||
const choice = await _confirmTruncateIfDestructive(extraCount, 'resend');
|
||||
if (!choice) return;
|
||||
if (choice === 'fork') {
|
||||
// Branch just before this user turn and send it again there; this
|
||||
// chat keeps every message.
|
||||
await _forkAndSend(msgIndex, userMsgElement.dataset.dbId || '', text, _ids);
|
||||
return;
|
||||
}
|
||||
// Resend/regenerate trims history to this point before resubmitting so
|
||||
// the replacement request is the only copy the backend sees.
|
||||
const keepCount = msgIndex;
|
||||
@@ -7551,14 +7442,25 @@ import { invalidateSettings } from './appConfig.js';
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything strictly after the AI reply being regenerated would also
|
||||
// be destroyed — that's the part a click on "Regenerate from here"
|
||||
// doesn't expect to lose.
|
||||
const extraCount = allMsgs.length - aiIndex - 1;
|
||||
const choice = await _confirmTruncateIfDestructive(extraCount, 'regenerate');
|
||||
if (!choice) return;
|
||||
|
||||
const _regenIds = _userMessageAttachmentIds(userMsgEl);
|
||||
// Collect any file_ids attached to the original user message so the
|
||||
// regenerated send re-uses them. Without this the AI is regenerated on
|
||||
// text alone — photos (and the user-edited OCR text cached server-side
|
||||
// under that file_id) would be silently dropped.
|
||||
const _attachEls = userMsgEl ? userMsgEl.querySelectorAll('[data-file-id]') : [];
|
||||
let _regenIds = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean);
|
||||
// Fallback for bubbles rendered before the data-file-id stamp landed:
|
||||
// sniff the file id straight out of any `.attach-image-preview img`
|
||||
// src URLs (matches /api/upload/<id>). Otherwise an older bubble would
|
||||
// regen with zero attachments and the photo would be lost from the
|
||||
// resulting message even though the file still exists on disk.
|
||||
if (!_regenIds.length && userMsgEl) {
|
||||
const _imgs = userMsgEl.querySelectorAll('.attach-image-preview img, .attach-card img');
|
||||
for (const _im of _imgs) {
|
||||
const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
|
||||
if (_m && _m[1] && !_regenIds.includes(_m[1])) _regenIds.push(_m[1]);
|
||||
}
|
||||
}
|
||||
_pendingRegenAttachments = _regenIds;
|
||||
|
||||
// Rescue: earlier-version regens (before the dataset.raw fix) stored the
|
||||
// photo's filename as the user-message content. On a follow-up regen,
|
||||
@@ -7567,7 +7469,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
// an image file". If userText is just a bare image filename and we have
|
||||
// attachments, drop it so the OCR text (or the image bytes for vision
|
||||
// models) is what the model actually sees.
|
||||
if (userText && _regenIds.length &&
|
||||
if (userText && _pendingRegenAttachments.length &&
|
||||
/^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i.test(userText.trim())) {
|
||||
userText = '';
|
||||
}
|
||||
@@ -7575,7 +7477,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
// A photo-only message has empty user text — regen must still proceed,
|
||||
// because the attachments themselves are the message. Bail only if there
|
||||
// is no text AND no attachments to send.
|
||||
if (!userText && !_regenIds.length) {
|
||||
if (!userText && !_pendingRegenAttachments.length) {
|
||||
if (uiModule) uiModule.showError('Nothing to regenerate — the user message has no text and no attachments');
|
||||
return;
|
||||
}
|
||||
@@ -7583,20 +7485,6 @@ import { invalidateSettings } from './appConfig.js';
|
||||
const sessionId = sessionModule.getCurrentSessionId();
|
||||
if (!sessionId) return;
|
||||
|
||||
if (choice === 'fork') {
|
||||
// Branch just before the user turn and send it again there, so the new
|
||||
// chat gets a fresh reply to the same message; this chat keeps every
|
||||
// message. No variants or hidden bubble: the fork starts without them.
|
||||
try {
|
||||
await _forkAndSend(userIndex, (userMsgEl && userMsgEl.dataset.dbId) || '', userText, _regenIds);
|
||||
} catch (err) {
|
||||
if (_pendingRegenAttachments === _regenIds) _pendingRegenAttachments = null;
|
||||
console.error('Fork failed:', err);
|
||||
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Save current response as a variant
|
||||
const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || '';
|
||||
const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || '';
|
||||
@@ -7608,19 +7496,13 @@ import { invalidateSettings } from './appConfig.js';
|
||||
}
|
||||
|
||||
const keepCount = userIndex;
|
||||
// Same as resend: cut at the user message's database id, because
|
||||
// userIndex counts only the messages loaded in the page.
|
||||
const regenBeforeId = (userMsgEl && userMsgEl.dataset.dbId) || '';
|
||||
|
||||
try {
|
||||
const regenTruncate = await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(regenBeforeId ? { before_msg_id: regenBeforeId } : { keep_count: keepCount })
|
||||
body: JSON.stringify({ keep_count: keepCount })
|
||||
});
|
||||
// A refused cut (e.g. a stale id) must not stack a second reply.
|
||||
if (!regenTruncate.ok) throw new Error('Server error ' + regenTruncate.status);
|
||||
_pendingRegenAttachments = _regenIds;
|
||||
|
||||
// Keep the original user bubble, but remove every rendered trace after
|
||||
// it, including agent-thread tool history between the user and AI bubble.
|
||||
@@ -7637,14 +7519,12 @@ import { invalidateSettings } from './appConfig.js';
|
||||
_pendingVariantLabel = 'regen';
|
||||
|
||||
_hideUserBubble = true;
|
||||
_regenKeptUserBubble = userMsgEl;
|
||||
const messageInput = uiModule.el('message');
|
||||
messageInput.value = userText;
|
||||
const submitBtn = document.querySelector('.send-btn');
|
||||
if (submitBtn) submitBtn.click();
|
||||
|
||||
} catch (err) {
|
||||
if (_pendingRegenAttachments === _regenIds) _pendingRegenAttachments = null;
|
||||
console.error('Regenerate failed:', err);
|
||||
if (uiModule) uiModule.showError('Regenerate failed: ' + err.message);
|
||||
}
|
||||
@@ -7656,8 +7536,6 @@ import { invalidateSettings } from './appConfig.js';
|
||||
// File-ids carried over from the original user message during a regen, so
|
||||
// photos / OCR overrides survive into the new send. Consumed once.
|
||||
let _pendingRegenAttachments = null;
|
||||
// Read-only view for tests.
|
||||
export function _pendingRegenAttachmentIds() { return _pendingRegenAttachments; }
|
||||
|
||||
/**
|
||||
* Called after streaming completes to attach variant navigation if this was a regen.
|
||||
@@ -7797,8 +7675,20 @@ import { invalidateSettings } from './appConfig.js';
|
||||
const sessionId = sessionModule.getCurrentSessionId();
|
||||
if (!sessionId) return;
|
||||
|
||||
const keepCount = aiIndex + 1;
|
||||
|
||||
try {
|
||||
await _forkSessionAt(aiIndex + 1, '', aiMsgElement.dataset.dbId || '');
|
||||
const res = await fetch(`${API_BASE}/api/session/${sessionId}/fork`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keep_count: keepCount }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
|
||||
await sessionModule.loadSessions();
|
||||
await sessionModule.selectSession(data.id);
|
||||
if (uiModule) uiModule.showToast(`Forked → ${data.name}`);
|
||||
} catch (err) {
|
||||
console.error('Fork failed:', err);
|
||||
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
|
||||
|
||||
@@ -1762,9 +1762,7 @@ async function _fetchDependencies() {
|
||||
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
|
||||
}
|
||||
const reqBody = {
|
||||
// The server validates repo_id as a pip package spec for pip
|
||||
// installs, so it can't contain a space.
|
||||
repo_id: `${backend}-setup`,
|
||||
repo_id: `${backend} setup`,
|
||||
cmd: cmd,
|
||||
remote_host: _envState.remoteHost || undefined,
|
||||
ssh_port: _getPort(_envState.remoteHost) || undefined,
|
||||
@@ -1782,7 +1780,7 @@ async function _fetchDependencies() {
|
||||
uiModule.showToast('Run failed: ' + String(data.detail || data.error || `HTTP ${res.status}`).slice(0, 200));
|
||||
return;
|
||||
}
|
||||
const payload = { repo_id: reqBody.repo_id, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true };
|
||||
const payload = { repo_id: `${backend} setup`, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true };
|
||||
_addTask(data.session_id, `${backend} setup`, 'download', payload);
|
||||
uiModule.showToast(`Running ${backend} setup on ${targetHost}…`);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1062,6 +1062,15 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
}
|
||||
}
|
||||
|
||||
/** Switch chat to agent mode if not already */
|
||||
function _ensureAgentMode() {
|
||||
const ab = document.getElementById('mode-agent-btn');
|
||||
const cb = document.getElementById('mode-chat-btn');
|
||||
if (ab && !ab.classList.contains('active')) {
|
||||
ab.click();
|
||||
}
|
||||
}
|
||||
|
||||
export function init(apiBase) {
|
||||
API_BASE = apiBase;
|
||||
initLibrary({
|
||||
@@ -7406,6 +7415,7 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
// Doc was opened last → it goes in front of the email windows (clears the
|
||||
// email-front flag; the doc/email z-index alternation lives in CSS).
|
||||
document.body.classList.remove('email-front');
|
||||
_ensureAgentMode();
|
||||
_markDocVisibleState(_lastSessionId, 'open');
|
||||
|
||||
document.body.classList.add('doc-view');
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { imageResultGeometry } from './image-result-geometry.js';
|
||||
import { beginAIOperation, decodeAIImage } from './ai-operation.js';
|
||||
|
||||
export function wireInpaintButtons({
|
||||
@@ -185,30 +184,17 @@ export function wireInpaintButtons({
|
||||
if (!state.editorOpen) return; // user closed mid-decode
|
||||
try {
|
||||
saveState('Inpaint result');
|
||||
// Whole-image edits keep the provider's native frame; only masked
|
||||
// edits need registration with the source document's coordinates.
|
||||
// OpenAI returns at one of its allowed sizes (1024²,
|
||||
// 1024×1536, 1536×1024) which often differs from our
|
||||
// canvas. Scale to canvas size with smoothing so the
|
||||
// inpaint blends in regardless of source dims.
|
||||
const shortPrompt = (prompt || '').trim().replace(/\s+/g, ' ').slice(0, 40);
|
||||
const layerName = shortPrompt ? `Inpaint: ${shortPrompt}` : 'Inpaint Result';
|
||||
const fullFrame = data.full_frame === true;
|
||||
const geometry = imageResultGeometry(resultImg.naturalWidth, resultImg.naturalHeight, state.imgWidth, state.imgHeight);
|
||||
const resultLayer = createLayer(layerName,
|
||||
fullFrame ? geometry.width : state.imgWidth,
|
||||
fullFrame ? geometry.height : state.imgHeight);
|
||||
const resultLayer = createLayer(layerName, state.imgWidth, state.imgHeight);
|
||||
resultLayer.ctx.imageSmoothingEnabled = true;
|
||||
resultLayer.ctx.imageSmoothingQuality = 'high';
|
||||
if (fullFrame) {
|
||||
resultLayer.ctx.drawImage(resultImg, geometry.drawX, geometry.drawY, geometry.drawWidth, geometry.drawHeight);
|
||||
state.layerOffsets.set(resultLayer.id, {
|
||||
x: geometry.offsetX,
|
||||
y: geometry.offsetY,
|
||||
});
|
||||
} else {
|
||||
const scale = Math.min(state.imgWidth / resultImg.naturalWidth, state.imgHeight / resultImg.naturalHeight);
|
||||
const width = resultImg.naturalWidth * scale, height = resultImg.naturalHeight * scale;
|
||||
resultLayer.ctx.drawImage(resultImg, (state.imgWidth - width) / 2, (state.imgHeight - height) / 2, width, height);
|
||||
}
|
||||
resultLayer.ctx.drawImage(resultImg, 0, 0, state.imgWidth, state.imgHeight);
|
||||
// Snapshot the AI result + hard mask used for this run.
|
||||
if (!fullFrame) {
|
||||
const aiSnap = document.createElement('canvas');
|
||||
aiSnap.width = state.imgWidth; aiSnap.height = state.imgHeight;
|
||||
aiSnap.getContext('2d').drawImage(resultLayer.canvas, 0, 0);
|
||||
@@ -219,7 +205,6 @@ export function wireInpaintButtons({
|
||||
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, base: baseSnap, padPx };
|
||||
// Apply initial alpha = hard mask (no feather, no edge shift).
|
||||
applyInpaintFeather(resultLayer, 0, 0);
|
||||
}
|
||||
state.layers.push(resultLayer);
|
||||
state.activeLayerId = resultLayer.id;
|
||||
state.lastInpaintLayerId = resultLayer.id;
|
||||
@@ -235,10 +220,6 @@ export function wireInpaintButtons({
|
||||
}
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
if (fullFrame) {
|
||||
if (uiModule) uiModule.showToast('Image edit complete', 4500);
|
||||
return;
|
||||
}
|
||||
// Reveal post-generation Feather + Edge Stroke sliders.
|
||||
// Cap Edge Stroke at ±padPx so the slider can't ask for
|
||||
// more AI buffer than we generated.
|
||||
|
||||
@@ -54,16 +54,16 @@ export function resolveQuickEditModel() {
|
||||
label: ((opt && opt.textContent) || '').trim(),
|
||||
};
|
||||
}
|
||||
const generation = document.getElementById('ge-ai-model');
|
||||
if (generation?.value && generation.value !== '__serve_cookbook__') {
|
||||
const index = generation.value.indexOf('::');
|
||||
const model = index < 0 ? '' : generation.value.slice(index + 2);
|
||||
if (model && modelCaps(model, '', '').inpaint) {
|
||||
return { endpoint: generation.value.slice(0, index), model,
|
||||
label: generation.options[generation.selectedIndex]?.textContent?.trim() || model };
|
||||
}
|
||||
}
|
||||
return { endpoint: '', model: '', label: 'Auto (saved settings)' };
|
||||
const inp = document.getElementById('ge-ai-inpaint');
|
||||
const opt = resolveInpaintModel(inp);
|
||||
if (!opt) return { endpoint: '', model: '', label: '' };
|
||||
const raw = opt.value || '';
|
||||
const idx = raw.indexOf('::');
|
||||
return {
|
||||
endpoint: idx < 0 ? raw : raw.slice(0, idx),
|
||||
model: idx < 0 ? '' : raw.slice(idx + 2),
|
||||
label: ((opt.textContent || '').trim().replace(/^Auto\s*\((.*)\)$/, '$1')),
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve the Quick Edit AGENT (planner) model — the vision-language model
|
||||
@@ -144,19 +144,21 @@ export function modelCaps(modelId, endpointName, endpointType) {
|
||||
function syncCommandModel(value) {
|
||||
const select = document.getElementById('ge-ai-command-model');
|
||||
if (!select || !select._modelsReady) return;
|
||||
if (value && ![...select.options].some(o => !o.disabled && o.value === value && value !== '__serve_cookbook__')) {
|
||||
if (localStorage.getItem('ge-ai-command-model') === value) localStorage.removeItem('ge-ai-command-model');
|
||||
value = '';
|
||||
}
|
||||
if (select.value !== value) {
|
||||
select.value = value;
|
||||
if (select.value !== value) select.value = '';
|
||||
updateFooterEditModel();
|
||||
}
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('ge-ai-command-model-change', e => syncCommandModel(e.detail?.value || ''));
|
||||
window.addEventListener('storage', e => {
|
||||
if (e.key === 'ge-ai-command-model') syncCommandModel(e.newValue || '');
|
||||
});
|
||||
}
|
||||
globalThis.window?.addEventListener('ge:model-endpoints-updated', e => {
|
||||
window.addEventListener('ge-ai-command-model-change', e => syncCommandModel(e.detail?.value || ''));
|
||||
window.addEventListener('storage', e => {
|
||||
if (e.key === 'ge-ai-command-model') syncCommandModel(e.newValue || '');
|
||||
});
|
||||
window.addEventListener('ge:model-endpoints-updated', e => {
|
||||
document.getElementById('ge-ai-command-model')?._refreshAIModels?.(e.detail?.baseUrl || '');
|
||||
});
|
||||
|
||||
@@ -254,9 +256,9 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
const data = await res.json();
|
||||
if (listeners.signal.aborted || !root.isConnected) return;
|
||||
const modelsList = data.models || [];
|
||||
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">Auto-detect</option>';
|
||||
if (aiGenSelect) aiGenSelect.innerHTML = '<option value="">None</option>';
|
||||
if (aiInpaintSelect) aiInpaintSelect.innerHTML = '<option value="">Auto</option>';
|
||||
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Same as generation</option>';
|
||||
if (aiCommandSelect) aiCommandSelect.innerHTML = '<option value="">Auto (default)</option>';
|
||||
const perToolSelects = Array.from(document.querySelectorAll('select.ge-tool-model'));
|
||||
for (const ts of perToolSelects) ts.innerHTML = '<option value="">Auto</option>';
|
||||
let firstGen = null;
|
||||
@@ -344,15 +346,45 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
// Sentinel changes are handled by the component's delegated listener.
|
||||
if (aiCommandSelect) {
|
||||
aiCommandSelect._modelsReady = true;
|
||||
// Legacy URL migration check: legacy endpoints with raw URLs are scrubbed.
|
||||
const scrubModelUrl = (saved) => (saved && saved.startsWith('http://') ? '' : saved);
|
||||
try {
|
||||
const rawSaved = localStorage.getItem('ge-ai-command-model') || '';
|
||||
let saved = rawSaved;
|
||||
if (saved.startsWith('http://') || saved.startsWith('https://') || saved.includes('://') || saved.startsWith('::')) {
|
||||
const modelPart = saved.includes('::') ? saved.split('::').pop() : '';
|
||||
const matching = loaded.filter(l => l.model === modelPart);
|
||||
if (matching.length === 1) {
|
||||
saved = matching[0].value;
|
||||
} else {
|
||||
saved = '';
|
||||
}
|
||||
try {
|
||||
if (saved) localStorage.setItem('ge-ai-command-model', saved);
|
||||
else localStorage.removeItem('ge-ai-command-model');
|
||||
} catch {}
|
||||
}
|
||||
if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) {
|
||||
aiCommandSelect.value = saved;
|
||||
} else {
|
||||
aiCommandSelect.value = '';
|
||||
}
|
||||
if (aiCommandSelect.value !== rawSaved) {
|
||||
if (aiCommandSelect.value) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
|
||||
else localStorage.removeItem('ge-ai-command-model');
|
||||
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: aiCommandSelect.value } }));
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (aiCommandSelect._commandWired !== listeners) {
|
||||
aiCommandSelect._commandWired = listeners;
|
||||
listen(aiCommandSelect, 'change', () => {
|
||||
if (aiCommandSelect.value === '__serve_cookbook__') return;
|
||||
const val = scrubModelUrl(aiCommandSelect.value) || '';
|
||||
const val = aiCommandSelect.value || '';
|
||||
try {
|
||||
if (val) localStorage.setItem('ge-ai-command-model', aiCommandSelect.value);
|
||||
else localStorage.removeItem('ge-ai-command-model');
|
||||
} catch {}
|
||||
updateFooterEditModel();
|
||||
globalThis.window?.dispatchEvent?.(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } }));
|
||||
window.dispatchEvent(new CustomEvent('ge-ai-command-model-change', { detail: { value: val } }));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -377,7 +409,6 @@ export function wireAIModelSelectors({ container, apiBase = '', openCookbookForI
|
||||
});
|
||||
}
|
||||
_aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() };
|
||||
container.dispatchEvent(new Event('ge-image-models-ready'));
|
||||
try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {}
|
||||
} catch (e) {
|
||||
if (listeners.signal.aborted || !root.isConnected) return;
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* Background Remove (rembg) + Sharpen wiring + the live edge-cleanup
|
||||
* tuner that runs on the most-recent rembg cutout.
|
||||
*
|
||||
* rembg-run button: active layer + POST to /api/image/remove-bg with an
|
||||
* rembg-run button: flatten + POST to /api/image/remove-bg with an
|
||||
* optional hint_mask if the user has a wand/lasso selection
|
||||
* active. Updates only that layer and binds its live edge tuner;
|
||||
* other layers and their visibility remain unchanged.
|
||||
* active. After the new layer lands, hides every previously-
|
||||
* visible layer so the cutout reads cleanly, and binds the
|
||||
* live-tuner to the new layer.
|
||||
*
|
||||
* Live edge-cleanup tuner: snapshots the pristine cutout the
|
||||
* moment it lands; subsequent feather/grow slider tweaks
|
||||
@@ -64,13 +65,37 @@ export function wireRembgAndSharpen({
|
||||
// NB: edge_feather / edge_grow are applied CLIENT-side so the
|
||||
// sliders can re-tune the cutout without re-running the model.
|
||||
const btn = document.getElementById('ge-rembg-run');
|
||||
const editedLayer = await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn);
|
||||
const before = state.layers.length;
|
||||
// Snapshot which layers were visible BEFORE the run so we know
|
||||
// which to hide after a successful cutout.
|
||||
const prevVisible = state.layers.filter(l => l.visible).map(l => l.id);
|
||||
await applyImageTool('/api/image/remove-bg', payload, 'BG Removed', btn);
|
||||
// applyImageTool resolves after the returned image is decoded and the
|
||||
// new layer is inserted. Keep a short poll for defensive compatibility
|
||||
// with alternate runners that may still commit on the next frame.
|
||||
let frames = 0;
|
||||
while (state.layers.length <= before && frames < 60) {
|
||||
await new Promise(r => requestAnimationFrame(r));
|
||||
frames++;
|
||||
}
|
||||
if (state.layers.length > before) {
|
||||
const newLayer = state.layers[state.layers.length - 1];
|
||||
bindRembgLiveTuner(newLayer);
|
||||
// Auto-hide underlying layers so the user sees just the
|
||||
// cutout — the eye toggles back on if they re-enable manually.
|
||||
for (const layer of state.layers) {
|
||||
if (prevVisible.includes(layer.id) && layer.id !== newLayer.id) {
|
||||
layer.visible = false;
|
||||
}
|
||||
}
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
}
|
||||
// Reset sliders so the new cutout starts clean.
|
||||
const f = document.getElementById('ge-rembg-feather');
|
||||
const g = document.getElementById('ge-rembg-grow');
|
||||
if (f) { f.value = 0; document.getElementById('ge-rembg-feather-label').textContent = '0px'; syncRembgFeather(0); }
|
||||
if (g) { g.value = 0; document.getElementById('ge-rembg-grow-label').textContent = '0px'; syncRembgGrow(0); }
|
||||
if (editedLayer) bindRembgLiveTuner(editedLayer);
|
||||
});
|
||||
|
||||
// ── Live edge-cleanup tuner ──
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
* "Double-check AI edits" toggle. Opened from the Quick Edit gear button and
|
||||
* from the editor setup checklist; keeps the Quick Edit box itself uncluttered.
|
||||
*/
|
||||
import { readImagePreferences, saveImagePreferences } from '../settings/imagePreferences.js';
|
||||
import { updateFooterEditModel } from './ai-models.js';
|
||||
|
||||
export function createAiSettings({ container }) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'ge-ai-settings';
|
||||
@@ -22,14 +19,11 @@ export function createAiSettings({ container }) {
|
||||
<button type="button" class="ge-ai-settings-close" data-ai-settings-close aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="ge-ai-settings-body">
|
||||
<label class="ge-ai-settings-label" for="ge-ai-model">Image generation model</label>
|
||||
<select class="ge-ai-command-model" id="ge-ai-model" title="Image generation model" aria-label="Image generation model"></select>
|
||||
<label class="ge-ai-settings-label" for="ge-ai-command-model">Image edit model</label>
|
||||
<select class="ge-ai-command-model" id="ge-ai-command-model" title="Image edit model" aria-label="Image edit model"></select>
|
||||
<label class="ge-ai-settings-label" for="ge-ai-command-agent-model">AI agent (vision) model</label>
|
||||
<select class="ge-ai-command-model" id="ge-ai-command-agent-model" title="Quick Edit agent model" aria-label="Quick Edit agent model"></select>
|
||||
<p class="ge-ai-settings-note">Recommended, not required — the vision agent plans edits and can double-check results.</p>
|
||||
<p class="ge-ai-settings-note" id="ge-ai-settings-save-status" role="status"></p>
|
||||
<label class="ge-ai-settings-label" for="ge-look-strength">Look strength</label>
|
||||
<div class="ge-ai-settings-strength">
|
||||
<input type="range" id="ge-look-strength" min="0.5" max="2" step="0.1" value="1.4" aria-label="Look grade strength" />
|
||||
@@ -47,7 +41,6 @@ export function createAiSettings({ container }) {
|
||||
|
||||
function open(focusId) {
|
||||
el.hidden = false;
|
||||
syncModels();
|
||||
if (focusId) {
|
||||
const target = document.getElementById(focusId);
|
||||
if (target) {
|
||||
@@ -63,59 +56,10 @@ export function createAiSettings({ container }) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
const onKey = (e) => {
|
||||
if (e.key !== 'Escape' || el.hidden) return;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
close();
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
const onKey = (e) => { if (e.key === 'Escape' && !el.hidden) close(); };
|
||||
document.addEventListener('keydown', onKey);
|
||||
|
||||
container.appendChild(el);
|
||||
const status = el.querySelector('#ge-ai-settings-save-status');
|
||||
const modelFields = [['ge-ai-model', 'image_model'], ['ge-ai-command-model', 'image_edit_model']];
|
||||
function applyModels(settings) {
|
||||
for (const [id, key] of modelFields) {
|
||||
if (!(key in settings)) continue;
|
||||
const select = el.querySelector(`#${id}`);
|
||||
const model = settings[key] || '';
|
||||
let option = [...select.options].find(option => {
|
||||
const value = option.value;
|
||||
return model ? value.slice(value.indexOf('::') + 2) === model : value === '';
|
||||
});
|
||||
if (!option && model && select.options.length) {
|
||||
option = document.createElement('option');
|
||||
option.value = `::${model}`;
|
||||
option.textContent = `${model} (unavailable)`;
|
||||
option.disabled = true;
|
||||
select.append(option);
|
||||
}
|
||||
if (option) select.value = option.value;
|
||||
}
|
||||
updateFooterEditModel();
|
||||
}
|
||||
async function syncModels() {
|
||||
try { applyModels(await readImagePreferences()); }
|
||||
catch (error) { status.textContent = error.message; }
|
||||
}
|
||||
for (const [id, key] of modelFields) {
|
||||
el.querySelector(`#${id}`).addEventListener('change', async (event) => {
|
||||
const raw = event.target.value;
|
||||
if (raw === '__serve_cookbook__') return;
|
||||
const model = raw.includes('::') ? raw.slice(raw.indexOf('::') + 2) : '';
|
||||
try {
|
||||
status.textContent = 'Saving…';
|
||||
await saveImagePreferences({ [key]: model });
|
||||
status.textContent = 'Saved';
|
||||
} catch (error) {
|
||||
await syncModels();
|
||||
status.textContent = error.message;
|
||||
}
|
||||
});
|
||||
}
|
||||
const onPreferencesChanged = event => applyModels(event.detail);
|
||||
window.addEventListener('image-preferences-changed', onPreferencesChanged);
|
||||
container.addEventListener('ge-image-models-ready', syncModels);
|
||||
|
||||
const strengthInput = el.querySelector('#ge-look-strength');
|
||||
const strengthVal = el.querySelector('#ge-look-strength-val');
|
||||
@@ -133,9 +77,7 @@ export function createAiSettings({ container }) {
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
window.removeEventListener('image-preferences-changed', onPreferencesChanged);
|
||||
container.removeEventListener('ge-image-models-ready', syncModels);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
el.remove();
|
||||
}
|
||||
|
||||
|
||||
@@ -76,12 +76,7 @@ export function createApplyImageTool({
|
||||
if (sel.endpoint) extraPayload._endpoint = sel.endpoint;
|
||||
if (sel.model && !extraPayload._model) extraPayload._model = sel.model;
|
||||
}
|
||||
const removeBackground = endpoint === '/api/image/remove-bg';
|
||||
const targetLayer = removeBackground ? state.layers.find(layer => layer.id === state.activeLayerId) : null;
|
||||
if (removeBackground && (!targetLayer?.canvas || targetLayer.kind === 'adjustment' || targetLayer.locks?.pixels)) {
|
||||
throw new Error('Select an unlocked image layer first');
|
||||
}
|
||||
const flatCanvas = removeBackground ? targetLayer.canvas : flatten();
|
||||
const flatCanvas = flatten();
|
||||
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
|
||||
const body = { image: imageB64, ...extraPayload };
|
||||
const res = await fetch(endpoint, {
|
||||
@@ -101,23 +96,6 @@ export function createApplyImageTool({
|
||||
const img = await decodeAIImage(data.image, operation.signal);
|
||||
operation.signal.throwIfAborted();
|
||||
if (!state.editorOpen) return; // user closed mid-decode (v2 review HIGH-4)
|
||||
if (removeBackground) {
|
||||
if (!state.layers.includes(targetLayer)) return;
|
||||
saveState('Remove background');
|
||||
// The returned pixels replace the editable source, not just its preview.
|
||||
targetLayer.kind = 'raster';
|
||||
targetLayer.placed = null;
|
||||
targetLayer.text = null;
|
||||
targetLayer.shape = null;
|
||||
targetLayer.ctx.clearRect(0, 0, targetLayer.canvas.width, targetLayer.canvas.height);
|
||||
targetLayer.ctx.drawImage(img, 0, 0, targetLayer.canvas.width, targetLayer.canvas.height);
|
||||
targetLayer._adjCacheKey = null;
|
||||
targetLayer._adjFinalKey = null;
|
||||
composite();
|
||||
renderLayerPanel();
|
||||
uiModule?.showToast('Background removed');
|
||||
return targetLayer;
|
||||
}
|
||||
{
|
||||
saveState();
|
||||
const layer = createLayer(layerName, state.imgWidth, state.imgHeight);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user