Compare commits

...
Author SHA1 Message Date
yilsemandClaude Opus 4.8 212015e7c9 chore(desktop): bump AITURK IDE to 1.0.0-beta.43
Server CI and Windows package / verify (push) Successful in 5m58s
Server CI and Windows package / windows-package (push) Successful in 13m48s
Carries the codex-bridge ide/ prefix normalization fix (previous commit)
into a downloadable release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 02:14:09 +03:00
yilsemandClaude Opus 4.8 65ce4be58b fix(codex-bridge): strip a stray ide/ prefix from stored model names
Server CI and Windows package / verify (push) Successful in 4m30s
Server CI and Windows package / windows-package (push) Successful in 16m59s
A hand- or agent-edited jev-settings.json could store a model name that
already carries the gateway's own "ide/" prefix. The bridge adds "ide/"
again when it routes, producing "ide/ide/<model>" — an unknown model whose
context window the bridge reads as the 32768 default and then silently
drops the request, answering "." with a wrong "model seçildi" instead of
the chosen model. jev_settings.read()/_policy() now normalize every stored
model name (fallbackModel, watchModels, exemptModels, prefilter.target,
autoRoute.model/targets) by stripping a leading "ide/". No real
provider/model id begins with "ide/", so valid settings are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-17 02:09:30 +03:00
yilsemandClaude Opus 5.5 86a6621936 feat(chrome): let the IDE agent use the user's Chrome and beta.42
Server CI and Windows package / verify (push) Successful in 3m33s
Server CI and Windows package / windows-package (push) Successful in 7m36s
The AITURK Chrome extension now registers the user's Chrome as the desktop
backend's browser controller, the way the ChatGPT extension lets its agent
work in the user's browser. The agent's existing browser tools (navigate,
snapshot, click, type, scroll, back, press, get_images, vision) run in its
own background tabs grouped as "AITURK", or a tab the user hands over.

Backend: a local desktop lane in the browser-control broker. One controller
registered from the loopback desktop transport is offered to every desktop
session as a lane template; each session still gets its own exact scope on
first use. The lane has its own switch (browser.extension_control.local_desktop,
on by default); with no extension registered, or after it disconnects, the
normal browser backend runs unchanged. browser_vision turns the extension's
capture into the usual native/auxiliary vision result.

Bridge: forwards controller commands only to a connection that registered,
refuses unknown or oversized commands, relays results.

Extension: service-worker controller with its own native connection,
trusted chrome.debugger input (press/release only: a hover move waits 5 s on
background tabs), refusal of password and payment-card fields and of
browser, extension, file and Web Store pages, panel switch, Stop, tab
hand-over, recent actions, Alt+Shift+A and right-click "send to AITURK".
Cancelling Chrome's debugging bar stops the agent. Existing panel flows and
privacy rules are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:38:52 +03:00
yilsemandClaude Opus 5.5 5c76c24c9c feat(codex-link): choose which models fill the Codex model menu and beta.41
Server CI and Windows package / verify (push) Successful in 2m54s
Server CI and Windows package / windows-package (push) Successful in 4m50s
The Codex desktop app's model picker asks the app server for one page of
at most 100 models and never asks for the next page (a fixed limit in the
app). AITURK sent 262, so everything after the first 100 in alphabetical
provider order was out of reach from the menu (Gemini, most NanoGPT
uncensored models, Grok, ModelArk...).

- Bridge: bridge.json codex_liste {medya_gizle, efor_gizle, one_al, gizli},
  re-read when the file changes. Image/audio/video models and "-low/-high"
  copies whose base model is listed are left out by default; pinned models
  follow Codex's own models in the given order; hidden ones are removed.
  The candidate list with each model's place is written to
  log/son-liste/codex-adaylar.json for the settings screen.
- Settings > Codex Bağlama: "Models shown in Codex" with a live count of the
  100 slots, search, filter, pin/move up/hide per model and the two switches.
- The IDE keeps codex_liste when it rewrites bridge.json.
- Bridge version 21, IDE 1.0.0-beta.41.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 05:16:40 +03:00
yilsem 81ee2ca646 feat(codex-link): main model delegates every task to subagents by default
Server CI and Windows package / verify (push) Successful in 2m56s
Server CI and Windows package / windows-package (push) Successful in 7m27s
User decision (15 Oct 2026): repeating or scheduled work goes to AITURK; all other work, coding included, goes to one subagent (easy) or a subagent team or dynamic workflow (hard). The main model talks, dispatches and verifies.
2026-10-15 02:26:45 +03:00
yilsem 557e619499 feat(codex-link): AITURK work routing setting and beta.40
Server CI and Windows package / verify (push) Successful in 2m42s
Server CI and Windows package / windows-package (push) Successful in 6m21s
- Default routing rule (user decision, 15 Oct 2026): repeating/scheduled work goes to the AITURK IDE bot as a cron job; coding stays in Codex (itself or its subagents); 'Codex' or 'sen yap' keeps everything in Codex.
- Settings > Codex Bağlama: choice (recommended / send large work / never / custom text) with a text box and restore-default; written to bridge.json aiturk_hat.
- Bridge re-reads aiturk_hat when bridge.json changes: no restart. IDE apply keeps aiturk_hat instead of wiping it.
- Bridge version 20, IDE 1.0.0-beta.40. Jev untouched.
2026-10-15 02:18:18 +03:00
yilsemandClaude Opus 5.5 a7ba300448 fix(codex-bridge): close the gaps an independent review found
Server CI and Windows package / verify (push) Successful in 3m23s
Server CI and Windows package / windows-package (push) Successful in 7m9s
- One decision per real turn: a turn starts with a user message after the
  previous final answer; tool steps, messages typed mid-turn, a lone "."
  and compaction keep the combo. Works without prompt_cache_key too.
- The virtual Otomatik name never reaches the gateway (stage off or error
  -> last choice or targets.zor); prefilter.target may not be it.
- finishCheck: at most once per turn; a broken, empty or refused retry
  delivers the already approved first answer.
- Layer window filter: only trusted windows, never the last layer.
- Correction channel only on always-pass targets while checks are on;
  Sesli-Hizli-Zeki stays always-pass by default; a >256 KB SSE frame
  passes unfiltered instead of breaking the stream.
- Turn-start questions wait in the local queue no longer than their
  timeout; decision-turn memory under a lock with refreshed order.
- Dify fetch refuses redirects (the app key never moves host); the
  rules file write retries a Windows sharing violation.
- An unreadable settings file no longer breaks every request.
- TypeScript accepts only https:// URLs the bridge accepts;
  finishCheck.timeoutSeconds (unused) removed.

Tests: 310 bridge tests, 36 electron tests; side-bridge live run for the
turn, prefilter (incl. decision timeout) and finish-check paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:14:38 +03:00
yilsemandClaude Opus 5.5 7c4ef18df0 feat(codex-bridge): decision-model stages, Dify rules and beta.39
Owner request, 14 October 2026: use the decision model (Jev / Celeris) at
several stages, let Dify supply the rules, code it without new bugs.
Every new stage is switched on in Settings > Kademeli Gecis and starts
disabled, so the upgrade changes no behaviour by itself.

- Settings schema 2 (Python and TypeScript validate the same rules); a
  schema 1 file is filled from the defaults. Default chain is now
  Katman-1 -> Katman-2 -> Katman-3 with Katman-3, Chat-Model and
  bekci-sesli-bekci always passing (owner decision of the same day).
- watchModels: the Bekci correction channel follows a settings list
  instead of the hard-coded Sesli-Hizli-Zeki.
- prefilter: a predicted refusal starts the step at the chosen layer.
- autoRoute: virtual "Otomatik" model; one decision per user turn picks
  the combo, tool steps of the same turn keep it.
- finishCheck: an answer that stops early goes back to the same model once.
- A layer whose window cannot hold the request is skipped.
- policy: stage rules from a Dify workflow (karar_politikasi.py), fetched
  in the background, validated, last good copy kept; turns never wait.
- karar_sor.py: verifierCheck for the dynamic-workflow acceptance step.
- Bridge version 19, IDE 1.0.0-beta.39.

Tests: 299 bridge tests, 36 electron tests; live run on a side bridge
(port 8797) with Celeris and the AITURK gateway for every stage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:48:33 +03:00
yilsem 8f53b090f7 feat(codex-link): persist direct subscription and AITURK connection modes
Server CI and Windows package / verify (push) Successful in 2m54s
Server CI and Windows package / windows-package (push) Successful in 7m17s
2026-10-13 19:56:40 +03:00
yilsem 049c4d1338 fix(installer): preserve healthy Python runtime and Codex connections
Server CI and Windows package / verify (push) Successful in 2m47s
Server CI and Windows package / windows-package (push) Successful in 6m58s
2026-10-13 17:22:28 +03:00
yilsem 4924781e2d fix(bridge): stop replaying explicit terminal provider failures 2026-10-13 17:18:38 +03:00
77 changed files with 6457 additions and 362 deletions

No files matched your search

+6
View File
@@ -35,8 +35,12 @@ jobs:
"$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/tools/test_protected_instruction_unattended.py tests/tools/test_file_write_safety.py tests/tools/test_computer_use_approval_isolation.py tests/cli/test_cli_approval_ui.py tests/cli/test_single_query_session_finalize.py -q -p no:cacheprovider -rf "$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/tools/test_protected_instruction_unattended.py tests/tools/test_file_write_safety.py tests/tools/test_computer_use_approval_isolation.py tests/cli/test_cli_approval_ui.py tests/cli/test_single_query_session_finalize.py -q -p no:cacheprovider -rf
- name: Codex bridge regression tests - name: Codex bridge regression tests
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m unittest discover -s apps/desktop/assets/codex-bridge' run: '"$RUNNER_TEMP/ci-venv/bin/python" -m unittest discover -s apps/desktop/assets/codex-bridge'
- name: Managed Python update preservation
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/test_install_ps1_reuse_healthy_venv.py tests/test_install_ps1_python_fallback_venv.py -q -p no:cacheprovider -rf'
- name: Project scoped and bounded search regressions - name: Project scoped and bounded search regressions
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/tools/test_search_project_scope.py tests/tools/test_search_files_cpu_windows.py tests/tools/test_search_zero_match_and_multipath.py tests/tools/test_search_giant_line_containment.py tests/tools/test_search_budget_truncation.py -q -p no:cacheprovider -rf' run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/tools/test_search_project_scope.py tests/tools/test_search_files_cpu_windows.py tests/tools/test_search_zero_match_and_multipath.py tests/tools/test_search_giant_line_containment.py tests/tools/test_search_budget_truncation.py -q -p no:cacheprovider -rf'
- name: Desktop browser control lane (AITURK Chrome)
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest tests/gateway/test_browser_control_local_lane.py tests/gateway/test_browser_control_broker.py tests/gateway/test_browser_control_broker_hardening.py tests/gateway/test_browser_control_cloud.py tests/tools/test_browser_extension_router.py tests/tools/test_browser_extension_router_wiring.py -q -p no:cacheprovider -rf'
- name: Native watch and speech mux pytest regressions - name: Native watch and speech mux pytest regressions
run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest apps/desktop/assets/codex-bridge/test_bekci_watch.py apps/desktop/assets/codex-bridge/test_native_speech_mux.py apps/desktop/assets/codex-bridge/test_live_limits.py -q -p no:cacheprovider -rf' run: '"$RUNNER_TEMP/ci-venv/bin/python" -m pytest apps/desktop/assets/codex-bridge/test_bekci_watch.py apps/desktop/assets/codex-bridge/test_native_speech_mux.py apps/desktop/assets/codex-bridge/test_live_limits.py -q -p no:cacheprovider -rf'
- name: Mobile and desktop startup regression tests - name: Mobile and desktop startup regression tests
@@ -61,6 +65,8 @@ jobs:
github-server-url: https://gitea.turkservis.online github-server-url: https://gitea.turkservis.online
- name: Install locked source dependencies - name: Install locked source dependencies
run: npm ci --no-audit --no-fund run: npm ci --no-audit --no-fund
- name: Chrome extension, agent control and bridge tests
run: node --test apps/chrome/tests/agent.test.mjs apps/chrome/tests/bridge.test.mjs apps/chrome/tests/panel.test.mjs
- name: Install Windows launcher cross compiler and framework reference - name: Install Windows launcher cross compiler and framework reference
run: | run: |
set -eu set -eu
+25 -3
View File
@@ -1,7 +1,8 @@
AITURK Chrome — Windows beta 0.1.0 AITURK Chrome — Windows beta 0.2.0
GEREKSİNİMLER GEREKSİNİMLER
Windows 10/11, Chrome 116 veya üzeri, AITURK IDE 1.0.0-beta.7 veya üzeri. Windows 10/11, Chrome 116 veya üzeri, AITURK IDE 1.0.0-beta.42 veya üzeri
(ajanın Chrome'u kullanması için; konuşma paneli eski IDE'lerde de çalışır).
Eski IDE sürümleri bu eklentiye bağlanamaz. Yerel IDE açık olmalıdır. Eski IDE sürümleri bu eklentiye bağlanamaz. Yerel IDE açık olmalıdır.
İlk beta yalnızca IDE'nin etkin yerel profilini destekler; uzak sunucu/bulut İlk beta yalnızca IDE'nin etkin yerel profilini destekler; uzak sunucu/bulut
konuşmaları ve başka bilgisayardaki konuşmalar bu sürümde desteklenmez. konuşmaları ve başka bilgisayardaki konuşmalar bu sürümde desteklenmez.
@@ -27,6 +28,26 @@ Görüntülenen uzun mesajlar kısaltılabilir. Modelin mevcut bağlamı değiş
"Yenile" konuşma listesini ve seçili geçmişi tekrar okur. "Yenile" konuşma listesini ve seçili geçmişi tekrar okur.
Ctrl+Enter mesaj gönderir. Enter yeni satır ekler. Ctrl+Enter mesaj gönderir. Enter yeni satır ekler.
AJAN BU CHROME'U KULLANSIN (YENİ)
Paneldeki "Ajan bu Chrome'u kullanabilsin" anahtarı açıkken (varsayılan açık)
AITURK IDE ajanı tarayıcı işlerini bu Chrome'da yapar: sayfa açar, okur,
tıklar, yazar, kaydırır, geri gider, tuşa basar, görselleri listeler ve ekran
görüntüsüne bakar. Panel kapalı olsa da çalışır; IDE açık olmalıdır.
- Ajan yalnız kendi sekmelerinde çalışır; bunlar "AITURK" sekme grubunda,
arka planda açılır. Kendi sekmenizi vermek için panelde "Bu sekmeyi ajana
ver"e ya da sağ tıkta "Bu sekmeyi AITURK ajanına ver"e basın.
- Ajan çalışırken Chrome üstte "hata ayıklama" uyarısı gösterir. Uyarıdaki
İptal, paneldeki Durdur ya da anahtarı kapatmak ajanı hemen durdurur.
- Ajan şifre ve kart numarası alanlarına yazmaz; bu adımları size bırakır ve
bildirim gösterir. chrome://, eklenti, dosya ve Web Mağazası sayfalarına girmez.
- Anahtar kapalıyken ya da Chrome kapalıyken ajan kendi tarayıcısını kullanır.
- Panelde "Ajanın son işlemleri" listesi ajanın ne yaptığını gösterir.
KISAYOL VE SAĞ TIK
Alt+Shift+A paneli açar (chrome://extensions/shortcuts üzerinden değişir).
Sağ tık: "AITURK'e gönder: seçili metin" / "bu sayfa" paneli açar ve metni
mesajınıza ek olarak hazırlar; konuşma seçince gönderebilirsiniz.
SAYFA İÇERİĞİ VE GİZLİLİK SAYFA İÇERİĞİ VE GİZLİLİK
Eklenti gezinme geçmişini, çerezleri, form değerlerini veya parolaları toplamaz. Eklenti gezinme geçmişini, çerezleri, form değerlerini veya parolaları toplamaz.
Sayfa/seçim yalnızca "Sayfayı / seçimi ekle" tıklandığında okunur; siz Gönder'e Sayfa/seçim yalnızca "Sayfayı / seçimi ekle" tıklandığında okunur; siz Gönder'e
@@ -41,7 +62,8 @@ SORUN GİDERME
"Native messaging host not found": bağlantı kurulumunu yeniden çalıştırın. "Native messaging host not found": bağlantı kurulumunu yeniden çalıştırın.
"IDE bağlantısı bulunamadı": Chrome destekli IDE sürümü açık ve yerel bağlantıda "IDE bağlantısı bulunamadı": Chrome destekli IDE sürümü açık ve yerel bağlantıda
olmalıdır. IDE'yi yeniden başlatıp eklentide Yeniden bağlan'a basın. olmalıdır. IDE'yi yeniden başlatıp eklentide Yeniden bağlan'a basın.
"Cannot access contents": ilgili sekmede AITURK araç çubuğu simgesine tıklayın. "Cannot access contents": eklentinin site erişimi kapalı olabilir; chrome://extensions
üzerinden AITURK için "Tüm sitelerde" erişimi açın.
chrome://, Chrome Web Store, PDF görüntüleyici gibi korumalı sayfalar okunamaz. chrome://, Chrome Web Store, PDF görüntüleyici gibi korumalı sayfalar okunamaz.
Zaman aşımında mesaj otomatik tekrar gönderilmez. Çifte gönderimi önlemek için Zaman aşımında mesaj otomatik tekrar gönderilmez. Çifte gönderimi önlemek için
önce IDE geçmişini kontrol edin. Taslağınız panel kapanana kadar korunur. önce IDE geçmişini kontrol edin. Taslağınız panel kapanana kadar korunur.
+16 -1
View File
@@ -1,7 +1,7 @@
# AITURK Chrome beta # AITURK Chrome beta
Windows / Chrome MV3 side panel for existing local AITURK IDE conversations. Windows / Chrome MV3 side panel for existing local AITURK IDE conversations.
Requires AITURK IDE 1.0.0-beta.9 or later; see `KURULUM.txt` for Turkish setup. Requires AITURK IDE 1.0.0-beta.9 or later (agent browser control: 1.0.0-beta.42); see `KURULUM.txt` for Turkish setup.
The extension talks only to `online.turkservis.aiturk` using Chrome native The extension talks only to `online.turkservis.aiturk` using Chrome native
messaging. The per-user .NET Framework host authenticates to Electron's random messaging. The per-user .NET Framework host authenticates to Electron's random
@@ -19,6 +19,20 @@ context is not truncated by this display policy. Approvals and clarifications
remain in the IDE. A backend event is stamped once and delivered to every remain in the IDE. A backend event is stamped once and delivered to every
attached viewer so the desktop can still show and answer these requests. attached viewer so the desktop can still show and answer these requests.
## Agent browser control
The service worker keeps its own native connection and registers this Chrome
with the IDE backend as the local desktop browser controller
(`browser.controller.local.*`, see the API server docs). The IDE agent's
existing `browser_*` tools (navigate, snapshot, click, type, scroll, back,
press, get_images, vision) then run here, in agent tabs grouped as "AITURK"
or a tab the user hands over; when the switch is off or Chrome is closed the
agent keeps its normal browser. Clicks and key presses use trusted
`chrome.debugger` input; password and payment-card fields are refused;
chrome://, extension, file and Web Store pages are refused. Cancelling
Chrome's debugging bar, the panel's Stop button or the switch turns control
off and cancels pending commands.
## Build and verification ## Build and verification
From the repository root, on Windows: From the repository root, on Windows:
@@ -39,6 +53,7 @@ UI tests require the existing desktop workspace's jsdom dependency:
```powershell ```powershell
npm ci --workspace apps/desktop --include-workspace-root npm ci --workspace apps/desktop --include-workspace-root
node --test apps/chrome/tests/panel.test.mjs node --test apps/chrome/tests/panel.test.mjs
node --test apps/chrome/tests/agent.test.mjs
``` ```
Exercise the production FastAPI session router and real temporary SQLite stores Exercise the production FastAPI session router and real temporary SQLite stores
+41
View File
@@ -1 +1,42 @@
import { createController } from './controller.js'
const controller = createController({ chrome })
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {}) chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(() => {})
// The side panel talks to the controller; nothing else may.
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
const type = String(message?.type || '')
if (sender.id !== chrome.runtime.id || !type.startsWith('control.') || type === 'control.state') return false
controller.onMessage(message, sender).then(sendResponse, error => sendResponse({ failure: String(error?.message || error) }))
return true
})
chrome.debugger.onDetach.addListener((source, reason) => controller.onDebuggerDetach(source, reason))
chrome.tabs.onRemoved.addListener(tabId => { void controller.onTabRemoved(tabId) })
chrome.alarms.onAlarm.addListener(alarm => controller.onAlarm(alarm.name))
const MENU = [
{ id: 'aiturk-selection', title: 'AITURK’e gönder: seçili metin', contexts: ['selection'] },
{ id: 'aiturk-page', title: 'AITURK’e gönder: bu sayfa', contexts: ['page'] },
{ id: 'aiturk-share', title: 'Bu sekmeyi AITURK ajanına ver', contexts: ['page'] },
]
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.removeAll(() => { for (const item of MENU) chrome.contextMenus.create(item) })
})
chrome.contextMenus.onClicked.addListener((info, tab) => {
if (!tab?.windowId) return
// Opening the panel must happen inside the click's user gesture, before any await.
chrome.sidePanel.open({ windowId: tab.windowId }).catch(() => {})
if (info.menuItemId === 'aiturk-share') {
void chrome.storage.local.get('selectedSession')
.then(saved => controller.shareTab(tab, typeof saved.selectedSession === 'string' ? saved.selectedSession : null))
.catch(error => chrome.storage.session.set({ pendingNotice: String(error?.message || error) }))
return
}
void chrome.storage.session.set({ pendingContext: {
kind: info.menuItemId === 'aiturk-selection' ? 'selection' : 'page', tabId: tab.id,
text: info.selectionText ? info.selectionText.slice(0, 24000) : '', at: Date.now(),
} })
})
void controller.connect()
+646
View File
@@ -0,0 +1,646 @@
// Lets the AITURK IDE agent use this Chrome, the way the ChatGPT extension
// lets its agent work in the user's browser. Runs in the service worker and
// keeps its own native connection, so it works with the side panel closed.
//
// Boundaries: the agent works only in its own tabs (grouped as "AITURK") or a
// tab the user explicitly hands over; never on chrome://, extension, file or
// Web Store pages; never types into password or payment-card fields. Chrome
// shows its own "debugging" bar while the agent is in control; cancelling it,
// the panel's Stop button or the switch turns control off at once.
import {
annotateRefs, clickWithEvents, listImages, locateRef, prepareTyping, readValue, scrollPage, snapshotPage, typeWithEvents,
} from './page-agent.js'
export const HOST = 'online.turkservis.aiturk'
export const CAPABILITIES = Object.freeze(['browser_navigate', 'browser_snapshot', 'browser_click', 'browser_type',
'browser_scroll', 'browser_back', 'browser_press', 'browser_get_images', 'browser_vision'])
const COMMAND_BUDGET_MS = 27000
const LOAD_WAIT_MS = 20000
const DEBUGGER_IDLE_MS = 45000
const SCREENSHOT_LIMIT = 380000
const LOG_SIZE = 30
export class ToolError extends Error {}
const KEY_ALIASES = { esc: 'Escape', return: 'Enter', up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight',
space: ' ', spacebar: ' ', del: 'Delete', pgup: 'PageUp', pgdn: 'PageDown', ctrl: 'Control', cmd: 'Meta', command: 'Meta', option: 'Alt' }
const NAMED_KEYS = { Enter: [13, 'Enter', '\r'], Tab: [9, 'Tab'], Escape: [27, 'Escape'], Backspace: [8, 'Backspace'],
Delete: [46, 'Delete'], ArrowUp: [38, 'ArrowUp'], ArrowDown: [40, 'ArrowDown'], ArrowLeft: [37, 'ArrowLeft'],
ArrowRight: [39, 'ArrowRight'], Home: [36, 'Home'], End: [35, 'End'], PageUp: [33, 'PageUp'], PageDown: [34, 'PageDown'],
' ': [32, 'Space', ' '], F5: [116, 'F5'] }
const MODIFIERS = { Alt: 1, Control: 2, Meta: 4, Shift: 8 }
// "Control+a" → one CDP key description; unknown names fail before any input.
export function parseKey(value) {
const parts = String(value || '').split('+').map(part => part.trim()).filter(Boolean)
if (!parts.length || parts.length > 4) throw new ToolError('Tuş adı geçersiz. Örnek: Enter, Tab, Escape, Control+a')
let modifiers = 0
const keyName = parts.pop()
for (const raw of parts) {
const name = KEY_ALIASES[raw.toLowerCase()] || raw[0].toUpperCase() + raw.slice(1).toLowerCase()
if (!MODIFIERS[name]) throw new ToolError(`Bilinmeyen değiştirici tuş: ${raw}`)
modifiers |= MODIFIERS[name]
}
const alias = KEY_ALIASES[keyName.toLowerCase()]
const name = alias || (keyName.length === 1 ? keyName : keyName[0].toUpperCase() + keyName.slice(1))
const named = NAMED_KEYS[name] || NAMED_KEYS[Object.keys(NAMED_KEYS).find(key => key.toLowerCase() === name.toLowerCase())]
if (named) {
const [code, keyCode, text] = named
return { key: name === ' ' ? ' ' : keyCode, code: keyCode === ' ' ? 'Space' : keyCode, windowsVirtualKeyCode: code,
text: modifiers & ~MODIFIERS.Shift ? undefined : text, modifiers }
}
if (name.length !== 1) throw new ToolError(`Bilinmeyen tuş: ${keyName}`)
const upper = name.toUpperCase()
const letter = /[A-Z]/.test(upper), digit = /[0-9]/.test(name)
return { key: name, code: letter ? `Key${upper}` : digit ? `Digit${name}` : '',
windowsVirtualKeyCode: letter || digit ? upper.charCodeAt(0) : name.charCodeAt(0),
text: modifiers & ~MODIFIERS.Shift ? undefined : name, modifiers }
}
// Only ordinary web pages. Local development servers are allowed; browser,
// extension, file and Web Store pages are not.
export function checkUrl(value, { allowBlank = false } = {}) {
let text = String(value || '').trim()
if (!text) throw new ToolError('Adres boş.')
if (!/^[a-z][a-z0-9+.-]*:/i.test(text)) text = `https://${text}`
let url
try { url = new URL(text) } catch { throw new ToolError(`Geçersiz adres: ${String(value).slice(0, 200)}`) }
if (allowBlank && url.href === 'about:blank') return url.href
if (!['http:', 'https:'].includes(url.protocol)) {
throw new ToolError(`Ajan bu tür sayfada çalışamaz (${url.protocol}). Yalnız http/https sayfaları açılabilir.`)
}
if (/^(chrome\.google\.com|chromewebstore\.google\.com)$/i.test(url.hostname) &&
(url.hostname.startsWith('chromewebstore') || url.pathname.startsWith('/webstore'))) {
throw new ToolError('Chrome Web Mağazası sayfalarında ajan çalışamaz.')
}
if (url.username || url.password) throw new ToolError('Adreste kullanıcı adı/şifre olamaz.')
return url.href
}
const shortHost = value => { try { return new URL(value).host } catch { return '' } }
function describeCommand(action, args) {
switch (action) {
case 'browser_navigate': return `Sayfa açılıyor: ${shortHost(args.url) || String(args.url || '').slice(0, 60)}`
case 'browser_snapshot': return 'Sayfa okunuyor'
case 'browser_click': return `Tıklanıyor: ${String(args.ref || '').slice(0, 20)}`
case 'browser_type': return `Yazılıyor: ${String(args.ref || '').slice(0, 20)}`
case 'browser_scroll': return args.direction === 'up' ? 'Yukarı kaydırılıyor' : 'Aşağı kaydırılıyor'
case 'browser_back': return 'Geri gidiliyor'
case 'browser_press': return `Tuşa basılıyor: ${String(args.key || '').slice(0, 20)}`
case 'browser_get_images': return 'Görseller listeleniyor'
case 'browser_vision': return 'Ekran görüntüsü alınıyor'
default: return action
}
}
export function createController({ chrome, setTimer = setTimeout, clearTimer = clearTimeout, now = () => Date.now(), random = () => crypto.randomUUID() }) {
let port = null, connected = false, registered = false, lastError = '', profile = ''
let enabled = true, instanceId = '', settingsLoaded = null, reconnectTimer = null, backoff = 2000
let nextId = 0, titles = new Map(), titlesAt = 0
const pending = new Map()
const active = new Map()
const queues = new Map()
const debuggers = new Map()
let state = null
// ── persistent pieces ────────────────────────────────────────────────
async function settings() {
settingsLoaded ||= (async () => {
const saved = await chrome.storage.local.get(['controlEnabled', 'controllerInstance'])
enabled = saved.controlEnabled !== false
instanceId = saved.controllerInstance || random()
if (!saved.controllerInstance) await chrome.storage.local.set({ controllerInstance: instanceId })
})()
return settingsLoaded
}
async function load() {
if (state) return state
const saved = await chrome.storage.session.get('controlState')
state = { tabs: {}, groups: {}, shared: null, log: [], ...(saved.controlState || {}) }
return state
}
async function save() { await chrome.storage.session.set({ controlState: state }) }
// ── native connection ────────────────────────────────────────────────
function request(action, params = {}) {
if (!port) return Promise.reject(new Error('IDE bağlantısı yok.'))
const id = `c${++nextId}`
return new Promise((resolve, reject) => {
const timer = setTimer(() => { pending.delete(id); reject(new Error('IDE yanıt vermedi.')) }, 30000)
pending.set(id, { resolve, reject, timer })
try { port.postMessage({ id, action, params }) }
catch (error) { clearTimer(timer); pending.delete(id); reject(error) }
})
}
function scheduleReconnect() {
if (!enabled || reconnectTimer) return
reconnectTimer = setTimer(() => { reconnectTimer = null; void connect() }, backoff)
backoff = Math.min(backoff * 2, 60000)
chrome.alarms?.create('aiturk-reconnect', { periodInMinutes: 1 })
}
async function connect() {
await settings()
if (!enabled || port) return
const current = chrome.runtime.connectNative(HOST)
port = current
current.onMessage.addListener(frame => onFrame(current, frame))
current.onDisconnect.addListener(() => {
if (port !== current) return
port = null; connected = false; registered = false
for (const item of pending.values()) { clearTimer(item.timer); item.reject(new Error('IDE bağlantısı kapandı.')) }
pending.clear()
abortAll('IDE bağlantısı kapandı.')
lastError ||= 'AITURK IDE kapalı ya da Chrome bağlantısı kurulmamış.'
broadcast()
scheduleReconnect()
})
try {
const status = await request('status')
profile = status?.profile || ''
connected = true; lastError = ''
await register()
backoff = 2000
chrome.alarms?.clear('aiturk-reconnect')
} catch (error) {
lastError = error.message
}
broadcast()
}
async function register() {
if (!port || !enabled) return
const result = await request('browser.register', { enabled: true, capabilities: [...CAPABILITIES],
controllerId: `aiturk-chrome-${instanceId}`, browserProfileId: instanceId })
registered = Boolean(result?.registered)
}
function onFrame(source, frame) {
if (source !== port || !frame || typeof frame !== 'object') return
if (frame.id !== undefined) {
const item = pending.get(frame.id)
if (!item) return
pending.delete(frame.id); clearTimer(item.timer)
frame.error ? item.reject(new Error(frame.error)) : item.resolve(frame.result)
return
}
if (frame.event === 'browser.command') void runCommand(frame)
else if (frame.event === 'browser.cancel') cancelCommand(frame.commandId, 'Ajan bu adımı iptal etti.')
else if (frame.event === 'disconnected') {
// The IDE restarted its backend; the native host is still here.
registered = false; connected = false
abortAll('IDE yeniden bağlanıyor.')
broadcast()
setTimer(() => { void reconnectBackend() }, 2000)
} else if (frame.event === 'error') { lastError = String(frame.message || '').slice(0, 300); broadcast() }
}
async function reconnectBackend() {
if (!port || !enabled) return
try {
const status = await request('status')
profile = status?.profile || ''
connected = true; lastError = ''
await register()
} catch (error) { lastError = error.message; setTimer(() => { void reconnectBackend() }, 10000) }
broadcast()
}
// ── commands ─────────────────────────────────────────────────────────
async function runCommand(frame) {
const { commandId, action, sessionId } = frame
const args = frame.arguments && typeof frame.arguments === 'object' ? frame.arguments : {}
const record = { commandId, cancelled: false, reason: '' }
active.set(commandId, record)
const entry = await logEntry({ at: now(), sessionId, action, text: describeCommand(action, args), status: 'running' })
let reply
try {
if (!CAPABILITIES.includes(action)) throw new ToolError('Chrome bu işlemi desteklemiyor.')
const result = await withBudget(execute(action, args, sessionId, record), COMMAND_BUDGET_MS)
reply = { commandId, ok: true, result }
entry.status = result?.success === false ? 'failed' : 'ok'
if (result?.success === false) entry.note = String(result.error || '').slice(0, 200)
} catch (error) {
const message = String(error?.message || error).slice(0, 2000)
// Expected refusals reach the model as a normal tool result it can act on.
reply = error instanceof ToolError ? { commandId, ok: true, result: { success: false, error: message } }
: { commandId, ok: false, error: message }
entry.status = 'failed'; entry.note = message.slice(0, 200)
}
if (record.cancelled) { entry.status = 'cancelled'; entry.note = record.reason }
active.delete(commandId)
await save().catch(() => {})
broadcast()
if (record.cancelled) return
try { await request('browser.result', reply) } catch {}
}
function cancelCommand(commandId, reason) {
const record = active.get(commandId)
if (record) { record.cancelled = true; record.reason = reason }
}
function abortAll(reason) { for (const record of active.values()) { record.cancelled = true; record.reason = reason } }
function guard(record) { if (record?.cancelled) throw new ToolError(record.reason || 'Durduruldu.') }
async function withBudget(promise, ms) {
let timer
const timeout = new Promise((_, reject) => { timer = setTimer(() => reject(new ToolError('Tarayıcı adımı zaman aşımına uğradı.')), ms) })
try { return await Promise.race([promise, timeout]) } finally { clearTimer(timer) }
}
// One step at a time per tab. The stored tail never rejects: the caller
// handles the step's own error, the queue only orders the steps.
function serial(tabId, task) {
const next = (queues.get(tabId) || Promise.resolve()).then(task)
const tail = next.catch(() => {}).then(() => { if (queues.get(tabId) === tail) queues.delete(tabId) })
queues.set(tabId, tail)
return next
}
async function execute(action, args, sessionId, record) {
if (action === 'browser_navigate') {
const url = checkUrl(args.url)
const tab = await agentTab(sessionId, true)
return serial(tab.id, async () => {
guard(record)
await chrome.tabs.update(tab.id, { url })
const loaded = await waitForLoad(tab.id, LOAD_WAIT_MS)
guard(record)
const snap = await inject(tab.id, snapshotPage, [{ full: false }])
return { success: true, url: snap.url, title: snap.title, snapshot: snap.snapshot, element_count: snap.element_count,
...(loaded ? {} : { note: 'Sayfa yüklenmeye devam ediyor; gerekirse browser_snapshot ile tekrar bakın.' }) }
})
}
const tab = await agentTab(sessionId, false)
return serial(tab.id, async () => {
guard(record)
const current = await chrome.tabs.get(tab.id)
const address = current.pendingUrl || current.url || ''
if (address === 'about:blank' || !address) {
if (action === 'browser_snapshot') return { success: true, snapshot: 'Boş sekme. Önce browser_navigate ile bir sayfa açın.', element_count: 0, url: 'about:blank', title: '' }
throw new ToolError('Sekme boş. Önce browser_navigate ile bir sayfa açın.')
}
checkUrl(address)
switch (action) {
case 'browser_snapshot': {
const snap = await inject(tab.id, snapshotPage, [{ full: args.full === true }])
return { success: true, ...snap }
}
case 'browser_click': return click(tab, args.ref, record)
case 'browser_type': return type(tab, args.ref, typeof args.text === 'string' ? args.text : String(args.text ?? ''), record)
case 'browser_scroll': {
const result = await inject(tab.id, scrollPage, [args.direction === 'up' ? 'up' : 'down'])
return { success: true, direction: args.direction === 'up' ? 'up' : 'down', ...result }
}
case 'browser_back': {
try { await chrome.tabs.goBack(tab.id) } catch { throw new ToolError('Geri gidilecek sayfa yok.') }
await waitForLoad(tab.id, LOAD_WAIT_MS)
const after = await chrome.tabs.get(tab.id)
return { success: true, url: after.url, title: after.title }
}
case 'browser_press': return press(tab, args.key)
case 'browser_get_images': return { success: true, ...(await inject(tab.id, listImages, [])) }
case 'browser_vision': return vision(tab, args.annotate === true)
}
throw new ToolError('Chrome bu işlemi desteklemiyor.')
})
}
async function click(tab, ref, record) {
const located = await inject(tab.id, locateRef, [ref])
if (located.error) throw new ToolError(located.error)
if (located.disabled) throw new ToolError(`${ref} şu an devre dışı.`)
guard(record)
let trusted = true
try {
const send = await debuggerFor(tab.id)
// No mouseMoved first: on a background tab Chrome holds a hover move
// for 5 s waiting for a frame, while press/release go straight through.
for (const type of ['mousePressed', 'mouseReleased']) {
await send('Input.dispatchMouseEvent', { type, x: located.x, y: located.y, button: 'left', clickCount: 1 })
}
} catch {
trusted = false
const result = await inject(tab.id, clickWithEvents, [ref])
if (result.error) throw new ToolError(result.error)
}
await settle(tab.id)
const after = await chrome.tabs.get(tab.id)
return { success: true, clicked: ref, element: located.name, url: after.url, title: after.title,
...(located.covered ? { warning: `Tıklanan noktada başka bir öğe vardı: "${located.covered}". Gerekirse önce onu kapatın.` } : {}),
...(trusted ? {} : { note: 'Gerçek fare tıklaması yerine sayfa olayı kullanıldı.' }) }
}
async function type(tab, ref, text, record) {
const prepared = await inject(tab.id, prepareTyping, [ref])
if (prepared.error) {
if (prepared.handoff) notify('AITURK senin girmeni bekliyor', prepared.error)
throw new ToolError(prepared.error)
}
guard(record)
if (prepared.select) {
const result = await inject(tab.id, typeWithEvents, [ref, text])
if (result.error) throw new ToolError(result.error)
return { success: true, typed: ref, value: result.value }
}
try {
const send = await debuggerFor(tab.id)
if (text) await send('Input.insertText', { text })
else for (const type of ['rawKeyDown', 'keyUp']) await send('Input.dispatchKeyEvent', { type, key: 'Delete', code: 'Delete', windowsVirtualKeyCode: 46 })
} catch {
const result = await inject(tab.id, typeWithEvents, [ref, text])
if (result.error) throw new ToolError(result.error)
}
const value = await inject(tab.id, readValue, [ref])
return { success: true, typed: ref, value: value.value ?? '' }
}
async function press(tab, key) {
const description = parseKey(key)
try {
const send = await debuggerFor(tab.id)
await send('Input.dispatchKeyEvent', { type: description.text ? 'keyDown' : 'rawKeyDown', ...description,
unmodifiedText: description.text })
await send('Input.dispatchKeyEvent', { type: 'keyUp', key: description.key, code: description.code,
windowsVirtualKeyCode: description.windowsVirtualKeyCode, modifiers: description.modifiers })
} catch {
throw new ToolError('Tuşa basılamadı: Chrome kontrol izni alınamadı (başka bir geliştirici aracı açık olabilir).')
}
await settle(tab.id)
const after = await chrome.tabs.get(tab.id)
return { success: true, pressed: String(key), url: after.url, title: after.title }
}
async function vision(tab, annotate) {
let annotations = []
if (annotate) {
await inject(tab.id, snapshotPage, [{ full: false }])
annotations = (await inject(tab.id, annotateRefs, [true])).annotations || []
}
try {
let data = ''
try {
const send = await debuggerFor(tab.id)
// A background tab paints on demand for the capture (about 1-3 s).
for (const options of [{ quality: 65, budget: 15000 }, { quality: 40, budget: 5000 }, { quality: 40, scale: 0.6, budget: 5000 }]) {
let clip
if (options.scale) {
const metrics = await send('Page.getLayoutMetrics', {})
const view = metrics.cssVisualViewport || metrics.layoutViewport
clip = { x: view.pageX || 0, y: view.pageY || 0, width: view.clientWidth, height: view.clientHeight, scale: options.scale }
}
const shot = await withBudget(send('Page.captureScreenshot', { format: 'jpeg', quality: options.quality, ...(clip ? { clip } : {}) }), options.budget)
data = shot?.data || ''
if (data && data.length <= SCREENSHOT_LIMIT) break
}
} catch {
const current = await chrome.tabs.get(tab.id)
if (!current.active) throw new ToolError('Ekran görüntüsü alınamadı: ajanın sekmesi görünür değil ve Chrome kontrol izni alınamadı.')
const url = await chrome.tabs.captureVisibleTab(current.windowId, { format: 'jpeg', quality: 55 })
data = String(url || '').replace(/^data:image\/jpeg;base64,/, '')
}
if (!data || data.length > SCREENSHOT_LIMIT) throw new ToolError('Ekran görüntüsü çok büyük ya da boş geldi.')
const after = await chrome.tabs.get(tab.id)
return { success: true, screenshot: data, mime: 'image/jpeg', url: after.url, title: after.title,
...(annotate ? { annotations } : {}) }
} finally {
if (annotate) await inject(tab.id, annotateRefs, [false]).catch(() => {})
}
}
// ── tabs ─────────────────────────────────────────────────────────────
async function agentTab(sessionId, create) {
const current = await load()
const key = sessionId || 'default'
const known = current.tabs[key]
if (known) {
try { return await chrome.tabs.get(known) } catch { delete current.tabs[key] }
}
if (current.shared && (!current.shared.sessionId || current.shared.sessionId === key)) {
try {
const tab = await chrome.tabs.get(current.shared.tabId)
current.tabs[key] = tab.id
current.shared = null
await save()
return tab
} catch { current.shared = null }
}
if (!create) throw new ToolError('Bu konuşma için açık bir tarayıcı sekmesi yok. Önce browser_navigate ile bir sayfa açın.')
const window = await chrome.windows.getLastFocused({ windowTypes: ['normal'] }).catch(() => null)
const tab = await chrome.tabs.create({ url: 'about:blank', active: false, ...(window?.id ? { windowId: window.id } : {}) })
current.tabs[key] = tab.id
await save()
await groupTab(tab, key).catch(() => {})
return tab
}
async function sessionTitle(sessionId) {
if (now() - titlesAt > 60000 && port) {
titlesAt = now()
try {
const data = await request('sessions.list')
titles = new Map((data?.sessions || []).map(row => [row.id, row.title]))
} catch {}
}
return titles.get(sessionId) || ''
}
async function groupTab(tab, key) {
const current = await load()
let groupId = current.groups[key]
if (groupId !== undefined) {
try {
const group = await chrome.tabGroups.get(groupId)
if (group.windowId !== tab.windowId) groupId = undefined
} catch { groupId = undefined }
}
groupId = await chrome.tabs.group({ tabIds: [tab.id], ...(groupId === undefined ? { createProperties: { windowId: tab.windowId } } : { groupId }) })
current.groups[key] = groupId
await save()
const title = await sessionTitle(key)
await chrome.tabGroups.update(groupId, { title: `AITURK${title ? ` · ${title.slice(0, 28)}` : ''}`, color: 'cyan' })
}
function waitForLoad(tabId, ms) {
return new Promise(resolve => {
let done = false
const finish = value => {
if (done) return
done = true; clearTimer(timer)
chrome.tabs.onUpdated.removeListener(listener)
resolve(value)
}
const listener = (id, change) => { if (id === tabId && change.status === 'complete') finish(true) }
const timer = setTimer(() => finish(false), ms)
chrome.tabs.onUpdated.addListener(listener)
// A page that finished before the listener existed.
// pendingUrl is set until the new document commits, so an old page's
// "complete" is never mistaken for the new one.
setTimer(() => { chrome.tabs.get(tabId).then(tab => { if (tab.status === 'complete' && !tab.pendingUrl) finish(true) }).catch(() => finish(false)) }, 250)
})
}
async function settle(tabId) {
await new Promise(resolve => setTimer(resolve, 400))
const tab = await chrome.tabs.get(tabId).catch(() => null)
if (tab?.status === 'loading') await waitForLoad(tabId, 8000)
}
async function inject(tabId, func, args) {
let results
try {
results = await chrome.scripting.executeScript({ target: { tabId }, func, args })
} catch (error) {
throw new ToolError(`Sayfaya erişilemedi: ${String(error?.message || error).slice(0, 200)}`)
}
const value = results?.[0]?.result
if (value === undefined || value === null) throw new ToolError('Sayfa yanıt vermedi; yükleniyor olabilir.')
return value
}
async function debuggerFor(tabId) {
let entry = debuggers.get(tabId)
if (!entry) {
await chrome.debugger.attach({ tabId }, '1.3')
entry = { timer: null }
debuggers.set(tabId, entry)
}
clearTimer(entry.timer)
entry.timer = setTimer(() => { void detachDebugger(tabId) }, DEBUGGER_IDLE_MS)
return (method, params) => chrome.debugger.sendCommand({ tabId }, method, params)
}
async function detachDebugger(tabId) {
const entry = debuggers.get(tabId)
if (!entry) return
clearTimer(entry.timer)
debuggers.delete(tabId)
await chrome.debugger.detach({ tabId }).catch(() => {})
}
// ── state for the panel ─────────────────────────────────────────────
async function logEntry(entry) {
const current = await load()
current.log.unshift(entry)
current.log.length = Math.min(current.log.length, LOG_SIZE)
await save().catch(() => {})
broadcast()
return entry
}
function notify(title, message) {
chrome.notifications?.create({ type: 'basic', iconUrl: 'icon128.png', title, message: String(message).slice(0, 200), priority: 1 })
?.catch?.(() => {})
}
async function snapshotState() {
const current = await load()
const tabs = []
for (const [sessionId, tabId] of Object.entries(current.tabs)) {
try {
const tab = await chrome.tabs.get(tabId)
tabs.push({ sessionId, tabId, title: (tab.title || '').slice(0, 120), url: shortHost(tab.url) })
} catch { delete current.tabs[sessionId] }
}
return { enabled, connected, registered, error: lastError, profile, running: active.size,
log: current.log.slice(0, 15), tabs, shared: current.shared }
}
function broadcast() {
chrome.action?.setBadgeText?.({ text: active.size ? '●' : '' })?.catch?.(() => {})
void snapshotState().then(view => chrome.runtime.sendMessage({ type: 'control.state', state: view })).catch(() => {})
}
async function setEnabled(value) {
await settings()
enabled = value === true
await chrome.storage.local.set({ controlEnabled: enabled })
if (enabled) {
backoff = 2000
if (port && !registered) { try { await register() } catch (error) { lastError = error.message } }
else await connect()
} else {
abortAll('Kullanıcı ajanın tarayıcı kullanımını kapattı.')
for (const tabId of [...debuggers.keys()]) await detachDebugger(tabId)
if (port) { try { await request('browser.register', { enabled: false }) } catch {} }
registered = false
const previous = port
port = null; connected = false
previous?.disconnect()
chrome.alarms?.clear('aiturk-reconnect')
}
broadcast()
return snapshotState()
}
async function shareTab(tab, sessionId) {
if (!tab?.id) throw new ToolError('Paylaşılacak sekme bulunamadı.')
checkUrl(tab.url || '')
const current = await load()
if (sessionId) current.tabs[sessionId] = tab.id
else current.shared = { tabId: tab.id, sessionId: null, title: (tab.title || '').slice(0, 120) }
await save()
broadcast()
return snapshotState()
}
async function releaseTab(sessionId) {
const current = await load()
const ids = sessionId ? [current.tabs[sessionId]] : Object.values(current.tabs)
if (sessionId) delete current.tabs[sessionId]
else { current.tabs = {}; current.shared = null }
for (const tabId of ids) if (tabId) await detachDebugger(tabId)
await save()
broadcast()
return snapshotState()
}
async function onMessage(message, sender) {
switch (message?.type) {
case 'control.get':
await settings()
if (enabled && !port) void connect()
return snapshotState()
case 'control.set': return setEnabled(message.enabled === true)
case 'control.stop': return setEnabled(false)
case 'control.share': {
const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true })
return shareTab(tab, typeof message.sessionId === 'string' ? message.sessionId : null)
}
case 'control.release': return releaseTab(typeof message.sessionId === 'string' ? message.sessionId : null)
}
return undefined
}
function onDebuggerDetach(source, reason) {
const entry = debuggers.get(source?.tabId)
if (!entry) return
clearTimer(entry.timer)
debuggers.delete(source.tabId)
if (reason === 'canceled_by_user') {
notify('AITURK durduruldu', 'Chrome uyarısındaki İptal ile ajanın tarayıcı kullanımı kapatıldı. Panelden tekrar açabilirsiniz.')
void setEnabled(false)
}
}
async function onTabRemoved(tabId) {
const current = await load()
let changed = false
for (const [key, value] of Object.entries(current.tabs)) if (value === tabId) { delete current.tabs[key]; changed = true }
if (current.shared?.tabId === tabId) { current.shared = null; changed = true }
debuggers.delete(tabId)
if (changed) { await save(); broadcast() }
}
return { connect, onMessage, onDebuggerDetach, onTabRemoved, setEnabled, shareTab, releaseTab, snapshotState,
onAlarm: name => { if (name === 'aiturk-reconnect') void connect() },
// exposed for tests
_execute: execute, _runCommand: runCommand, _state: () => ({ port, connected, registered, enabled, active }) }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+8 -5
View File
@@ -1,13 +1,16 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "AITURK — IDE Yan Paneli", "name": "AITURK — IDE Yan Paneli",
"version": "0.1.0", "version": "0.2.0",
"description": "AITURK IDE konuşmalarınızı Chrome yan panelinde seçin ve aynı bağlamla devam edin. Windows beta.", "description": "AITURK IDE konuşmalarınızı Chrome yan panelinde sürdürün; isterseniz AITURK ajanı bu Chrome'da sayfa açıp tıklayabilsin. Windows beta.",
"minimum_chrome_version": "116", "minimum_chrome_version": "116",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsqPEObthL/kikWFLJrsJWfxxEqn50wJa3kbDGdZM644GW035J6lpkb932DBsHUtMdcDfRsQNWtjrIfLjxJP3xJv+vWLoO5QpcxHmqteyVS05SQw/RV0mV+pEQpuDnTlxUwAvfPk8bpyj7sD4zVHqLy0Q+BbcqAdkPUQL7x9wJDZxaM6fmrUyrTQMkFRjd6+2Stdg+rfrIgCNym55UaM0dHCtMPxpyn79eqyRT3CwDEmqAz99YCDwKbVu+MCEjQiVVeYSgJmfwcL1vV8Zsue1gOsC5mdQEtGGyHiHV8XIc8SL8k8AkGJu6v8srGEtjLRKvb/G66wzq10FxCis9M0f6QIDAQAB", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsqPEObthL/kikWFLJrsJWfxxEqn50wJa3kbDGdZM644GW035J6lpkb932DBsHUtMdcDfRsQNWtjrIfLjxJP3xJv+vWLoO5QpcxHmqteyVS05SQw/RV0mV+pEQpuDnTlxUwAvfPk8bpyj7sD4zVHqLy0Q+BbcqAdkPUQL7x9wJDZxaM6fmrUyrTQMkFRjd6+2Stdg+rfrIgCNym55UaM0dHCtMPxpyn79eqyRT3CwDEmqAz99YCDwKbVu+MCEjQiVVeYSgJmfwcL1vV8Zsue1gOsC5mdQEtGGyHiHV8XIc8SL8k8AkGJu6v8srGEtjLRKvb/G66wzq10FxCis9M0f6QIDAQAB",
"permissions": ["sidePanel", "nativeMessaging", "storage", "activeTab", "scripting"], "permissions": ["sidePanel", "nativeMessaging", "storage", "activeTab", "scripting", "tabs", "tabGroups", "debugger", "contextMenus", "notifications", "alarms"],
"background": { "service_worker": "background.js" }, "host_permissions": ["<all_urls>"],
"action": { "default_title": "AITURK yan panelini aç" }, "background": { "service_worker": "background.js", "type": "module" },
"action": { "default_title": "AITURK yan panelini aç", "default_icon": { "16": "icon16.png", "32": "icon32.png", "48": "icon48.png" } },
"icons": { "16": "icon16.png", "32": "icon32.png", "48": "icon48.png", "128": "icon128.png" },
"commands": { "_execute_action": { "suggested_key": { "default": "Alt+Shift+A" }, "description": "AITURK yan panelini aç" } },
"side_panel": { "default_path": "panel.html" }, "side_panel": { "default_path": "panel.html" },
"content_security_policy": { "extension_pages": "script-src 'self'; object-src 'none'; connect-src 'none'; base-uri 'none'" }, "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'none'; connect-src 'none'; base-uri 'none'" },
"homepage_url": "https://turkservis.online/indir" "homepage_url": "https://turkservis.online/indir"
+341
View File
@@ -0,0 +1,341 @@
// Functions injected into a page the IDE agent controls. Each one runs through
// chrome.scripting.executeScript in Chrome's isolated world, so every function
// must be self-contained (no imports, no outer variables). They share one
// global per document: the element map of the latest snapshot, which turns the
// model's "@e5" back into the element it saw.
// Accessibility-style outline of the page with refs for interactive elements.
// full=false lists interactive elements and headings; full=true adds visible
// text in reading order. Same-origin iframes and open shadow roots are walked.
export function snapshotPage(options) {
const full = Boolean(options && options.full)
const limit = Math.max(2000, Math.min(Number(options && options.limit) || 15000, 40000))
const refs = new Map()
const lines = []
let counter = 0, length = 0, omitted = 0
const ROLE_BY_TAG = { a: 'link', button: 'button', select: 'combobox', textarea: 'textbox', summary: 'button',
h1: 'heading', h2: 'heading', h3: 'heading', h4: 'heading', h5: 'heading', h6: 'heading', img: 'img', iframe: 'iframe' }
const INTERACTIVE = new Set(['button', 'link', 'textbox', 'searchbox', 'combobox', 'checkbox', 'radio', 'switch', 'tab',
'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'slider', 'spinbutton', 'treeitem', 'listbox'])
const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'HEAD', 'META', 'LINK'])
const clean = (value, max = 100) => String(value || '').replace(/\s+/g, ' ').trim().slice(0, max)
const quote = value => JSON.stringify(value)
function visible(element) {
const rect = element.getBoundingClientRect()
if (rect.width < 1 || rect.height < 1) return false
const style = element.ownerDocument.defaultView.getComputedStyle(element)
return style.visibility !== 'hidden' && style.display !== 'none' && style.opacity !== '0'
}
function inputRole(element) {
const type = (element.getAttribute('type') || 'text').toLowerCase()
if (['button', 'submit', 'reset', 'image'].includes(type)) return 'button'
if (type === 'checkbox') return 'checkbox'
if (type === 'radio') return 'radio'
if (type === 'range') return 'slider'
if (type === 'number') return 'spinbutton'
if (type === 'search') return 'searchbox'
if (type === 'hidden') return ''
return 'textbox'
}
function roleOf(element) {
const explicit = clean(element.getAttribute('role'), 40).split(' ')[0]
if (explicit) return explicit
const tag = element.tagName.toLowerCase()
if (tag === 'input') return inputRole(element)
if (tag === 'a') return element.hasAttribute('href') ? 'link' : ''
if (element.isContentEditable && element.getAttribute('contenteditable') !== null) return 'textbox'
return ROLE_BY_TAG[tag] || ''
}
function nameOf(element, role) {
const doc = element.ownerDocument
const labelled = element.getAttribute('aria-labelledby')
if (labelled) {
const text = labelled.split(/\s+/).map(id => doc.getElementById(id)?.textContent || '').join(' ')
if (clean(text)) return clean(text)
}
const aria = element.getAttribute('aria-label')
if (clean(aria)) return clean(aria)
if (element.id && ['textbox', 'searchbox', 'combobox', 'checkbox', 'radio', 'slider', 'spinbutton', 'switch'].includes(role)) {
const label = Array.from(doc.getElementsByTagName('label')).find(candidate => candidate.htmlFor === element.id)
if (label && clean(label.textContent)) return clean(label.textContent)
}
const wrapping = element.closest('label')
if (wrapping && wrapping !== element && clean(wrapping.textContent)) return clean(wrapping.textContent)
if (element.tagName === 'IMG') return clean(element.getAttribute('alt'))
if (element.tagName === 'INPUT' && ['button', 'submit', 'reset'].includes((element.type || '').toLowerCase())) {
return clean(element.value || element.type)
}
const text = role === 'textbox' || role === 'searchbox' || role === 'combobox' ? '' : clean(element.innerText || element.textContent)
return text || clean(element.getAttribute('title')) || clean(element.getAttribute('placeholder'))
}
function states(element, role) {
const out = []
if (role === 'heading') out.push(`level=${element.tagName.match(/\d/)?.[0] || element.getAttribute('aria-level') || 2}`)
if (element.disabled || element.getAttribute('aria-disabled') === 'true') out.push('disabled')
if (element.checked || element.getAttribute('aria-checked') === 'true') out.push('checked')
if (element.getAttribute('aria-expanded') === 'true') out.push('expanded')
if (element.getAttribute('aria-selected') === 'true') out.push('selected')
if (element.required) out.push('required')
return out
}
function valueOf(element, role) {
if (!['textbox', 'searchbox', 'combobox', 'spinbutton', 'slider'].includes(role)) return ''
const type = (element.getAttribute('type') || '').toLowerCase()
if (type === 'password') return element.value ? '••••' : ''
if (element.tagName === 'SELECT') return clean(element.selectedOptions?.[0]?.textContent, 80)
if (element.isContentEditable) return clean(element.innerText, 120)
return clean(element.value, 120)
}
function push(line) {
if (length + line.length + 1 > limit) { omitted++; return }
lines.push(line)
length += line.length + 1
}
function describe(element, depth) {
const role = roleOf(element)
if (!role) return false
const interactive = INTERACTIVE.has(role) || (element.tagName === 'INPUT' && role)
if (!interactive && role !== 'heading' && !(full && (role === 'img' || role === 'iframe'))) return false
if (!visible(element)) return false
const name = nameOf(element, role)
const parts = [`${' '.repeat(depth)}- ${role}`]
if (name) parts.push(quote(name))
const flags = states(element, role)
let ref = ''
if (interactive) {
ref = `e${++counter}`
refs.set(ref, new WeakRef(element))
flags.push(`ref=${ref}`)
}
if (flags.length) parts.push(`[${flags.join('] [')}]`)
const value = valueOf(element, role)
if (value) parts.push(`(değer: ${quote(value)})`)
if ((element.getAttribute('type') || '').toLowerCase() === 'password') parts.push('(şifre alanı: ajan yazamaz)')
if (role === 'link' && full) {
try { parts.push(`→ ${new URL(element.getAttribute('href'), element.baseURI).href.slice(0, 160)}`) } catch {}
}
push(parts.join(' '))
if (full && element.tagName === 'SELECT') {
Array.from(element.options).slice(0, 20).forEach(option => push(`${' '.repeat(depth + 1)}- option ${quote(clean(option.textContent))}${option.selected ? ' [selected]' : ''}`))
}
return interactive
}
function directText(element) {
let text = ''
for (const node of element.childNodes) if (node.nodeType === 3) text += node.textContent
return clean(text, 300)
}
function walk(root, depth) {
for (const element of root.children || []) {
if (SKIP.has(element.tagName.toUpperCase())) continue
if (element.getAttribute('aria-hidden') === 'true' || element.hidden) continue
if (element.tagName === 'IFRAME') {
let inner = null
try { inner = element.contentDocument } catch {}
if (inner?.body && visible(element)) { push(`${' '.repeat(depth)}- iframe ${quote(clean(element.title || element.name || ''))}`); walk(inner.body, depth + 1) }
else if (visible(element)) push(`${' '.repeat(depth)}- iframe ${quote(clean(element.title || element.src, 80))} (farklı alan adı: içerik okunamaz)`)
continue
}
const interactive = describe(element, depth)
if (!interactive && full && visible(element)) {
const text = directText(element)
if (text && !roleOf(element)) push(`${' '.repeat(depth)}- text: ${text}`)
}
if (element.shadowRoot) walk(element.shadowRoot, depth)
// A described control's own children rarely add meaning (button labels).
if (!interactive || element.tagName === 'SELECT' || element.getAttribute('role') === 'listbox') walk(element, depth)
}
}
walk(document.body || document.documentElement, 0)
globalThis.__aiturkRefs = refs
const header = [`Sayfa: ${clean(document.title, 200) || '(başlıksız)'}`, `Adres: ${location.href.slice(0, 300)}`]
if (!lines.length) lines.push('(Görünür etkileşimli öğe yok. Sayfa yükleniyor olabilir; browser_snapshot ile tekrar bakın ya da full=true deneyin.)')
if (omitted) lines.push(`… (${omitted} satır daha var; kısaltıldı. Sayfayı kaydırıp tekrar bakın.)`)
return { snapshot: header.concat(lines).join('\n'), element_count: counter, url: location.href, title: document.title, truncated: omitted > 0 }
}
// Where to click/type for a ref, after scrolling it into view. Coordinates are
// top-level viewport CSS pixels (same-origin iframe offsets added).
export function locateRef(ref) {
const key = String(ref || '').trim().replace(/^@/, '').replace(/^ref=/, '').replace(/^\[|\]$/g, '')
const element = globalThis.__aiturkRefs?.get(key)?.deref()
if (!element || !element.isConnected) {
return { error: `Öğe bulunamadı (@${key}). Sayfa değişmiş olabilir; browser_snapshot ile yeni numaraları alın.` }
}
element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' })
const rect = element.getBoundingClientRect()
let x = rect.left + rect.width / 2, y = rect.top + rect.height / 2
let view = element.ownerDocument.defaultView
while (view && view !== window && view.frameElement) {
const frame = view.frameElement.getBoundingClientRect()
x += frame.left; y += frame.top
view = view.parent
}
const type = (element.getAttribute('type') || '').toLowerCase()
const autocomplete = (element.getAttribute('autocomplete') || '').toLowerCase()
let covered = ''
if (element.ownerDocument === document) {
const top = document.elementFromPoint(x, y)
if (top && top !== element && !element.contains(top) && !top.contains(element)) {
covered = String(top.innerText || top.getAttribute('aria-label') || top.tagName).replace(/\s+/g, ' ').trim().slice(0, 80)
}
}
return {
x: Math.round(x), y: Math.round(y),
tag: element.tagName.toLowerCase(),
name: String(element.getAttribute('aria-label') || element.innerText || element.value || element.getAttribute('title') || '').replace(/\s+/g, ' ').trim().slice(0, 80),
editable: element.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName),
password: type === 'password',
payment: /^cc-|^card|card-?number|cvc|cvv/.test(autocomplete) || /cvc|cvv|card.?number/i.test(element.name || ''),
disabled: Boolean(element.disabled || element.getAttribute('aria-disabled') === 'true'),
covered,
}
}
// Focus a ref and select its current content so typed text replaces it.
// Refuses password and payment-card fields: those stay with the user.
export function prepareTyping(ref) {
const key = String(ref || '').trim().replace(/^@/, '').replace(/^ref=/, '').replace(/^[|]$/g, '')
const element = globalThis.__aiturkRefs?.get(key)?.deref()
if (!element || !element.isConnected) {
return { error: `Öğe bulunamadı (@${key}). Sayfa değişmiş olabilir; browser_snapshot ile yeni numaraları alın.` }
}
const type = (element.getAttribute('type') || '').toLowerCase()
const autocomplete = (element.getAttribute('autocomplete') || '').toLowerCase()
if (type === 'password' || autocomplete.includes('password')) {
return { error: 'Şifre alanına ajan yazamaz. Bu adımı kullanıcıya bırakın.', handoff: true }
}
if (/^cc-|card-?number|cvc|cvv/.test(autocomplete) || /cvc|cvv|card.?number/i.test(element.name || '')) {
return { error: 'Kart bilgisi alanına ajan yazamaz. Bu adımı kullanıcıya bırakın.', handoff: true }
}
const editable = element.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName)
if (!editable) return { error: `@${key} bir yazı alanı değil (${element.tagName.toLowerCase()}).` }
if (element.disabled || element.readOnly || element.getAttribute('aria-disabled') === 'true') {
return { error: `@${key} şu an devre dışı ya da salt okunur.` }
}
element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' })
if (element.tagName === 'SELECT') return { select: true }
element.focus()
if (element.isContentEditable) {
const range = element.ownerDocument.createRange()
range.selectNodeContents(element)
const selection = element.ownerDocument.getSelection()
selection.removeAllRanges(); selection.addRange(range)
} else if (typeof element.select === 'function' && element.tagName !== 'SELECT') {
element.select()
}
return { focused: element.ownerDocument.activeElement === element }
}
// Script fallback when trusted input is unavailable: replace the value and fire
// the events frameworks listen to.
export function typeWithEvents(ref, text) {
const key = String(ref || '').trim().replace(/^@/, '').replace(/^ref=/, '')
const element = globalThis.__aiturkRefs?.get(key)?.deref()
if (!element) return { error: `Öğe bulunamadı (@${key}).` }
if (element.tagName === 'SELECT') {
const wanted = String(text).trim().toLocaleLowerCase()
const option = Array.from(element.options).find(item => item.textContent.trim().toLocaleLowerCase() === wanted ||
item.value.toLocaleLowerCase() === wanted) || Array.from(element.options).find(item => item.textContent.trim().toLocaleLowerCase().includes(wanted))
if (!option) return { error: `Listede "${String(text).slice(0, 80)}" seçeneği yok.` }
element.value = option.value
} else if (element.isContentEditable) {
element.textContent = text
} else {
const proto = element.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
setter ? setter.call(element, text) : (element.value = text)
}
element.dispatchEvent(new Event('input', { bubbles: true }))
element.dispatchEvent(new Event('change', { bubbles: true }))
return { value: element.isContentEditable ? element.textContent : element.value }
}
// Read back what a field holds after typing (password fields never echoed).
export function readValue(ref) {
const key = String(ref || '').trim().replace(/^@/, '').replace(/^ref=/, '')
const element = globalThis.__aiturkRefs?.get(key)?.deref()
if (!element) return { error: `Öğe bulunamadı (@${key}).` }
return { value: String(element.isContentEditable ? element.innerText : element.value ?? '').slice(0, 2000) }
}
export function clickWithEvents(ref) {
const key = String(ref || '').trim().replace(/^@/, '').replace(/^ref=/, '')
const element = globalThis.__aiturkRefs?.get(key)?.deref()
if (!element) return { error: `Öğe bulunamadı (@${key}).` }
element.click()
return { clicked: true }
}
// Scroll the page, or the scrollable area under the viewport centre when the
// window itself does not move (single-page apps).
export function scrollPage(direction) {
const sign = direction === 'up' ? -1 : 1
const amount = Math.round(window.innerHeight * 0.8) * sign
const before = window.scrollY
window.scrollBy({ top: amount, behavior: 'instant' })
if (window.scrollY !== before) {
return { scrolled: true, atEnd: sign > 0 ? window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2 : window.scrollY <= 0 }
}
let element = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)
while (element && element !== document.body) {
const style = getComputedStyle(element)
if (/(auto|scroll)/.test(style.overflowY) && element.scrollHeight > element.clientHeight) {
const previous = element.scrollTop
element.scrollBy({ top: amount, behavior: 'instant' })
if (element.scrollTop !== previous) {
return { scrolled: true, atEnd: sign > 0 ? element.scrollTop + element.clientHeight >= element.scrollHeight - 2 : element.scrollTop <= 0 }
}
}
element = element.parentElement
}
return { scrolled: false, atEnd: true }
}
export function listImages() {
const images = []
for (const image of document.images) {
if (images.length >= 100) break
const rect = image.getBoundingClientRect()
if (rect.width < 8 || rect.height < 8) continue
const src = image.currentSrc || image.src || ''
images.push({ src: src.startsWith('data:') ? `${src.slice(0, 60)}…` : src.slice(0, 500),
alt: String(image.alt || '').slice(0, 200), width: Math.round(rect.width), height: Math.round(rect.height) })
}
return { images, count: images.length, url: location.href, title: document.title }
}
// Numbered labels over the refs of the latest snapshot, for annotated
// screenshots; call again with show=false to remove them.
export function annotateRefs(show) {
document.getElementById('__aiturk_labels')?.remove()
if (!show) return { removed: true }
const layer = document.createElement('div')
layer.id = '__aiturk_labels'
layer.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:2147483647'
const annotations = []
for (const [ref, weak] of globalThis.__aiturkRefs || []) {
const element = weak.deref()
if (!element || element.ownerDocument !== document) continue
const rect = element.getBoundingClientRect()
if (rect.bottom < 0 || rect.top > innerHeight || rect.right < 0 || rect.left > innerWidth) continue
const label = document.createElement('span')
label.textContent = ref.slice(1)
label.style.cssText = `position:fixed;left:${Math.max(0, rect.left)}px;top:${Math.max(0, rect.top)}px;background:#007f79;color:#fff;font:600 11px/14px system-ui;padding:0 3px;border-radius:3px`
layer.append(label)
annotations.push({ ref: `@${ref}`, label: `[${ref.slice(1)}]` })
}
document.documentElement.append(layer)
return { annotations }
}
+1 -1
View File
@@ -1,2 +1,2 @@
:root{color-scheme:light dark;--bg:#f8faf9;--surface:#fff;--ink:#172a2a;--muted:#617675;--line:#dce5e2;--accent:#007f79;--soft:#e7f4ef;--error:#a43d24;font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif} :root{color-scheme:light dark;--bg:#f8faf9;--surface:#fff;--ink:#172a2a;--muted:#617675;--line:#dce5e2;--accent:#007f79;--soft:#e7f4ef;--error:#a43d24;font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif}
*{box-sizing:border-box}body{margin:0;min-width:300px;height:100dvh;display:flex;flex-direction:column;background:var(--bg);color:var(--ink)}header{padding:18px 16px 10px;display:flex;align-items:center;justify-content:space-between;gap:6px}.brand{display:flex;align-items:center;gap:8px;letter-spacing:.02em}.mark{background:var(--accent);color:#fff;border-radius:8px;display:grid;place-items:center;width:28px;height:28px;font-size:19px;font-weight:750}.badge{color:var(--muted);font-size:9px;letter-spacing:.1em}button,input,textarea,select{font:inherit;color:inherit}button{cursor:pointer;background:var(--accent);color:#fff;border:0;border-radius:7px;padding:8px 12px;font-weight:600}button:disabled{opacity:.4;cursor:default}.quiet{background:transparent;color:var(--muted);font-size:12px;padding:6px}.quiet:hover:enabled{background:var(--soft);color:var(--accent)}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.connection{padding:0 16px 14px;display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px}.dot{height:7px;width:7px;border-radius:50%;background:var(--muted);flex-shrink:0}.dot.online{background:var(--accent)}.picker{padding:8px 16px 10px;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.row{display:flex;align-items:center;justify-content:space-between;gap:8px}label{font-weight:650;font-size:12px}input,select,textarea{display:block;width:100%;border:1px solid var(--line);background:var(--surface);border-radius:7px;padding:10px}input{margin:5px 0 8px}select{font-weight:600;text-overflow:ellipsis}.hint{font-size:11px;color:var(--muted);margin:7px 0 0}.picker .hint{margin-top:6px}#notice{margin:10px 16px 0;padding:10px;background:var(--soft);border-left:3px solid var(--error);font-size:12px;overflow-wrap:anywhere}main{flex:1;min-height:100px;overflow-y:auto;padding:12px 16px;scrollbar-width:thin}.empty{padding:30px 6px;color:var(--muted)}.empty-mark{font-size:42px;color:var(--accent);font-weight:800}h1{font-size:27px;line-height:1.2;letter-spacing:-.04em;color:var(--ink);font-weight:600}.message{margin:8px 0 22px;overflow-wrap:anywhere}.message .role{font-weight:700;color:var(--muted);font-size:11px;margin-bottom:6px;letter-spacing:.05em}.message .text{white-space:pre-wrap;tab-size:2}.message.user .text{background:var(--soft);border-radius:9px;padding:10px 12px}.message.streaming .role{color:var(--accent)}.activity{margin:0;padding:3px 16px;color:var(--accent);font-size:12px;min-height:24px}form{padding:8px 16px 10px}textarea{resize:vertical;max-height:220px;min-height:76px}form>.row{margin-top:6px}#attachment{font-size:11px;display:flex;align-items:center;justify-content:space-between;gap:5px;background:var(--soft);border-radius:6px;padding:5px 8px;margin-bottom:6px}#attachment-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}footer{font-size:10px;display:flex;justify-content:space-between;padding:8px 16px 12px;color:var(--muted)}a{color:var(--muted);text-decoration:none}a:hover{text-decoration:underline}[hidden]{display:none!important}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}@media(prefers-color-scheme:dark){:root{--bg:#121c1c;--surface:#192525;--ink:#e1ede9;--muted:#94aaa5;--line:#2e4140;--accent:#43cabb;--soft:#203a34;--error:#e9997e}button{color:#10201d}}@media(max-width:340px){.badge{display:none}} *{box-sizing:border-box}body{margin:0;min-width:300px;height:100dvh;display:flex;flex-direction:column;background:var(--bg);color:var(--ink)}header{padding:18px 16px 10px;display:flex;align-items:center;justify-content:space-between;gap:6px}.brand{display:flex;align-items:center;gap:8px;letter-spacing:.02em}.mark{background:var(--accent);color:#fff;border-radius:8px;display:grid;place-items:center;width:28px;height:28px;font-size:19px;font-weight:750}.badge{color:var(--muted);font-size:9px;letter-spacing:.1em}button,input,textarea,select{font:inherit;color:inherit}button{cursor:pointer;background:var(--accent);color:#fff;border:0;border-radius:7px;padding:8px 12px;font-weight:600}button:disabled{opacity:.4;cursor:default}.quiet{background:transparent;color:var(--muted);font-size:12px;padding:6px}.quiet:hover:enabled{background:var(--soft);color:var(--accent)}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.connection{padding:0 16px 14px;display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px}.dot{height:7px;width:7px;border-radius:50%;background:var(--muted);flex-shrink:0}.dot.online{background:var(--accent)}.picker{padding:8px 16px 10px;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.row{display:flex;align-items:center;justify-content:space-between;gap:8px}label{font-weight:650;font-size:12px}input,select,textarea{display:block;width:100%;border:1px solid var(--line);background:var(--surface);border-radius:7px;padding:10px}input{margin:5px 0 8px}select{font-weight:600;text-overflow:ellipsis}.hint{font-size:11px;color:var(--muted);margin:7px 0 0}.picker .hint{margin-top:6px}#notice{margin:10px 16px 0;padding:10px;background:var(--soft);border-left:3px solid var(--error);font-size:12px;overflow-wrap:anywhere}main{flex:1;min-height:100px;overflow-y:auto;padding:12px 16px;scrollbar-width:thin}.empty{padding:30px 6px;color:var(--muted)}.empty-mark{font-size:42px;color:var(--accent);font-weight:800}h1{font-size:27px;line-height:1.2;letter-spacing:-.04em;color:var(--ink);font-weight:600}.message{margin:8px 0 22px;overflow-wrap:anywhere}.message .role{font-weight:700;color:var(--muted);font-size:11px;margin-bottom:6px;letter-spacing:.05em}.message .text{white-space:pre-wrap;tab-size:2}.message.user .text{background:var(--soft);border-radius:9px;padding:10px 12px}.message.streaming .role{color:var(--accent)}.activity{margin:0;padding:3px 16px;color:var(--accent);font-size:12px;min-height:24px}form{padding:8px 16px 10px}textarea{resize:vertical;max-height:220px;min-height:76px}form>.row{margin-top:6px}#attachment{font-size:11px;display:flex;align-items:center;justify-content:space-between;gap:5px;background:var(--soft);border-radius:6px;padding:5px 8px;margin-bottom:6px}#attachment-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}footer{font-size:10px;display:flex;justify-content:space-between;padding:8px 16px 12px;color:var(--muted)}a{color:var(--muted);text-decoration:none}a:hover{text-decoration:underline}[hidden]{display:none!important}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}.control{margin:0 16px 10px;padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:var(--surface)}.control .hint{margin-top:6px}.control .actions{justify-content:flex-start;gap:4px;margin-top:4px}.toggle{display:flex;align-items:center;gap:8px;font-weight:650;font-size:12px;cursor:pointer;min-height:32px}.toggle input{width:18px;height:18px;margin:0;accent-color:var(--accent)}.stop{background:var(--error);color:#fff;padding:6px 12px}#control-details{margin-top:6px;font-size:11px;color:var(--muted)}#control-details summary{cursor:pointer;min-height:24px}#control-log{margin:6px 0 0;padding-left:18px;max-height:160px;overflow-y:auto}#control-log li{margin:3px 0;overflow-wrap:anywhere}#control-log li.failed{color:var(--error)}#control-log li.running{color:var(--accent)}@media(prefers-color-scheme:dark){:root{--bg:#121c1c;--surface:#192525;--ink:#e1ede9;--muted:#94aaa5;--line:#2e4140;--accent:#43cabb;--soft:#203a34;--error:#e9997e}button{color:#10201d}}@media(max-width:340px){.badge{display:none}}
+7 -1
View File
@@ -4,6 +4,12 @@
<body> <body>
<header><div class="brand"><span class="mark">A</span><strong>AITURK</strong><span class="badge">CHROME BETA</span></div><button id="reconnect" class="quiet">Yeniden bağlan</button></header> <header><div class="brand"><span class="mark">A</span><strong>AITURK</strong><span class="badge">CHROME BETA</span></div><button id="reconnect" class="quiet">Yeniden bağlan</button></header>
<div class="connection"><span id="dot" class="dot"></span><span id="connection" role="status">IDE bağlantısı kuruluyor…</span></div> <div class="connection"><span id="dot" class="dot"></span><span id="connection" role="status">IDE bağlantısı kuruluyor…</span></div>
<section id="control" class="control" aria-label="Ajanın bu Chrome’u kullanması">
<div class="row"><label class="toggle"><input id="control-enabled" type="checkbox"><span>Ajan bu Chrome’u kullanabilsin</span></label><button id="control-stop" type="button" class="stop" hidden>Durdur</button></div>
<p id="control-status" class="hint" role="status">Durum okunuyor…</p>
<div class="row actions"><button id="control-share" type="button" class="quiet">+ Bu sekmeyi ajana ver</button><button id="control-release" type="button" class="quiet" hidden>Sekmeleri geri al</button></div>
<details id="control-details" hidden><summary>Ajanın son işlemleri</summary><ol id="control-log"></ol></details>
</section>
<section class="picker" aria-label="IDE konuşmaları"> <section class="picker" aria-label="IDE konuşmaları">
<div class="row"><label for="search">IDE konuşmaların</label><button id="refresh" class="quiet" disabled>Yenile</button></div> <div class="row"><label for="search">IDE konuşmaların</label><button id="refresh" class="quiet" disabled>Yenile</button></div>
<input id="search" type="search" placeholder="Başlığa göre ara…" autocomplete="off" disabled> <input id="search" type="search" placeholder="Başlığa göre ara…" autocomplete="off" disabled>
@@ -19,6 +25,6 @@
<div class="row"><button id="add-page" type="button" class="quiet" disabled>+ Sayfayı / seçimi ekle</button><div><button id="stop" type="button" class="quiet" hidden>Durdur</button><button id="send" type="submit" disabled>Gönder ↑</button></div></div> <div class="row"><button id="add-page" type="button" class="quiet" disabled>+ Sayfayı / seçimi ekle</button><div><button id="stop" type="button" class="quiet" hidden>Durdur</button><button id="send" type="submit" disabled>Gönder ↑</button></div></div>
<p class="hint">Sayfa yalnızca siz eklediğinizde okunur. Gönderilen mesajlar IDE’nin araçlarını çalıştırabilir. Onaylar IDE’de yanıtlanır.</p> <p class="hint">Sayfa yalnızca siz eklediğinizde okunur. Gönderilen mesajlar IDE’nin araçlarını çalıştırabilir. Onaylar IDE’de yanıtlanır.</p>
</form> </form>
<footer><a href="https://turkservis.online/indir" target="_blank" rel="noopener noreferrer">Kurulum ve yardım ↗</a><span>0.1.0 · Windows</span></footer> <footer><a href="https://turkservis.online/indir" target="_blank" rel="noopener noreferrer">Kurulum ve yardım ↗</a><span>0.2.0 · Windows · Alt+Shift+A</span></footer>
<script src="panel.js" type="module"></script> <script src="panel.js" type="module"></script>
</body></html> </body></html>
+83 -9
View File
@@ -112,9 +112,11 @@ function paintHistory(messages) {
async function openSession(id) { async function openSession(id) {
const intent = ++generation const intent = ++generation
// A page sent from the context menu before any conversation was chosen
// waits for the first choice; switching conversations still clears it.
if (selected) { attachment = null; $('attachment').hidden = true }
selected = id; opening = Boolean(id); stream = null selected = id; opening = Boolean(id); stream = null
bufferedEvents = [] bufferedEvents = []
attachment = null; $('attachment').hidden = true
$('message').value = ''; $('stop').hidden = true $('message').value = ''; $('stop').hidden = true
notice(''); controls() notice(''); controls()
if (!id) { opening = false; $('transcript').replaceChildren(); activity('Bir konuşma seçin.'); return } if (!id) { opening = false; $('transcript').replaceChildren(); activity('Bir konuşma seçin.'); return }
@@ -203,20 +205,92 @@ $('stop').addEventListener('click', async () => {
try { await request('message.stop', { sessionId: selected }); activity('Durdurma isteği IDE’ye iletildi.') } try { await request('message.stop', { sessionId: selected }); activity('Durdurma isteği IDE’ye iletildi.') }
catch (error) { notice(error.message) } catch (error) { notice(error.message) }
}) })
function setAttachment(context) {
if (!context?.text) throw new Error('Bu sayfadan okunabilir metin alınamadı.')
attachment = context
$('attachment-label').textContent = `${context.selection ? 'Seçim' : 'Sayfa'}: ${context.title} · ${context.text.length.toLocaleString('tr')} karakter`
$('attachment').hidden = false
notice(selected ? 'Sayfa içeriği mesajınızla birlikte IDE’ye ve seçili model sağlayıcısına gönderilecek. Hassas bilgi içermediğinden emin olun.'
: 'Ek hazır. Göndermek için yukarıdan bir IDE konuşması seçin.')
}
async function capture(tabId) {
const results = await chrome.scripting.executeScript({ target: { tabId }, func: capturePageContext })
return results[0]?.result
}
$('add-page').addEventListener('click', async () => { $('add-page').addEventListener('click', async () => {
const intent = generation const intent = generation
try { try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
if (!tab?.id || !/^https?:\/\//.test(tab.url || '')) throw new Error('Yalnızca normal HTTP/HTTPS sayfaları eklenebilir. Önce AITURK araç çubuğu simgesine tıklayarak bu sekmeye erişim verin.') if (!tab?.id || !/^https?:\/\//.test(tab.url || '')) throw new Error('Yalnızca normal HTTP/HTTPS sayfaları eklenebilir.')
const results = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: capturePageContext }) const context = await capture(tab.id)
if (intent !== generation) return if (intent !== generation) return
const context = results[0]?.result setAttachment(context)
if (!context?.text) throw new Error('Bu sayfadan okunabilir metin alınamadı.')
attachment = context
$('attachment-label').textContent = `${context.selection ? 'Seçim' : 'Sayfa'}: ${context.title} · ${context.text.length.toLocaleString('tr')} karakter`
$('attachment').hidden = false
notice('Sayfa içeriği mesajınızla birlikte IDE’ye ve seçili model sağlayıcısına gönderilecek. Hassas bilgi içermediğinden emin olun.')
} catch (error) { notice(error.message) } } catch (error) { notice(error.message) }
}) })
// Text or a page sent from the right-click menu while the panel opens.
async function takePendingContext() {
const saved = await chrome.storage.session.get(['pendingContext', 'pendingNotice'])
if (saved.pendingNotice) { notice(saved.pendingNotice); await chrome.storage.session.remove('pendingNotice') }
const pending = saved.pendingContext
if (!pending) return
await chrome.storage.session.remove('pendingContext')
if (Date.now() - pending.at > 120000) return
try {
if (pending.kind === 'selection' && pending.text) {
const tab = await chrome.tabs.get(pending.tabId).catch(() => null)
let address = ''
try { const url = new URL(tab?.url || ''); address = url.origin + url.pathname } catch {}
setAttachment({ title: (tab?.title || '').slice(0, 200), url: address, text: pending.text.slice(0, 24000), selection: true })
} else setAttachment(await capture(pending.tabId))
} catch (error) { notice(error.message) }
}
// ── The agent using this Chrome ───────────────────────────────────────
const STATUS_MARK = { ok: '✓', failed: '✕', cancelled: '■', running: '…' }
function renderControl(state) {
if (!state || typeof state !== 'object') return
$('control-enabled').checked = Boolean(state.enabled)
$('control-stop').hidden = !(state.enabled && state.running)
$('control-release').hidden = !(state.tabs?.length || state.shared)
let text
if (!state.enabled) text = 'Kapalı. Açarsan AITURK ajanı tarayıcı işlerini bu Chrome’da, kendi “AITURK” sekmelerinde yapar.'
else if (!state.registered) text = `IDE bekleniyor… AITURK IDE açık olmalı.${state.error ? ' (' + state.error + ')' : ''}`
else if (state.running) text = 'Ajan şu an bu Chrome’da çalışıyor. Durdurmak için “Durdur”a bas.'
else text = 'Hazır: ajan tarayıcı işlerini bu Chrome’da, “AITURK” sekme grubunda yapar. Şifre ve kart alanlarına yazmaz.'
if (state.shared) text += ` Paylaşılan sekme: “${state.shared.title || 'sekme'}”; ajan bir sonraki tarayıcı işinde bunu kullanır.`
if (state.tabs?.length) text += ` Ajanın sekmeleri: ${state.tabs.map(tab => tab.url || tab.title).filter(Boolean).join(', ')}.`
$('control-status').textContent = text
const items = (state.log || []).map(entry => {
const item = document.createElement('li')
item.className = entry.status
item.textContent = `${STATUS_MARK[entry.status] || ''} ${entry.text}${entry.note ? ' — ' + entry.note : ''}`
return item
})
$('control-log').replaceChildren(...items)
$('control-details').hidden = !items.length
}
async function control(type, extra = {}) {
try {
const reply = await chrome.runtime.sendMessage({ type, ...extra })
if (reply?.failure) notice(reply.failure)
else renderControl(reply)
} catch (error) { notice(`Eklenti arka planına ulaşılamadı: ${error.message}`) }
}
chrome.runtime.onMessage.addListener(message => { if (message?.type === 'control.state') renderControl(message.state) })
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'session' && (changes.pendingContext?.newValue || changes.pendingNotice?.newValue)) void takePendingContext()
})
$('control-enabled').addEventListener('change', () => void control('control.set', { enabled: $('control-enabled').checked }))
$('control-stop').addEventListener('click', () => void control('control.stop'))
$('control-share').addEventListener('click', () => void control('control.share', { sessionId: selected || null }))
$('control-release').addEventListener('click', () => void control('control.release'))
$('remove-attachment').addEventListener('click', () => { attachment = null; $('attachment').hidden = true; notice('') }) $('remove-attachment').addEventListener('click', () => { attachment = null; $('attachment').hidden = true; notice('') })
void connect() void connect()
void control('control.get')
void takePendingContext()
+339
View File
@@ -0,0 +1,339 @@
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { test } from 'node:test'
import * as page from '../extension/page-agent.js'
import { CAPABILITIES, checkUrl, createController, parseKey, ToolError } from '../extension/controller.js'
const { JSDOM } = createRequire(import.meta.url)('jsdom')
const GLOBALS = ['window', 'document', 'location', 'getComputedStyle', 'HTMLInputElement', 'HTMLTextAreaElement', 'Event', 'innerHeight', 'innerWidth']
function pageFixture(t, html) {
const dom = new JSDOM(`<!doctype html><html><head><title>Sipariş formu</title></head><body>${html}</body></html>`,
{ url: 'https://shop.example.test/checkout?session=gizli' })
const { window } = dom
window.HTMLElement.prototype.getBoundingClientRect = function () {
if (this.dataset.size === '0') return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }
return { left: 10, top: 20, right: 110, bottom: 40, width: 100, height: 20 }
}
window.HTMLElement.prototype.scrollIntoView = () => {}
window.document.elementFromPoint = () => null
window.scrollBy = () => {}
for (const key of GLOBALS) globalThis[key] = key === 'innerHeight' ? 800 : key === 'innerWidth' ? 1200 : window[key]
t.after(() => { delete globalThis.__aiturkRefs; window.close(); for (const key of GLOBALS) delete globalThis[key] })
return window
}
const FORM = `
<h1>Ödeme</h1>
<nav aria-hidden="true"><a href="/gizli">Gizli menü</a></nav>
<a href="/docs">Belgeler</a>
<label for="mail">E-posta</label><input id="mail" type="email" value="ali@example.test">
<input type="password" aria-label="Şifre" value="cok-gizli">
<input autocomplete="cc-number" aria-label="Kart numarası">
<button style="display:none">Görünmez</button>
<button data-size="0">Sıfır boyut</button>
<select aria-label="Şehir"><option>Ankara</option><option selected>İzmir</option></select>
<label><input type="checkbox" checked> Koşulları kabul ediyorum</label>
<p>Toplam tutar: 120 TL</p>
<button>Siparişi tamamla</button>
<img src="data:image/png;base64,${'A'.repeat(200)}" alt="Ürün">`
test('snapshot lists visible controls with refs, masks passwords and skips hidden parts', t => {
pageFixture(t, FORM)
const result = page.snapshotPage({ full: false })
const lines = result.snapshot.split('\n')
assert.equal(lines[0], 'Sayfa: Sipariş formu')
assert.ok(lines.includes('- heading "Ödeme" [level=1]'))
assert.ok(lines.includes('- link "Belgeler" [ref=e1]'))
assert.ok(lines.includes('- textbox "E-posta" [ref=e2] (değer: "ali@example.test")'))
const password = lines.find(line => line.includes('"Şifre"'))
assert.ok(password.includes('(değer: "••••")') && password.includes('şifre alanı: ajan yazamaz'))
assert.ok(!result.snapshot.includes('cok-gizli'))
assert.ok(!result.snapshot.includes('Gizli menü'))
assert.ok(!result.snapshot.includes('Görünmez') && !result.snapshot.includes('Sıfır boyut'))
assert.ok(lines.some(line => line.startsWith('- combobox "Şehir"') && line.includes('(değer: "İzmir")')))
assert.ok(lines.some(line => line.startsWith('- checkbox "Koşulları kabul ediyorum"') && line.includes('[checked]')))
assert.ok(!result.snapshot.includes('Toplam tutar'), 'compact view leaves plain text out')
assert.equal(result.element_count, 7)
assert.equal(globalThis.__aiturkRefs.size, 7)
const full = page.snapshotPage({ full: true })
assert.ok(full.snapshot.includes('- text: Toplam tutar: 120 TL'))
assert.ok(full.snapshot.includes('→ https://shop.example.test/docs'))
assert.ok(full.snapshot.includes('- option "Ankara"'))
const short = page.snapshotPage({ limit: 2000, full: true })
assert.ok(short.snapshot.length <= 2300)
})
test('a long page is cut with an explicit note instead of silently', t => {
pageFixture(t, Array.from({ length: 400 }, (_, index) => `<button>Düğme numarası ${index}</button>`).join(''))
const result = page.snapshotPage({ limit: 2000 })
assert.equal(result.truncated, true)
assert.match(result.snapshot, /satır daha var; kısaltıldı/)
})
test('refs resolve to coordinates; stale refs fail with a recoverable message', t => {
const window = pageFixture(t, FORM)
page.snapshotPage({})
const located = page.locateRef('@e2')
assert.deepEqual([located.x, located.y, located.tag, located.password], [60, 30, 'input', false])
window.document.getElementById('mail').remove()
assert.match(page.locateRef('@e2').error, /browser_snapshot/)
assert.match(page.locateRef('e99').error, /bulunamadı/)
})
test('typing refuses password and card fields, focuses text fields and fires framework events', t => {
const window = pageFixture(t, FORM)
page.snapshotPage({})
const refs = [...globalThis.__aiturkRefs.keys()]
const refOf = predicate => `@${refs.find(ref => predicate(globalThis.__aiturkRefs.get(ref).deref()))}`
const passwordRef = refOf(element => element.type === 'password')
const cardRef = refOf(element => element.getAttribute('autocomplete') === 'cc-number')
assert.equal(page.prepareTyping(passwordRef).handoff, true)
assert.match(page.prepareTyping(cardRef).error, /Kart/)
assert.match(page.prepareTyping('@e1').error, /yazı alanı değil/)
const mail = window.document.getElementById('mail')
let inputs = 0
mail.addEventListener('input', () => inputs++)
assert.equal(page.prepareTyping('@e2').focused, true)
assert.deepEqual(page.typeWithEvents('@e2', 'veli@example.test'), { value: 'veli@example.test' })
assert.equal(inputs, 1)
assert.deepEqual(page.readValue('@e2'), { value: 'veli@example.test' })
const selectRef = refOf(element => element.tagName === 'SELECT')
assert.deepEqual(page.prepareTyping(selectRef), { select: true })
assert.deepEqual(page.typeWithEvents(selectRef, 'ankara'), { value: 'Ankara' })
assert.match(page.typeWithEvents(selectRef, 'Paris').error, /seçeneği yok/)
})
test('images are listed with data URLs shortened; annotation labels come and go', t => {
const window = pageFixture(t, FORM)
const images = page.listImages()
assert.equal(images.count, 1)
assert.ok(images.images[0].src.length < 70 && images.images[0].alt === 'Ürün')
page.snapshotPage({})
const labels = page.annotateRefs(true)
assert.equal(labels.annotations.length, 7)
assert.ok(window.document.getElementById('__aiturk_labels'))
page.annotateRefs(false)
assert.equal(window.document.getElementById('__aiturk_labels'), null)
})
test('keys and addresses are validated before anything touches the page', () => {
assert.deepEqual(parseKey('Enter'), { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r', modifiers: 0 })
assert.deepEqual(parseKey('ctrl+a'), { key: 'a', code: 'KeyA', windowsVirtualKeyCode: 65, text: undefined, modifiers: 2 })
assert.equal(parseKey('Shift+Tab').modifiers, 8)
assert.equal(parseKey('esc').key, 'Escape')
assert.throws(() => parseKey('Hyper+x'), ToolError)
assert.throws(() => parseKey('NotAKey'), ToolError)
assert.equal(checkUrl('example.test/a?b=1'), 'https://example.test/a?b=1')
assert.equal(checkUrl('http://localhost:5173/'), 'http://localhost:5173/')
for (const blocked of ['chrome://settings', 'file:///C:/Windows/win.ini', 'javascript:alert(1)', 'chrome-extension://abc/panel.html',
'https://chromewebstore.google.com/detail/x', 'https://user:pass@example.test/']) {
assert.throws(() => checkUrl(blocked), ToolError, blocked)
}
})
// ── controller with a fake Chrome ──────────────────────────────────────
const hub = () => ({ listeners: new Set(), addListener(fn) { this.listeners.add(fn) }, removeListener(fn) { this.listeners.delete(fn) },
emit(...args) { for (const fn of [...this.listeners]) fn(...args) } })
const pick = (store, keys) => Object.fromEntries([keys].flat().filter(key => key in store).map(key => [key, store[key]]))
const until = async (condition, label) => {
for (let index = 0; index < 400; index++) { if (condition()) return; await new Promise(resolve => setTimeout(resolve, 5)) }
assert.fail(`zaman aşımı: ${label}`)
}
function fakeChrome({ pageScript = {}, tabs: initialTabs = [] } = {}) {
const local = {}, session = {}, native = [], debuggerCalls = [], notifications = [], groups = new Map()
const tabs = new Map(initialTabs.map(tab => [tab.id, { status: 'complete', windowId: 1, active: true, ...tab }]))
let nextTab = 100, nextGroup = 1, port = null
const onUpdated = hub()
const respond = message => {
const result = message.action === 'status' ? { profile: 'default', local: true }
: message.action === 'browser.register' ? { registered: message.params.enabled }
: message.action === 'sessions.list' ? { sessions: [{ id: 'durable-1', title: 'Rapor işi' }] }
: { accepted: true }
port.onMessage.emit({ id: message.id, result })
}
const chrome = {
runtime: {
connectNative: () => {
port = { onMessage: hub(), onDisconnect: hub(), disconnected: false, disconnect() { this.disconnected = true },
postMessage: message => { native.push(message); queueMicrotask(() => respond(message)) } }
return port
},
sendMessage: async () => {},
},
storage: {
local: { get: async keys => pick(local, keys), set: async value => Object.assign(local, value) },
session: { get: async keys => structuredClone(pick(session, keys)), set: async value => Object.assign(session, structuredClone(value)) },
},
windows: { getLastFocused: async () => ({ id: 1 }) },
tabs: {
onUpdated,
create: async ({ url, active, windowId }) => {
const tab = { id: nextTab++, url, active, windowId, status: 'complete', title: '' }
tabs.set(tab.id, tab)
return { ...tab }
},
get: async id => { if (!tabs.has(id)) throw new Error(`No tab with id: ${id}`); return { ...tabs.get(id) } },
update: async (id, { url }) => {
const tab = tabs.get(id)
Object.assign(tab, { pendingUrl: url, status: 'loading' })
setTimeout(() => { Object.assign(tab, { url, pendingUrl: undefined, status: 'complete', title: 'Açılan sayfa' }); onUpdated.emit(id, { status: 'complete' }) }, 20)
return { ...tab }
},
goBack: async () => {},
query: async () => [...tabs.values()].filter(tab => tab.active).map(tab => ({ ...tab })),
group: async ({ tabIds, groupId }) => { const id = groupId ?? nextGroup++; groups.set(id, { windowId: tabs.get(tabIds[0]).windowId }); return id },
captureVisibleTab: async () => 'data:image/jpeg;base64,QUJD',
},
tabGroups: { get: async id => ({ id, ...groups.get(id) }), update: async (id, value) => Object.assign(groups.get(id), value) },
scripting: {
executeScript: async ({ target, func, args }) => {
const handler = pageScript[func.name]
if (!handler) throw new Error(`script not expected: ${func.name}`)
return [{ result: await handler(...args, target.tabId) }]
},
},
debugger: {
attach: async () => {}, detach: async () => {},
sendCommand: async (target, method, params) => { debuggerCalls.push([method, params]); return method === 'Page.captureScreenshot' ? { data: 'QUJD' } : {} },
},
notifications: { create: async (...args) => { notifications.push(args) } },
alarms: { create() {}, clear() {} },
action: { setBadgeText: async () => {} },
}
return { chrome, native, debuggerCalls, notifications, tabs, groups, local, session, get port() { return port } }
}
const SNAPSHOT = { snapshot: 'Sayfa: Açılan sayfa\n- button "Gönder" [ref=e1]', element_count: 1, url: 'https://example.test/', title: 'Açılan sayfa' }
async function connected(fake) {
const controller = createController({ chrome: fake.chrome, random: () => 'instance-1' })
await controller.connect()
await until(() => controller._state().registered, 'registration')
return controller
}
function command(fake, action, args, commandId = 'ab'.repeat(16)) {
fake.port.onMessage.emit({ event: 'browser.command', commandId, action, arguments: args, sessionId: 'durable-1' })
}
const resultFor = (fake, commandId = 'ab'.repeat(16)) => fake.native.find(message => message.action === 'browser.result' && message.params.commandId === commandId)
test('controller registers this Chrome for the IDE agent with a stable identity', async () => {
const fake = fakeChrome()
await connected(fake)
const register = fake.native.find(message => message.action === 'browser.register')
assert.deepEqual(register.params, { enabled: true, capabilities: [...CAPABILITIES], controllerId: 'aiturk-chrome-instance-1', browserProfileId: 'instance-1' })
assert.equal(fake.local.controllerInstance, 'instance-1')
})
test('navigate opens the agent its own background tab in an AITURK group and returns a snapshot', async () => {
const fake = fakeChrome({ pageScript: { snapshotPage: () => SNAPSHOT } })
await connected(fake)
command(fake, 'browser_navigate', { url: 'example.test' })
await until(() => resultFor(fake), 'navigate result')
const { params } = resultFor(fake)
assert.equal(params.ok, true)
assert.equal(params.result.success, true)
assert.equal(params.result.snapshot, SNAPSHOT.snapshot)
const tab = fake.tabs.get(100)
assert.equal(tab.active, false)
assert.equal(tab.url, 'https://example.test/')
assert.equal(fake.groups.get(1).title, 'AITURK · Rapor işi')
assert.equal(fake.groups.get(1).color, 'cyan')
})
test('blocked pages and missing tabs come back as tool errors the agent can read', async () => {
const fake = fakeChrome()
await connected(fake)
command(fake, 'browser_navigate', { url: 'chrome://settings/passwords' }, '01'.repeat(16))
command(fake, 'browser_click', { ref: '@e1' }, '02'.repeat(16))
await until(() => resultFor(fake, '01'.repeat(16)) && resultFor(fake, '02'.repeat(16)), 'refusals')
assert.deepEqual(resultFor(fake, '01'.repeat(16)).params.result.success, false)
assert.match(resultFor(fake, '01'.repeat(16)).params.result.error, /çalışamaz/)
assert.match(resultFor(fake, '02'.repeat(16)).params.result.error, /browser_navigate/)
assert.equal(fake.tabs.size, 0, 'a refused navigation never opens a tab')
})
test('click is a real mouse click at the element; a covering element is reported', async () => {
const fake = fakeChrome({ tabs: [{ id: 7, url: 'https://example.test/', title: 'Örnek' }],
pageScript: { locateRef: () => ({ x: 60, y: 30, name: 'Gönder', covered: 'Çerez bildirimi' }) } })
const controller = await connected(fake)
await controller.shareTab(await fake.chrome.tabs.get(7), 'durable-1')
command(fake, 'browser_click', { ref: '@e1' })
await until(() => resultFor(fake), 'click result')
const { result } = resultFor(fake).params
assert.equal(result.success, true)
assert.match(result.warning, /Çerez bildirimi/)
const mouse = fake.debuggerCalls.filter(([method]) => method === 'Input.dispatchMouseEvent').map(([, params]) => [params.type, params.x, params.y])
assert.deepEqual(mouse, [['mousePressed', 60, 30], ['mouseReleased', 60, 30]])
})
test('password fields are handed to the user: nothing is typed and a notification is shown', async () => {
const fake = fakeChrome({ tabs: [{ id: 7, url: 'https://example.test/login' }],
pageScript: { prepareTyping: () => ({ error: 'Şifre alanına ajan yazamaz. Bu adımı kullanıcıya bırakın.', handoff: true }) } })
const controller = await connected(fake)
await controller.shareTab(await fake.chrome.tabs.get(7), 'durable-1')
command(fake, 'browser_type', { ref: '@e2', text: 'parola' })
await until(() => resultFor(fake), 'type result')
assert.equal(resultFor(fake).params.result.success, false)
assert.ok(!fake.debuggerCalls.some(([method]) => method === 'Input.insertText'))
assert.equal(fake.notifications.length, 1)
})
test('typing inserts trusted text and reports what the field now holds', async () => {
const fake = fakeChrome({ tabs: [{ id: 7, url: 'https://example.test/' }],
pageScript: { prepareTyping: () => ({ focused: true }), readValue: () => ({ value: 'İstanbul' }) } })
const controller = await connected(fake)
await controller.shareTab(await fake.chrome.tabs.get(7), 'durable-1')
command(fake, 'browser_type', { ref: '@e3', text: 'İstanbul' })
await until(() => resultFor(fake), 'type result')
assert.deepEqual(resultFor(fake).params.result, { success: true, typed: '@e3', value: 'İstanbul' })
assert.deepEqual(fake.debuggerCalls.find(([method]) => method === 'Input.insertText')[1], { text: 'İstanbul' })
})
test('vision returns a JPEG capture of the agent tab', async () => {
const fake = fakeChrome({ tabs: [{ id: 7, url: 'https://example.test/', title: 'Örnek' }] })
const controller = await connected(fake)
await controller.shareTab(await fake.chrome.tabs.get(7), 'durable-1')
command(fake, 'browser_vision', { question: 'Ne var?' })
await until(() => resultFor(fake), 'vision result')
assert.deepEqual(resultFor(fake).params.result, { success: true, screenshot: 'QUJD', mime: 'image/jpeg', url: 'https://example.test/', title: 'Örnek' })
})
test('a cancelled command never answers late; stopping turns control off at the IDE', async () => {
let release
const fake = fakeChrome({ tabs: [{ id: 7, url: 'https://example.test/' }],
pageScript: { snapshotPage: () => new Promise(resolve => { release = () => resolve(SNAPSHOT) }) } })
const controller = await connected(fake)
await controller.shareTab(await fake.chrome.tabs.get(7), 'durable-1')
command(fake, 'browser_snapshot', {})
await until(() => release, 'snapshot started')
fake.port.onMessage.emit({ event: 'browser.cancel', commandId: 'ab'.repeat(16) })
release()
await new Promise(resolve => setTimeout(resolve, 50))
assert.equal(resultFor(fake), undefined)
const port = fake.port
const state = await controller.onMessage({ type: 'control.stop' })
assert.equal(state.enabled, false)
assert.deepEqual(fake.native.filter(message => message.action === 'browser.register').at(-1).params, { enabled: false })
assert.equal(port.disconnected, true)
assert.equal(fake.local.controlEnabled, false)
})
test('cancelling Chrome’s debugging bar is treated as the user stopping the agent', async () => {
const fake = fakeChrome({ tabs: [{ id: 7, url: 'https://example.test/' }],
pageScript: { locateRef: () => ({ x: 1, y: 1, name: 'x' }) } })
const controller = await connected(fake)
await controller.shareTab(await fake.chrome.tabs.get(7), 'durable-1')
command(fake, 'browser_click', { ref: '@e1' })
await until(() => resultFor(fake), 'click result')
controller.onDebuggerDetach({ tabId: 7 }, 'canceled_by_user')
await until(() => fake.local.controlEnabled === false, 'disabled')
assert.equal(fake.notifications.length, 1)
})
+49
View File
@@ -151,6 +151,55 @@ test('real HTTP+WebSocket: same stored/runtime session, queue sends and preserve
assert.ok(!JSON.stringify(events).includes('secret never')) assert.ok(!JSON.stringify(events).includes('secret never'))
}) })
test('browser control: only a registered connection receives agent commands; results and refusals go back', async t => {
const f = await fixture(t), events = []
const client = new ChromeGatewayClient(f.resolve, event => events.push(event))
t.after(() => client.close())
const call = (action, params = {}) => client.handle({ id: 'test', action, params })
const command = (action, args = {}) => client.onEvent({ type: 'browser.controller.command', session_id: 'durable-1',
payload: { command_id: 'ab'.repeat(16), action, arguments: args, tool_call_id: 'call-1' } })
assert.throws(() => validateRequest({ id: 'a', action: 'browser.register', params: { enabled: true, capabilities: ['browser_cdp'], controllerId: 'c', browserProfileId: 'p' } }))
assert.throws(() => validateRequest({ id: 'a', action: 'browser.result', params: { commandId: '../x', ok: true } }))
assert.throws(() => validateRequest({ id: 'a', action: 'browser.result', params: { commandId: 'ab'.repeat(16), ok: false } }))
await call('status')
command('browser_navigate', { url: 'https://example.test/' })
assert.equal(events.length, 0, 'commands are never forwarded before the extension registers')
await call('browser.register', { enabled: true, capabilities: ['browser_navigate', 'browser_vision'], controllerId: 'chrome-ext', browserProfileId: 'profile-1' })
assert.deepEqual(f.calls.at(-1).params, { protocol_version: 1, controller_id: 'chrome-ext', browser_profile_id: 'profile-1',
capabilities: ['browser_navigate', 'browser_vision'], profile: 'default' })
assert.equal(f.calls.at(-1).method, 'browser.controller.local.register')
command('browser_navigate', { url: 'https://example.test/' })
assert.deepEqual(events.at(-1), { event: 'browser.command', commandId: 'ab'.repeat(16), action: 'browser_navigate',
arguments: { url: 'https://example.test/' }, sessionId: 'durable-1' })
const before = f.calls.length
command('browser_evaluate', { expression: 'document.cookie' })
command('browser_type', { ref: '@e1', text: 'x'.repeat(300 * 1024) })
assert.equal(events.at(-1).action, 'browser_navigate', 'refused commands never reach the extension')
await new Promise(resolve => setTimeout(resolve, 50))
const refusals = f.calls.slice(before)
assert.equal(refusals.length, 2)
assert.ok(refusals.every(frame => frame.method === 'browser.controller.local.result' && frame.params.ok === false))
client.onEvent({ type: 'browser.controller.cancel', payload: { command_id: 'cd'.repeat(16) } })
assert.deepEqual(events.at(-1), { event: 'browser.cancel', commandId: 'cd'.repeat(16) })
await call('browser.result', { commandId: 'ab'.repeat(16), ok: true, result: { success: true, url: 'https://example.test/' } })
assert.deepEqual(f.calls.at(-1).params, { command_id: 'ab'.repeat(16), ok: true, result: { success: true, url: 'https://example.test/' } })
await call('browser.result', { commandId: 'ab'.repeat(16), ok: false, error: 'Sekme kapandı' })
assert.deepEqual(f.calls.at(-1).params, { command_id: 'ab'.repeat(16), ok: false, error: 'Sekme kapandı' })
await call('browser.register', { enabled: false })
assert.equal(f.calls.at(-1).method, 'browser.controller.local.detach')
const count = events.length
command('browser_navigate', { url: 'https://example.test/' })
assert.equal(events.length, count, 'switching control off stops forwarding')
})
test('remote and non-loopback descriptors are rejected before opening a socket', async () => { test('remote and non-loopback descriptors are rejected before opening a socket', async () => {
const client = new ChromeGatewayClient(async () => ({ baseUrl: 'https://example.test', wsUrl: 'wss://example.test/api/ws', token: 'x', mode: 'remote' }), () => {}) const client = new ChromeGatewayClient(async () => ({ baseUrl: 'https://example.test', wsUrl: 'wss://example.test/api/ws', token: 'x', mode: 'remote' }), () => {})
await assert.rejects(client.handle({ id: '1', action: 'status' }), /yalnızca/) await assert.rejects(client.handle({ id: '1', action: 'status' }), /yalnızca/)
+57 -4
View File
@@ -12,21 +12,33 @@ async function settle(condition) {
assert.ok(condition(), 'UI did not settle') assert.ok(condition(), 'UI did not settle')
} }
async function panel(t, respond) { const CONTROL_READY = { enabled: true, connected: true, registered: true, running: 0, tabs: [], shared: null,
log: [{ status: 'ok', text: 'Sayfa açılıyor: example.test' }, { status: 'failed', text: '<b>Yazılıyor</b>', note: 'Şifre alanına ajan yazamaz.' }] }
async function panel(t, respond, { controlState = CONTROL_READY, session = {} } = {}) {
const html = await readFile(new URL('../extension/panel.html', import.meta.url), 'utf8') const html = await readFile(new URL('../extension/panel.html', import.meta.url), 'utf8')
const dom = new JSDOM(html, { url: 'https://extension-fixture.test/panel.html' }) const dom = new JSDOM(html, { url: 'https://extension-fixture.test/panel.html' })
const calls = [] const calls = [], controlCalls = []
const port = { onMessage: eventHub(), onDisconnect: eventHub(), disconnect() {}, postMessage(message) { const port = { onMessage: eventHub(), onDisconnect: eventHub(), disconnect() {}, postMessage(message) {
calls.push(message) calls.push(message)
queueMicrotask(() => respond(message, frame => port.onMessage.emit(frame), port)) queueMicrotask(() => respond(message, frame => port.onMessage.emit(frame), port))
} } } }
const runtimeMessages = eventHub()
const sessionStore = { ...session }
Object.assign(globalThis, { window: dom.window, document: dom.window.document, Option: dom.window.Option, Object.assign(globalThis, { window: dom.window, document: dom.window.document, Option: dom.window.Option,
chrome: { runtime: { connectNative: () => port }, storage: { local: { get: async () => ({}), set: async () => {} } } } }) chrome: {
runtime: { connectNative: () => port, onMessage: runtimeMessages,
sendMessage: async message => { controlCalls.push(message); return message.type === 'control.set' ? { ...controlState, enabled: message.enabled } : controlState } },
storage: { local: { get: async () => ({}), set: async () => {} }, onChanged: eventHub(),
session: { get: async keys => Object.fromEntries([keys].flat().filter(key => key in sessionStore).map(key => [key, sessionStore[key]])),
remove: async key => { delete sessionStore[key] }, set: async value => Object.assign(sessionStore, value) } },
tabs: { get: async id => ({ id, title: 'Kaynak sayfa', url: 'https://example.test/yazi?token=gizli#x' }) },
} })
await import(`../extension/panel.js?test=${Math.random()}`) await import(`../extension/panel.js?test=${Math.random()}`)
t.after(() => { dom.window.close(); for (const key of ['window', 'document', 'Option', 'chrome']) delete globalThis[key] }) t.after(() => { dom.window.close(); for (const key of ['window', 'document', 'Option', 'chrome']) delete globalThis[key] })
const get = id => dom.window.document.getElementById(id) const get = id => dom.window.document.getElementById(id)
await settle(() => !get('sessions').disabled) await settle(() => !get('sessions').disabled)
return { dom, calls, port, get, async select(id) { return { dom, calls, controlCalls, runtimeMessages, port, get, async select(id) {
get('sessions').value = id get('sessions').value = id
get('sessions').dispatchEvent(new dom.window.Event('change')) get('sessions').dispatchEvent(new dom.window.Event('change'))
await settle(() => !get('sessions').disabled) await settle(() => !get('sessions').disabled)
@@ -101,3 +113,44 @@ test('explicit page capture excludes form values and strips URL credentials in q
assert.equal(context.url, 'https://example.test/page') assert.equal(context.url, 'https://example.test/page')
assert.equal(context.selection, false) assert.equal(context.selection, false)
}) })
test('agent control section shows the live state, never renders log text as HTML, and drives the background', async t => {
const p = await panel(t, fixtureResponse)
await settle(() => p.get('control-status').textContent.startsWith('Hazır'))
assert.equal(p.get('control-enabled').checked, true)
assert.equal(p.get('control-stop').hidden, true)
assert.equal(p.get('control-log').querySelector('b'), null)
assert.ok(p.get('control-log').textContent.includes('<b>Yazılıyor</b>'))
assert.ok(p.get('control-log').textContent.includes('Şifre alanına ajan yazamaz.'))
p.runtimeMessages.emit({ type: 'control.state', state: { ...CONTROL_READY, running: 1 } })
assert.equal(p.get('control-stop').hidden, false)
assert.ok(p.get('control-status').textContent.includes('çalışıyor'))
await p.select('one')
p.get('control-share').dispatchEvent(new p.dom.window.Event('click'))
p.get('control-stop').dispatchEvent(new p.dom.window.Event('click'))
p.get('control-enabled').checked = false
p.get('control-enabled').dispatchEvent(new p.dom.window.Event('change'))
await settle(() => p.controlCalls.some(call => call.type === 'control.set'))
assert.deepEqual(p.controlCalls.find(call => call.type === 'control.share'), { type: 'control.share', sessionId: 'one' })
assert.ok(p.controlCalls.some(call => call.type === 'control.stop'))
assert.deepEqual(p.controlCalls.find(call => call.type === 'control.set'), { type: 'control.set', enabled: false })
await settle(() => p.get('control-status').textContent.startsWith('Kapalı'))
})
test('a selection sent from the right-click menu waits for the first conversation and drops URL secrets', async t => {
const p = await panel(t, fixtureResponse, { session: { pendingContext: { kind: 'selection', tabId: 7, text: 'Seçilen paragraf', at: Date.now() } } })
await settle(() => !p.get('attachment').hidden)
assert.ok(p.get('attachment-label').textContent.includes('Seçim: Kaynak sayfa'))
assert.ok(p.get('notice').textContent.includes('konuşması seçin'))
await p.select('one')
assert.equal(p.get('attachment').hidden, false, 'the first choice keeps the pending attachment')
p.get('message').value = 'Bunu özetle'
p.get('composer').dispatchEvent(new p.dom.window.Event('submit', { cancelable: true }))
await settle(() => p.calls.some(call => call.action === 'message.send'))
const sent = p.calls.find(call => call.action === 'message.send').params.text
assert.ok(sent.includes('Seçilen paragraf'))
assert.ok(sent.includes('https://example.test/yazi'))
assert.ok(!sent.includes('token=gizli'))
})
@@ -27,6 +27,45 @@ Run: `python bridge.py --home <data dir> --hermes-home <agent home>`. The IDE wr
Tests: `python -m unittest discover` in this directory. Tests: `python -m unittest discover` in this directory.
## Version 19: decision stages (14 October 2026)
The decision model (Jev or another System One compatible service such as Celeris) can now be asked at
several stages. Every new stage is switched on in Settings → Kademeli Geçiş and starts disabled, so an
upgrade changes no behaviour by itself. The decision model never writes text; an unreachable or invalid
decision always keeps the current path.
- `jev-settings.json` schema 2 adds `watchModels`, `prefilter`, `autoRoute`, `finishCheck`,
`verifierCheck` and `policy`. A schema 1 file is filled from `jev-settings-defaults.json`.
The default chain is `Katman-1 → Katman-2 → Katman-3`; `Katman-3`, `Chat-Model`,
`bekci-sesli-bekci` and `Sesli-Hizli-Zeki` always pass.
- **Correction channel** (`watchModels`): the Bekçi watch sideband is no longer bound to the hard-coded
name `Sesli-Hizli-Zeki`; any exact target in the list owns it, but only while it always passes the check
(a checked answer may be replaced by a layer, and a receipt must not belong to an answer the user never saw).
A single SSE frame above 256 KB no longer breaks the stream; the rest of the stream passes unfiltered.
- **Refusal pre-check** (`prefilter`): at the start of a turn, "would a mainstream model refuse or water
this down?". If yes, the step starts at `prefilter.target` instead of collecting a refusal first.
- **Automatic model** (`autoRoute`): a virtual `ide/<model>` entry ("Otomatik") in the Codex list. A new turn
(a user message after the previous final answer) gets one decision call (hard? research?) and goes to
the configured combo. Tool steps, messages typed while the turn runs, a lone "." and compaction stay on
the same combo, because switching providers mid-turn breaks encrypted history. When the stage is off or
fails, the virtual name never reaches the gateway: the last choice or `targets.zor` is used. The user's
explicit model choice is never changed by this stage.
- Both turn-start questions go in one call; the wait, including the local queue, is bounded by
`timeoutSeconds` (default 3 s).
- **Layer window filter**: a layer whose trusted context window (pencere.bekci_calisir) cannot hold the
request is skipped. The last link of the chain is never skipped.
- **Early finish check** (`finishCheck`): asked in the same call as the refusal check on a final answer.
If the answer stopped early or claimed success without evidence, it goes back to the same model with
`finishCheck.note`, at most once per turn. If the retry breaks, comes back empty or fails the check, the
first (already approved) answer is delivered.
- **Rules from Dify** (`policy.source = dify`, `karar_politikasi.py`): the stage questions, thresholds and
combo choices may come from a Dify workflow's `kurallar` output. Fetched in the background every
`refreshMinutes`, validated against the local settings, last good copy kept in `jev-policy.json`.
The transition chain and the always-pass list stay local. A turn never waits for Dify, and a redirect
from the Dify endpoint is refused so the app key never travels to another host.
- **Workflow reviewer check** (`verifierCheck`, `karar_sor.py`): the Codex dynamic-workflow acceptance
step asks whether a reviewer approved without concrete evidence; if so acceptance is blocked.
## Version 11: isolated routes and cross-provider preparation ## Version 11: isolated routes and cross-provider preparation
- Pure subscription chats and native voice retain their ChatGPT route. Added - Pure subscription chats and native voice retain their ChatGPT route. Added
+52 -1
View File
@@ -32,9 +32,10 @@ import time
import urllib.error import urllib.error
import urllib.request import urllib.request
import gecis_context import gecis_context
import terminal_stream
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
SURUM = "17" # AITURK IDE'nin bekledigi kopru surumu (codex-link-config.ts CODEX_BRIDGE_VERSION) SURUM = "21" # AITURK IDE'nin bekledigi kopru surumu (codex-link-config.ts CODEX_BRIDGE_VERSION)
try: try:
with open(os.path.abspath(__file__), "rb") as _f: with open(os.path.abspath(__file__), "rb") as _f:
KOD = hashlib.sha256(_f.read()).hexdigest()[:8] KOD = hashlib.sha256(_f.read()).hexdigest()[:8]
@@ -348,6 +349,36 @@ class H(http.server.BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(hata) self.wfile.write(hata)
return True return True
observer = None
first_chunks = []
failure_key = getattr(self, "_failure_key", None)
if (hedef == KOPRU and failure_key is not None and durum == 200
and "text/event-stream" in yanit.headers.get("Content-Type", "")
and yanit.headers.get("Content-Encoding", "identity") == "identity"):
observer = terminal_stream.Observer()
if not started:
# Inspect only until the first SSE frame/comment. Keep normal
# streaming and heartbeats; an error-only response can still
# return a truthful HTTP failure before headers are committed.
try:
while True:
first = yanit.read1(4096)
if not first:
break
first_chunks.append(first)
observer.feed(first)
if observer.error and not observer.payload_seen:
yanit.close()
self._json(400, terminal_stream.error_body(observer.error))
return True
if observer.comment_seen or observer.payload_seen or sum(map(len, first_chunks)) >= observer.LIMIT:
break
except (OSError, http.client.HTTPException):
# A transport interruption without an explicit provider
# failure keeps the client's existing recovery behavior.
yanit.close()
self.close_connection = True
return True
if not started: if not started:
self.send_response(durum) self.send_response(durum)
for k, v in yanit.headers.items(): for k, v in yanit.headers.items():
@@ -359,6 +390,10 @@ class H(http.server.BaseHTTPRequestHandler):
event_tail = b"" event_tail = b""
completed = False completed = False
try: try:
for first in first_chunks:
toplam += len(first)
self.wfile.write(b"%x\r\n" % len(first) + first + b"\r\n")
self.wfile.flush()
while True: while True:
try: try:
parca = yanit.read1(4096) # gelen kadarini hemen verir; bekletmez parca = yanit.read1(4096) # gelen kadarini hemen verir; bekletmez
@@ -370,6 +405,8 @@ class H(http.server.BaseHTTPRequestHandler):
if not parca: if not parca:
break break
toplam += len(parca) toplam += len(parca)
if observer is not None:
observer.feed(parca)
if getattr(self, "_native_return_scope", None): if getattr(self, "_native_return_scope", None):
event_tail += parca event_tail += parca
while b"\n" in event_tail: while b"\n" in event_tail:
@@ -382,6 +419,9 @@ class H(http.server.BaseHTTPRequestHandler):
pass pass
self.wfile.write(b"%x\r\n" % len(parca) + parca + b"\r\n") self.wfile.write(b"%x\r\n" % len(parca) + parca + b"\r\n")
self.wfile.flush() self.wfile.flush()
if observer is not None and observer.error and not observer.completed:
self.server.failure_replays.remember(failure_key, observer.error)
log(f"{tag} terminal worker error; identical automatic replay will return HTTP 400")
self.wfile.write(b"0\r\n\r\n") self.wfile.write(b"0\r\n\r\n")
self.wfile.flush() self.wfile.flush()
except (ConnectionAbortedError, BrokenPipeError, ConnectionResetError): except (ConnectionAbortedError, BrokenPipeError, ConnectionResetError):
@@ -453,6 +493,7 @@ class H(http.server.BaseHTTPRequestHandler):
def _isle(self): def _isle(self):
self._native_return_scope = None self._native_return_scope = None
self._failure_key = None
with kilit: with kilit:
sira[0] += 1 sira[0] += 1
n = sira[0] n = sira[0]
@@ -479,6 +520,12 @@ class H(http.server.BaseHTTPRequestHandler):
req = {} req = {}
except Exception: except Exception:
req = {} req = {}
if external:
self._failure_key = terminal_stream.request_key(req or {"raw": govde.hex()}, self.headers)
previous_failure = self.server.failure_replays.take(self._failure_key)
if previous_failure:
log(f"[{n} {model}] terminal worker error replay stopped before upstream request")
return self._json(400, terminal_stream.error_body(previous_failure))
thread = str(req.get("prompt_cache_key") or "") thread = str(req.get("prompt_cache_key") or "")
scope = hashlib.sha256((str(self.headers.get("chatgpt-account-id") or "") + "|" + thread).encode()).hexdigest() scope = hashlib.sha256((str(self.headers.get("chatgpt-account-id") or "") + "|" + thread).encode()).hexdigest()
source = TRANSFERS.get(scope) if thread else None source = TRANSFERS.get(scope) if thread else None
@@ -541,6 +588,10 @@ class H(http.server.BaseHTTPRequestHandler):
class Sunucu(http.server.ThreadingHTTPServer): class Sunucu(http.server.ThreadingHTTPServer):
def __init__(self, *args, **kwargs):
self.failure_replays = terminal_stream.FailureReplays()
super().__init__(*args, **kwargs)
daemon_threads = True daemon_threads = True
# Shipped Python 3.11 defaults to only five pending connections. Model # Shipped Python 3.11 defaults to only five pending connections. Model
# discovery, voice and simultaneous chats can arrive in the same burst. # discovery, voice and simultaneous chats can arrive in the same burst.
@@ -192,7 +192,41 @@ def sec(data, tanimli=(), kapsam="ide", turler=None):
return [{"id": s["id"], "ad": s["ad"], "kind": s["kind"], "yol": s["yol"]} for s in secilen[:EN_COK_MODEL]] return [{"id": s["id"], "ad": s["ad"], "kind": s["kind"], "yol": s["yol"]} for s in secilen[:EN_COK_MODEL]]
def sirala_codex(modeller, ekler, ozgun_kimlikler=()): # ---- Codex secicisinde gorunen liste (15 Ekim 2026) ----
# Codex masaustu uygulamasinin model secicisi listeyi tek sayfada, en cok 100 modelle ister (uygulamanin kendi kodunda
# sabit; sonraki sayfayi istemez). Kullanici o 100'e hangi modellerin girecegini secer (bridge.json "codex_liste"):
# "one_al" ozgun modellerin hemen arkasina verdigi sirayla alinir, "gizli" cikarilir; resim/ses/video modelleri ve
# ana modeli listede olan dusuk-orta-yuksek kopyalari istege bagli gizlenir (varsayilan: gizli).
CODEX_SECICI_SINIRI = 100
_MEDYA = re.compile(r"(?:^|[-_/. ])(?:tts|image|imagen|veo|video|lyria|embedding|transcribe|whisper|flux|kokoro|orpheus|"
r"chatterbox|seedance|seedream|nano-banana|aqa|robotics|computer-use|realtime|music|audio|speech)"
r"(?=$|[-_/. ])", re.I)
_EFOR = re.compile(r"-(?:none|minimal|low|medium|high|xhigh|max)$", re.I)
def liste_ayari(ham):
"""bridge.json "codex_liste" -> {medya_gizle, efor_gizle, one_al, gizli}; eksik ya da bozuk alan varsayilandir."""
a = ham if isinstance(ham, dict) else {}
def liste(anahtar):
deger = a.get(anahtar)
return [str(s) for s in deger if isinstance(s, str) and s.strip()][:2000] if isinstance(deger, list) else []
return {"medya_gizle": a.get("medya_gizle") is not False, "efor_gizle": a.get("efor_gizle") is not False,
"one_al": list(dict.fromkeys(liste("one_al"))), "gizli": liste("gizli")}
def medya_mi(slug, ad=""):
"""Codex sohbetinde calismayan resim, ses, video, gomme modeli mi (kimlikten ya da adindan)."""
return bool(_MEDYA.search(slug or "") or _MEDYA.search(ad or ""))
def efor_kopyasi_mi(slug, kimlikler):
""""...-high" gibi bir kopya ve ana modeli de listede mi; Codex'in kendi dusunme seviyesi secimi ana modelde var."""
taban = _EFOR.sub("", slug or "")
return taban != slug and taban in kimlikler
def sirala_codex(modeller, ekler, ozgun_kimlikler=(), ayar=None):
"""Ozgun abonelik modelleri oldugu gibi kalir: sirasi, priority degeri ve adi degismez, """Ozgun abonelik modelleri oldugu gibi kalir: sirasi, priority degeri ve adi degismez,
listenin basinda durur. Ek modeller onlarin ARKASINA eklenir: combo A-Z, sonra listenin basinda durur. Ek modeller onlarin ARKASINA eklenir: combo A-Z, sonra
saglayici A-Z / model A-Z. Ozgun bir modelle ayni adi tasiyan ek model " (AITURK)" saglayici A-Z / model A-Z. Ozgun bir modelle ayni adi tasiyan ek model " (AITURK)"
@@ -227,6 +261,26 @@ def sirala_codex(modeller, ekler, ozgun_kimlikler=()):
return (1, saglayici.casefold(), model.casefold(), slug) return (1, saglayici.casefold(), model.casefold(), slug)
ek_modeller.sort(key=anahtar) ek_modeller.sort(key=anahtar)
adaylar = None
if ayar is not None:
# Kullanicinin secimi: once kimin gizlenecegi, sonra one alinanlar (verdigi sirayla); gerisi yukaridaki sirada.
ayar = liste_ayari(ayar)
kimlikler = {m.get("slug", "") for m in ek_modeller}
one_sira = {s: i for i, s in enumerate(s for s in ayar["one_al"] if s in kimlikler)}
gizli = set(ayar["gizli"])
adaylar, kalan = [], []
for m in ek_modeller:
slug = m.get("slug", "")
medya, efor = medya_mi(slug, m.get("display_name")), efor_kopyasi_mi(slug, kimlikler)
one = slug in one_sira
neden = ("gizli" if slug in gizli else "medya" if medya and ayar["medya_gizle"] and not one
else "efor" if efor and ayar["efor_gizle"] and not one else "")
adaylar.append({"slug": slug, "ad": m.get("display_name") or slug, "kind": bilgiler.get(slug, {}).get("kind") or "model",
"medya": medya, "efor": efor, "neden": neden})
if not neden:
kalan.append(m)
kalan.sort(key=lambda m: (0, one_sira[m.get("slug", "")]) if m.get("slug", "") in one_sira else (1, 0))
ek_modeller = kalan
taban = max([m.get("priority") or 0 for m in ozgunler], default=-1) + 1 taban = max([m.get("priority") or 0 for m in ozgunler], default=-1) + 1
for n, model in enumerate(ek_modeller): for n, model in enumerate(ek_modeller):
model["priority"] = taban + n model["priority"] = taban + n
@@ -234,6 +288,14 @@ def sirala_codex(modeller, ekler, ozgun_kimlikler=()):
if yalin(ad) in ozgun_adlar: if yalin(ad) in ozgun_adlar:
model["display_name"] = f"{ad}{AITURK_EKI}" model["display_name"] = f"{ad}{AITURK_EKI}"
modeller[:] = (ozgunler + ek_modeller)[:EN_COK_MODEL] modeller[:] = (ozgunler + ek_modeller)[:EN_COK_MODEL]
if adaylar is None:
return None
# Secicide gorunecek yer: ozgun modeller once sayilir; Codex ilk 100 girdiyi gosterir.
yer = {m.get("slug", ""): i for i, m in enumerate(modeller)}
for a in adaylar:
a["sira"] = yer.get(a["slug"]) if not a["neden"] else None
a["menude"] = a["sira"] is not None and a["sira"] < CODEX_SECICI_SINIRI
return {"sinir": CODEX_SECICI_SINIRI, "ozgun_sayisi": len(ozgunler), "adaylar": adaylar}
# ---- Secicide model adinin altindaki satir ---- # ---- Secicide model adinin altindaki satir ----
+43 -8
View File
@@ -11,7 +11,9 @@ isteklere, istek gecerken ekler. Istemci `originator` basligindan ayirt edilir (
Hat, Codex Baglama'nin parcasidir: ayri bir dugmesi yoktur, bu bilgisayarda isi gonderebilen bir AITURK komutu Hat, Codex Baglama'nin parcasidir: ayri bir dugmesi yoktur, bu bilgisayarda isi gonderebilen bir AITURK komutu
(`aiturk is`) bulundugu surece aciktir. Komut yoksa talimat eklenmez: gonderme yolu olmayan bir "AITURK'e gonder" (`aiturk is`) bulundugu surece aciktir. Komut yoksa talimat eklenmez: gonderme yolu olmayan bir "AITURK'e gonder"
talimati modeli yaniltir. Ayar (bridge.json, istege bagli): "aiturk_hat": {"enabled": false} kapatir; talimati modeli yaniltir. Ayar (bridge.json, istege bagli): "aiturk_hat": {"enabled": false} kapatir;
{"komut": "..."} komutu elle verir; {"talimat": "..."} metni degistirir. {"komut": "..."} komutu elle verir; {"talimat": "..."} metni degistirir ({komut}, {zamanla} yer tutuculari);
{"secim": "onerilen"|"buyuk"|"kapali"|"ozel"} AITURK IDE ayarlar ekranindan yazilir. Kopru ayari her istekte
dosyadan tazeler (kopru.py hat_ayari), yeniden baslatma gerekmez.
""" """
import os import os
import shutil import shutil
@@ -19,7 +21,7 @@ import subprocess
MASAUSTU = {"codex desktop"} MASAUSTU = {"codex desktop"}
ISARET = "[AITURK iş hattı]" ISARET = "[AITURK iş hattı]"
VARSAYILAN = ( ESKI = (
ISARET + " Bu Codex, AITURK'e bağlı. Şu kurala göre çalış:\n" ISARET + " Bu Codex, AITURK'e bağlı. Şu kurala göre çalış:\n"
"1. Basit soruyu, bilgi isteğini ya da tek küçük düzeltmeyi kendin yap.\n" "1. Basit soruyu, bilgi isteğini ya da tek küçük düzeltmeyi kendin yap.\n"
"2. Kullanıcı \"sen yap\" derse işi kendin yap.\n" "2. Kullanıcı \"sen yap\" derse işi kendin yap.\n"
@@ -28,6 +30,30 @@ VARSAYILAN = (
"bitmesini bekle ve sonucu kullanıcıya kısaca aktar:\n{komut}\n" "bitmesini bekle ve sonucu kullanıcıya kısaca aktar:\n{komut}\n"
"Komut \"işi sen yap\" derse ya da hata verirse işi kendin yap. Emin değilsen işi küçük say ve kendin yap." "Komut \"işi sen yap\" derse ya da hata verirse işi kendin yap. Emin değilsen işi küçük say ve kendin yap."
) )
# Kullanici karari, 15 Ekim 2026: tekrarli/zamanli isler AITURK IDE botuna, kodlama Codex'te kalir.
VARSAYILAN = (
ISARET + " Bu Codex, AITURK'e bağlı. İş dağıtımı kuralı:\n"
"1. Tekrarlı, zamanlanmış ya da sürekli açık kalacak işleri (her gün şu saatte, saat başı, haftada bir, "
"'7 gün her gün baksın' gibi) AITURK IDE'deki bota ver. İşin tam metnini UTF-8 bir dosyaya yaz ve şu komutla "
"gönder, sonucu kullanıcıya kısaca aktar:\n{komut}\n"
"2. Kodlama ve diğer işleri AITURK'e verme. Ana model işi kendisi yapmaz: kolay ya da tek parça işi tek bir alt "
"ajana, zor ya da çok parçalı işi alt ajan ekibine veya dinamik workflow'a ver. Ana model kullanıcıyla konuşur, "
"işi dağıtır, sonucu doğrulayıp aktarır.\n"
"3. Kullanıcı 'Codex' derse işi devretme. Komut hata verirse işi alt ajanla yap."
)
# Ayarlar ekranindaki hazir secenekler (bridge.json "aiturk_hat": {"secim": ...}).
SECIMLER = {"onerilen": VARSAYILAN, "buyuk": ESKI}
def zamanla_satiri(ortam=None):
"""AITURK IDE botuna zamanlanmis is veren hazir satir; IDE ajan klasoru yoksa ''."""
ortam = os.environ if ortam is None else ortam
ev = str(ortam.get("HERMES_HOME") or "").strip()
if not ev:
return ""
exe = os.path.join(ev, "bin", "hermes.exe")
return (f"$env:HERMES_HOME='{ev}'; & '{exe}' cron create --name '<kısa ad>' --workdir '<işin klasörü>' "
"'<zamanlama: 30m, every 2h ya da 0 9 * * *>' '<işin tam ve kendi başına anlaşılır talimatı>'")
def masaustu_mu(originator): def masaustu_mu(originator):
@@ -76,16 +102,25 @@ def komut_satiri(yol, sohbet=""):
return f'"{yol}" is' + (f" --sohbet {sohbet}" if sohbet else "") + ' --dosya "<iş metnini yazdığın dosya>"' return f'"{yol}" is' + (f" --sohbet {sohbet}" if sohbet else "") + ' --dosya "<iş metnini yazdığın dosya>"'
def talimat(ayar, bulunan=None, sohbet=""): def talimat(ayar, bulunan=None, sohbet="", zamanla=None):
"""Eklenecek metin; hat kapaliysa ya da gonderme komutu yoksa None. """Eklenecek metin; hat kapaliysa ya da metnin istedigi gonderme yolu yoksa None.
bulunan: bu bilgisayarda bulunan AITURK komutunun yolu (komut_bul); ayardaki "komut" onun onune gecer.""" bulunan: bu bilgisayarda bulunan AITURK komutunun yolu (komut_bul); ayardaki "komut" onun onune gecer.
zamanla: AITURK IDE botuna zamanlanmis is veren satir (zamanla_satiri); None ise ortamdan bulunur.
Secim (ayarlar ekrani): "onerilen" (varsayilan), "buyuk" (eski davranis), "kapali", "ozel" ("talimat" metni).
Secimsiz eski ayarda "talimat" doluysa ozel sayilir."""
ayar = ayar if isinstance(ayar, dict) else {} ayar = ayar if isinstance(ayar, dict) else {}
if ayar.get("enabled") is False: secim = str(ayar.get("secim") or "").strip()
if ayar.get("enabled") is False or secim == "kapali":
return None return None
if secim in SECIMLER:
metin = SECIMLER[secim]
else:
metin = str(ayar.get("talimat") or "").strip() or VARSAYILAN
komut = str(ayar.get("komut") or "").strip() or (komut_satiri(bulunan, sohbet) if bulunan else "") komut = str(ayar.get("komut") or "").strip() or (komut_satiri(bulunan, sohbet) if bulunan else "")
if not komut: zamanla = zamanla_satiri() if zamanla is None else zamanla
if ("{komut}" in metin and not komut) or ("{zamanla}" in metin and not zamanla):
return None return None
return str(ayar.get("talimat") or VARSAYILAN).replace("{komut}", komut) return metin.replace("{komut}", komut).replace("{zamanla}", zamanla)
def ekle(req, metin): def ekle(req, metin):
@@ -5,10 +5,9 @@
"fallbackModel": "Katman-1", "fallbackModel": "Katman-1",
"rejectionRules": [ "rejectionRules": [
{ "from": "Katman-1", "to": "Katman-2" }, { "from": "Katman-1", "to": "Katman-2" },
{ "from": "Katman-2", "to": "Katman-3" }, { "from": "Katman-2", "to": "Katman-3" }
{ "from": "Katman-3", "to": "Katman-4" }
], ],
"exemptModels": ["Sesli-Hizli-Zeki", "Katman-4"], "exemptModels": ["Chat-Model", "bekci-sesli-bekci", "Sesli-Hizli-Zeki", "Katman-3"],
"thresholds": { "ret": 0.5, "sart": 0.7 }, "thresholds": { "ret": 0.5, "sart": 0.7 },
"questions": { "questions": {
"ret": "The assistant did NOT do what the user asked or did NOT answer the user's question: it refused, declined, said it cannot or will not, avoided giving the requested judgment, or redirected to a different topic.", "ret": "The assistant did NOT do what the user asked or did NOT answer the user's question: it refused, declined, said it cannot or will not, avoided giving the requested judgment, or redirected to a different topic.",
@@ -18,5 +17,40 @@
"criteria": { "ret": { "true": "", "false": "" }, "sart": { "true": "", "false": "" } }, "criteria": { "ret": { "true": "", "false": "" }, "sart": { "true": "", "false": "" } },
"maxConcurrent": 0, "maxConcurrent": 0,
"queueWaitSeconds": 3600, "queueWaitSeconds": 3600,
"failureAction": "pass" "failureAction": "pass",
"watchModels": ["Sesli-Hizli-Zeki", "Chat-Model", "bekci-sesli-bekci"],
"prefilter": {
"enabled": false,
"question": "A mainstream AI assistant such as ChatGPT, Claude or Gemini would refuse this request, water it down, or answer it only with added warnings or conditions.",
"threshold": 0.6,
"target": "Katman-2",
"timeoutSeconds": 3
},
"autoRoute": {
"enabled": false,
"model": "Otomatik",
"timeoutSeconds": 3,
"questions": {
"zor": "The user's request is hard: doing it well needs multi-step work, expert or technical knowledge, careful analysis, planning, calculation or writing and changing code. Greetings, small talk, simple facts and short simple commands are NOT hard.",
"arastirma": "The user's request is mainly research: finding, reading and comparing many sources or files and reporting what they say, rather than changing anything."
},
"thresholds": { "zor": 0.5, "arastirma": 0.6 },
"targets": { "basit": "Workflow-Kesif", "zor": "Ajan", "arastirma": "Arastirma" }
},
"finishCheck": {
"enabled": false,
"question": "The assistant stopped before the user's task was finished: it said it is done or handed back without doing all of what was asked, or it claimed success (tests pass, file changed, deployed) without showing the actual evidence.",
"threshold": 0.6,
"note": "Önceki cevabın iş bitmeden bitti dedi ya da kanıt göstermedi. İşi gerçekten bitir; gerekiyorsa araçları kullan ve gerçek kanıtı göster."
},
"verifierCheck": {
"enabled": false,
"question": "The reviewer approved the work without concrete evidence: it does not cite the actual diff, the real command output or specific lines, so the approval may be a guess.",
"threshold": 0.6
},
"policy": {
"source": "local",
"endpoint": "https://dify.turkservis.online/v1",
"refreshMinutes": 5
}
} }
+108 -19
View File
@@ -4,11 +4,16 @@ Kullanici karari (11 Ekim 2026): "Jev kati olsun. Af yok istisna yok. Herhangi b
gecirmezse 1.Katmana yonlendirecek. 1.Katman combosu gecmezse 2.Katmana, 2.Katman gecmezse 3.Katmana, 3.Katman gecirmezse 1.Katmana yonlendirecek. 1.Katman combosu gecmezse 2.Katmana, 2.Katman gecmezse 3.Katmana, 3.Katman
gecmezse 4.Katmana. 4.Katman combosu gelirse Jev bunu sorgulamadan gecirecek." gecmezse 4.Katmana. 4.Katman combosu gelirse Jev bunu sorgulamadan gecirecek."
12 Ekim 2026 istisnasi: çözülmüş hedefi tam Sesli-Hizli-Zeki olan cevap Jev'e sorulmaz. 12 Ekim 2026 istisnasi: çözülmüş hedefi muaf listesinde olan cevap Jev'e sorulmaz (ayar: exemptModels).
Taşıma/boş yanıt kurtarması ve yedek katmanların denetimi korunur. Taşıma/boş yanıt kurtarması ve yedek katmanların denetimi korunur.
Diğer hedeflerde denetim secili modelin katman olup olmamasina bakmaz. Diğer hedeflerde denetim secili modelin katman olup olmamasina bakmaz.
Gecmezse adimi sirayla Katman-1 -> Katman-2 -> Katman-3 -> Katman-4 yapar; secili model zaten bir katmansa Gecmezse adimi ayardaki zincir yapar (14 Ekim 2026: Katman-1 -> Katman-2 -> Katman-3, Katman-3 muaf); secili model
yalniz ondan sonrakiler denenir. Katmanlar OmniRoute combolaridir (KATMAN); kopru onlari yalniz adiyla cagirir. zaten bir katmansa yalniz ondan sonrakiler denenir. Katmanlar OmniRoute combolaridir; kopru onlari yalniz adiyla cagirir.
14 Ekim 2026 karar asamalari (hepsi ayardan acilir, varsayilan kapali): tur basinda "Otomatik" modelin combo secimi
(autoRoute), normal modelin reddedecegi tahmin edilen isi dogrudan katmana verme (prefilter), isi bitirmeden "bitti"
diyen son cevabi ayni modele bir kez geri verme (finishCheck). Karar modeli metin yazmaz; hata ya da zaman asiminda
bugunku yol izlenir.
Jev (TypeSafe System One) metin uretmez, sorulan evet/hayir sorularina olasilik verir. Kural tektir ve istisnasi Jev (TypeSafe System One) metin uretmez, sorulan evet/hayir sorularina olasilik verir. Kural tektir ve istisnasi
yoktur: cevap istegi yapmiyorsa (ret) ya da sormadan kendi sartlariyla degistiriyorsa (sart) adim gecmez. yoktur: cevap istegi yapmiyorsa (ret) ya da sormadan kendi sartlariyla degistiriyorsa (sart) adim gecmez.
@@ -22,7 +27,7 @@ import jev_settings
ADRES = "https://api.typesafe.ai/v1/systemone" ADRES = "https://api.typesafe.ai/v1/systemone"
MODEL = "jev-1.13.0" MODEL = "jev-1.13.0"
KATMAN = ("Katman-1", "Katman-2", "Katman-3", "Katman-4") KATMAN = ("Katman-1", "Katman-2", "Katman-3") # varsayilan zincir; asil zincir ayardan okunur
ESIK = {"ret": 0.5, "sart": 0.7} ESIK = {"ret": 0.5, "sart": 0.7}
SORU = { SORU = {
"ret": ("The assistant did NOT do what the user asked or did NOT answer the user's question: it refused, declined, " "ret": ("The assistant did NOT do what the user asked or did NOT answer the user's question: it refused, declined, "
@@ -32,6 +37,17 @@ SORU = {
} }
_ANAHTAR = {"okundu": False, "deger": ""} _ANAHTAR = {"okundu": False, "deger": ""}
def izleme_mi(hedef):
"""Bekci izleme kanali (cevaptan sonra duzeltme) bu hedefin mi? Ayar: watchModels, tam ad eslesmesi.
Jev acikken yalniz muaf hedefte: denetlenen cevap baska katmana gecebilir, makbuz kullanicinin gormedigi
cevaba baglanmasin."""
try:
s = jev_settings.read()[0]
return hedef in s['watchModels'] and (not s['enabled'] or hedef in s['exemptModels'])
except Exception:
return False
def muaf_mi(hedef): def muaf_mi(hedef):
"""Settings' always-pass list matches exact resolved model IDs. """Settings' always-pass list matches exact resolved model IDs.
@@ -109,16 +125,28 @@ def _metin(oge):
def son_istek(req): def son_istek(req):
"""Kullanicinin bu turdaki istegi: son kullanici mesaji. Sesli aktarimda yalniz <input> icindeki soz; """Kullanicinin bu turdaki istegi: son kullanici mesaji. Sesli aktarimda yalniz <input> icindeki soz;
baska sarmalli sistem mesajlari (kanca baglami) atlanir.""" baska sarmalli sistem mesajlari (kanca baglami) atlanir."""
for oge in reversed(req.get("input") or []): return son_istek_sirasi(req)[0]
if not (isinstance(oge, dict) and oge.get("type", "message") == "message" and oge.get("role") == "user"):
continue
metin = _metin(oge).strip() def istek_metni(oge):
m = re.search(r"<realtime_delegation>.*?<input>(.*?)</input>", metin, re.S) """Bir kullanici mesajindaki istek: sesli aktarimda <input> icindeki soz; sarmalli sistem mesaji ise ""."""
if m: if not (isinstance(oge, dict) and oge.get("type", "message") == "message" and oge.get("role") == "user"):
return m.group(1).strip() return ""
if metin and not metin.startswith("<"): metin = _metin(oge).strip()
return metin m = re.search(r"<realtime_delegation>.*?<input>(.*?)</input>", metin, re.S)
return "" if m:
return m.group(1).strip()
return metin if metin and not metin.startswith("<") else ""
def son_istek_sirasi(req):
"""(istek, girdideki sirasi). Turun arac adimlarinda da ayni sira doner; yoksa ("", -1)."""
girdi = req.get("input") or []
for sira in range(len(girdi) - 1, -1, -1):
metin = istek_metni(girdi[sira])
if metin:
return metin, sira
return "", -1
def hazirlik_mi(govde): def hazirlik_mi(govde):
@@ -166,24 +194,83 @@ def komutlar(govde):
return "\n".join(satirlar) return "\n".join(satirlar)
def _gonder(govde, key, settings=None): def _gonder(govde, key, settings=None, timeout=None):
settings = settings or jev_settings.read()[0] settings = settings or jev_settings.read()[0]
istek = urllib.request.Request(settings['endpoint'], data=json.dumps(govde).encode("utf-8"), method="POST", istek = urllib.request.Request(settings['endpoint'], data=json.dumps(govde).encode("utf-8"), method="POST",
headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"}) headers={"Authorization": "Bearer " + key, "Content-Type": "application/json"})
with urllib.request.urlopen(istek, timeout=settings['timeoutSeconds']) as yanit: with urllib.request.urlopen(istek, timeout=timeout or settings['timeoutSeconds']) as yanit:
return json.load(yanit) return json.load(yanit)
def sorular(durum, soru, key, timeout, gonder=_gonder, settings=None):
"""Karar modeline bir cagrida birden cok evet/hayir sorusu. Donus {ad: 0-1 olasilik} | None (sorulamadi).
durum: {"user_request": ..., ["assistant_reply": ...]}; soru: {ad: talimat}."""
settings = settings or jev_settings.read()[0]
if not key or not soru or not durum.get("user_request"):
return None
govde = {"model": settings['model'], "state": durum,
"questions": {k: {"type": "noul", "instructions": v} for k, v in soru.items()}}
if not jev_settings.acquire(settings, wait=timeout):
return None
try:
response = gonder(govde, key, settings, timeout) if gonder is _gonder else gonder(govde, key)
yanitlar = (response or {}).get("answers") or {}
result = {k: float(yanitlar[k]["noul"]) for k in soru}
return result if all(0 <= v <= 1 for v in result.values()) else None
except Exception:
return None
finally:
jev_settings.release()
def on_karar(istek, oto, ret, key, gonder=_gonder):
"""Tur basi karari: istenen sorular tek cagrida. oto: Otomatik modelin combo secimi; ret: normal model reddeder mi.
Donus: {"hedef": Otomatik icin secilen ad ya da None, "ret": bool ya da None, "puan": {...} ya da None}."""
settings = jev_settings.read()[0]
a, p = settings['autoRoute'], settings['prefilter']
soru = {}
if oto:
soru.update(a['questions'])
if ret and p['enabled']:
soru["ret"] = p['question']
if not soru:
return {"hedef": None, "ret": None, "puan": None}
sure = max(a['timeoutSeconds'] if oto else 0, p['timeoutSeconds'] if ret else 0) or 3
puan = sorular({"user_request": istek}, soru, key, sure, gonder=gonder, settings=settings)
hedef = None
if oto:
if puan is None:
hedef = a['targets']['zor'] # karar alinamazsa guclu yol
elif puan["arastirma"] >= a['thresholds']['arastirma']:
hedef = a['targets']['arastirma']
elif puan["zor"] >= a['thresholds']['zor']:
hedef = a['targets']['zor']
else:
hedef = a['targets']['basit']
tahmin = None if puan is None or "ret" not in puan else puan["ret"] >= p['threshold']
return {"hedef": hedef, "ret": tahmin, "puan": puan}
def bitmedi_mi(puan):
"""finishCheck: son cevap isi bitirmeden 'bitti' mi dedi? Soru sorulmadiysa False."""
if not puan or "bitmedi" not in puan:
return False
return puan["bitmedi"] >= jev_settings.read()[0]['finishCheck']['threshold']
class Puan(dict): class Puan(dict):
"""Carry the thresholds used for this evaluation through a settings change.""" """Carry the thresholds used for this evaluation through a settings change."""
pass pass
def sor(istek, cevap, key, gonder=_gonder): def sor(istek, cevap, key, gonder=_gonder, bitis=False):
"""Jev'e sorar. Donus: {"ret", "sart"} olasiliklari | None (sorulamadi).""" """Jev'e sorar. Donus: {"ret", "sart"[, "bitmedi"]} olasiliklari | None (sorulamadi).
bitis=True ve finishCheck acikken ayni cagriya "is bitmeden bitti mi dedi" sorusu da eklenir."""
settings = jev_settings.read()[0] settings = jev_settings.read()[0]
govde = {"model": settings['model'], "state": {"user_request": istek, "assistant_reply": cevap}, govde = {"model": settings['model'], "state": {"user_request": istek, "assistant_reply": cevap},
"questions": {k: {"type": "noul", "instructions": v} for k, v in settings['questions'].items()}} "questions": {k: {"type": "noul", "instructions": v} for k, v in settings['questions'].items()}}
if bitis and settings['finishCheck']['enabled']:
govde['questions']['bitmedi'] = {"type": "noul", "instructions": settings['finishCheck']['question']}
for name, criteria in settings.get('criteria', {}).items(): for name, criteria in settings.get('criteria', {}).items():
values = {k: v for k, v in criteria.items() if v.strip()} values = {k: v for k, v in criteria.items() if v.strip()}
if values: if values:
@@ -193,7 +280,9 @@ def sor(istek, cevap, key, gonder=_gonder):
try: try:
response = gonder(govde, key, settings) if gonder is _gonder else gonder(govde, key) response = gonder(govde, key, settings) if gonder is _gonder else gonder(govde, key)
yanitlar = (response or {}).get("answers") or {} yanitlar = (response or {}).get("answers") or {}
result = Puan({k: float(yanitlar[k]["noul"]) for k in SORU}) result = Puan({k: float(yanitlar[k]["noul"]) for k in settings['questions']})
if 'bitmedi' in govde['questions'] and isinstance(yanitlar.get('bitmedi'), dict):
result['bitmedi'] = float(yanitlar['bitmedi']['noul']) # ek soru: cevapsizsa ana karar yine gecerli
if any(not 0 <= v <= 1 for v in result.values()): if any(not 0 <= v <= 1 for v in result.values()):
return None return None
result.thresholds = settings['thresholds'] result.thresholds = settings['thresholds']
+194 -21
View File
@@ -1,4 +1,11 @@
"""Per-user settings, re-read for new evaluations without restarting active chats.""" """Per-user settings, re-read for new evaluations without restarting active chats.
Schema 2 (14 Oct 2026) adds the decision stages: watchModels, prefilter, autoRoute, finishCheck,
verifierCheck and policy. A schema 1 file stays valid: every missing section is filled from the
defaults, and every new stage starts disabled, so an upgrade changes no behaviour by itself.
With policy.source "dify" the stage settings may be overridden by the last good rules fetched
from Dify (jev-policy.json); invalid or missing rules leave the local settings in force.
"""
import base64 import base64
import copy import copy
import ctypes import ctypes
@@ -8,8 +15,13 @@ import sys
import threading import threading
import time import time
from pathlib import Path from pathlib import Path
from urllib.parse import urlsplit
DEFAULTS = json.loads(Path(__file__).with_name('jev-settings-defaults.json').read_text(encoding='utf-8')) DEFAULTS = json.loads(Path(__file__).with_name('jev-settings-defaults.json').read_text(encoding='utf-8'))
POLICY_FILE = 'jev-policy.json'
# Sections Dify rules may change. The transition chain and the always-pass list stay local.
POLICY_KEYS = ('prefilter', 'autoRoute', 'finishCheck', 'verifierCheck', 'thresholds', 'questions')
SECTIONS = ('watchModels', 'prefilter', 'autoRoute', 'finishCheck', 'verifierCheck', 'policy')
_condition = threading.Condition() _condition = threading.Condition()
_active = 0 _active = 0
@@ -20,16 +32,73 @@ def home():
return Path(os.environ.get('AITURK_CODEX_BRIDGE_HOME') or Path(__file__).parent) return Path(os.environ.get('AITURK_CODEX_BRIDGE_HOME') or Path(__file__).parent)
def read(): def _fill(value, default):
try: """Missing keys of a schema 1 file come from the defaults; present values are kept as written."""
data = json.loads((home() / 'jev-settings.json').read_text(encoding='utf-8')) if isinstance(default, dict) and isinstance(value, dict):
except FileNotFoundError: return {k: _fill(value[k], d) if k in value else copy.deepcopy(d) for k, d in default.items()} | \
return copy.deepcopy(DEFAULTS), None {k: v for k, v in value.items() if k not in default}
settings = data['settings'] return value
def _text(value, limit=10000):
return isinstance(value, str) and bool(value.strip()) and len(value) <= limit
def _prob(value):
return isinstance(value, (int, float)) and not isinstance(value, bool) and 0 <= value <= 1
def _int(value, low, high):
return isinstance(value, int) and not isinstance(value, bool) and low <= value <= high
def _https(value):
if not _text(value, 2048):
return False
url = urlsplit(value)
return url.scheme == 'https' and bool(url.netloc) and not url.username and not url.password
IDE_PREFIX = 'ide/'
def _strip_ide(value):
"""A stored model name must never carry the gateway's own 'ide/' prefix; the bridge adds it when
it routes. A doubled 'ide/ide/...' makes the bridge treat the name as an unknown model, read its
context window as the 32768 default and silently drop the request. No real provider/model id
begins with 'ide/', so stripping a leading 'ide/' here can only undo an accidental prefix."""
if isinstance(value, str):
while value.startswith(IDE_PREFIX):
value = value[len(IDE_PREFIX):]
return value
def _normalize_models(settings):
"""Strip a stray 'ide/' prefix from every stored model name so routing can never double it."""
if 'fallbackModel' in settings:
settings['fallbackModel'] = _strip_ide(settings['fallbackModel'])
for key in ('watchModels', 'exemptModels'):
values = settings.get(key)
if isinstance(values, list):
settings[key] = [_strip_ide(x) for x in values]
prefilter = settings.get('prefilter')
if isinstance(prefilter, dict) and 'target' in prefilter:
prefilter['target'] = _strip_ide(prefilter['target'])
auto = settings.get('autoRoute')
if isinstance(auto, dict):
if 'model' in auto:
auto['model'] = _strip_ide(auto['model'])
targets = auto.get('targets')
if isinstance(targets, dict):
for key in list(targets):
targets[key] = _strip_ide(targets[key])
return settings
def validate(settings):
"""Raise ValueError when a value could route to the wrong place or wait without bound."""
# A corrupted file must not silently change routing or contact another host. # A corrupted file must not silently change routing or contact another host.
from urllib.parse import urlsplit if not _https(settings['endpoint']):
url = urlsplit(settings['endpoint'])
if url.scheme != 'https' or not url.netloc or url.username or url.password:
raise ValueError('Invalid Jev endpoint') raise ValueError('Invalid Jev endpoint')
for name in ('ret', 'sart'): for name in ('ret', 'sart'):
n = settings['thresholds'][name] n = settings['thresholds'][name]
@@ -42,24 +111,81 @@ def read():
raise ValueError('Invalid Jev concurrency') raise ValueError('Invalid Jev concurrency')
if not settings['fallbackModel'] or not isinstance(settings['rejectionRules'], list) or settings['failureAction'] not in ('pass', 'next'): if not settings['fallbackModel'] or not isinstance(settings['rejectionRules'], list) or settings['failureAction'] not in ('pass', 'next'):
raise ValueError('Invalid Jev routing') raise ValueError('Invalid Jev routing')
return settings, data.get('protectedKey') watch = settings['watchModels']
if not isinstance(watch, list) or len(watch) > 64 or not all(_text(x, 256) for x in watch) or len(set(watch)) != len(watch):
raise ValueError('Invalid watch models')
p = settings['prefilter']
if not (isinstance(p.get('enabled'), bool) and _text(p.get('question')) and _prob(p.get('threshold'))
and _text(p.get('target'), 256) and _int(p.get('timeoutSeconds'), 1, 60)):
raise ValueError('Invalid prefilter')
a = settings['autoRoute']
if not (isinstance(a.get('enabled'), bool) and _text(a.get('model'), 256) and _int(a.get('timeoutSeconds'), 1, 60)
and all(_text((a.get('questions') or {}).get(k)) and _prob((a.get('thresholds') or {}).get(k)) for k in ('zor', 'arastirma'))
and all(_text((a.get('targets') or {}).get(k), 256) for k in ('basit', 'zor', 'arastirma'))):
raise ValueError('Invalid automatic routing')
if a['model'] in a['targets'].values() or p['target'] == a['model']:
raise ValueError('Automatic routing cannot target itself')
f = settings['finishCheck']
if not (isinstance(f.get('enabled'), bool) and _text(f.get('question')) and _prob(f.get('threshold'))
and _text(f.get('note'), 4000)):
raise ValueError('Invalid finish check')
v = settings['verifierCheck']
if not (isinstance(v.get('enabled'), bool) and _text(v.get('question')) and _prob(v.get('threshold'))):
raise ValueError('Invalid verifier check')
pol = settings['policy']
if not (pol.get('source') in ('local', 'dify') and _https(pol.get('endpoint')) and _int(pol.get('refreshMinutes'), 1, 1440)):
raise ValueError('Invalid policy source')
return settings
def key(): def _policy(settings):
_, encrypted = read() """Last good Dify rules on top of the local settings; anything invalid keeps the local settings."""
if settings['policy']['source'] != 'dify':
return settings
try:
cache = json.loads((home() / POLICY_FILE).read_text(encoding='utf-8'))
overrides = cache.get('overrides')
if not cache.get('ok') or not isinstance(overrides, dict):
return settings
merged = copy.deepcopy(settings)
for key in POLICY_KEYS:
if isinstance(overrides.get(key), dict):
merged[key] = _fill(overrides[key], settings[key])
_normalize_models(merged)
return validate(merged)
except (OSError, ValueError, KeyError, TypeError, AttributeError):
return settings
def read(overlay=True):
"""(settings, protectedKey). overlay=False: the local settings without the Dify rules."""
try:
data = json.loads((home() / 'jev-settings.json').read_text(encoding='utf-8'))
except FileNotFoundError:
return copy.deepcopy(DEFAULTS), None
settings = data['settings']
for section in SECTIONS:
settings[section] = _fill(settings[section], DEFAULTS[section]) if section in settings else copy.deepcopy(DEFAULTS[section])
_normalize_models(settings)
validate(settings)
return (_policy(settings) if overlay else settings), data.get('protectedKey')
class _Blob(ctypes.Structure):
_fields_ = [('cbData', ctypes.c_uint32), ('pbData', ctypes.POINTER(ctypes.c_ubyte))]
def _unprotect(encrypted):
if not encrypted: if not encrypted:
return '' return ''
if os.name != 'nt': if os.name != 'nt':
raise RuntimeError('Windows credential protection required') raise RuntimeError('Windows credential protection required')
from ctypes import wintypes
class Blob(ctypes.Structure):
_fields_ = [('cbData', wintypes.DWORD), ('pbData', ctypes.POINTER(ctypes.c_ubyte))]
raw = base64.b64decode(encrypted, validate=True) raw = base64.b64decode(encrypted, validate=True)
buf = (ctypes.c_ubyte * len(raw)).from_buffer_copy(raw) buf = (ctypes.c_ubyte * len(raw)).from_buffer_copy(raw)
source, result = Blob(len(raw), buf), Blob() source, result = _Blob(len(raw), buf), _Blob()
decrypt = ctypes.windll.crypt32.CryptUnprotectData decrypt = ctypes.windll.crypt32.CryptUnprotectData
decrypt.argtypes = [ctypes.POINTER(Blob), ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, wintypes.DWORD, ctypes.POINTER(Blob)] decrypt.argtypes = [ctypes.POINTER(_Blob), ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(_Blob)]
decrypt.restype = wintypes.BOOL decrypt.restype = ctypes.c_int
if not decrypt(ctypes.byref(source), None, None, None, None, 0, ctypes.byref(result)): if not decrypt(ctypes.byref(source), None, None, None, None, 0, ctypes.byref(result)):
raise ctypes.WinError() raise ctypes.WinError()
try: try:
@@ -69,9 +195,56 @@ def key():
ctypes.windll.kernel32.LocalFree(result.pbData) ctypes.windll.kernel32.LocalFree(result.pbData)
def acquire(settings): def key():
_, encrypted = read()
return _unprotect(encrypted)
def policy_key():
"""Dify app key for the rules source (protectedPolicyKey, Windows DPAPI). Empty when not set."""
try:
data = json.loads((home() / 'jev-settings.json').read_text(encoding='utf-8'))
except (OSError, ValueError):
return ''
return _unprotect(data.get('protectedPolicyKey'))
def write_policy(result):
"""Atomically store the outcome of a Dify fetch. A failed fetch keeps the last good overrides."""
path = home() / POLICY_FILE
previous = {}
try:
previous = json.loads(path.read_text(encoding='utf-8'))
except (OSError, ValueError):
pass
record = {'fetchedAt': time.strftime('%Y-%m-%dT%H:%M:%S%z'), 'ok': bool(result.get('ok')), 'error': str(result.get('error') or '')[:500]}
record['overrides'] = result.get('overrides') if result.get('ok') else previous.get('overrides')
if not result.get('ok') and previous.get('ok'):
record['ok'] = True # the last good rules stay in force; the error is reported beside them
record['lastGoodAt'] = previous.get('lastGoodAt') or previous.get('fetchedAt')
elif result.get('ok'):
record['lastGoodAt'] = record['fetchedAt']
tmp = path.with_name(path.name + f'.{os.getpid()}.{threading.get_ident()}.tmp')
tmp.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding='utf-8')
try:
for deneme in range(5):
try:
os.replace(tmp, path) # Windows: a reader holding the file makes this fail briefly
break
except PermissionError:
if deneme == 4:
raise
time.sleep(0.05 * (deneme + 1))
finally:
if tmp.exists():
tmp.unlink()
return record
def acquire(settings, wait=None):
"""wait: at most this many seconds in the queue (turn-start questions must not hold a request)."""
global _active global _active
deadline = time.monotonic() + settings['queueWaitSeconds'] deadline = time.monotonic() + (min(settings['queueWaitSeconds'], wait) if wait else settings['queueWaitSeconds'])
with _condition: with _condition:
while settings['maxConcurrent'] and _active >= settings['maxConcurrent']: while settings['maxConcurrent'] and _active >= settings['maxConcurrent']:
remaining = deadline - time.monotonic() remaining = deadline - time.monotonic()
@@ -0,0 +1,102 @@
"""Decision rules from Dify (14 Oct 2026).
Owner request, 14 Oct 2026: "Dify'de hazirlansa oradan gelse". The model a Codex step talks to stays an
OmniRoute combo, because a Dify app would not pass Codex's own tools through. What Dify supplies is the rules:
the decision questions, thresholds and which combo each kind of request goes to. The bridge fetches them in
the background (policy.refreshMinutes), validates them against the local settings and stores the last good
copy in jev-policy.json. A turn never waits for Dify; a failed fetch keeps the last good rules.
The Dify workflow returns one output named ``kurallar``: a JSON object (or JSON text) whose keys are any of
jev_settings.POLICY_KEYS, each holding the same fields as the local settings section, for example
{"autoRoute": {"enabled": true, "targets": {"zor": "Ajan"}}}.
"""
import copy
import json
import time
import urllib.error
import urllib.request
import jev_settings
UA = "AITURK-IDE-codex-bridge/karar-politikasi"
class _YonlendirmeYok(urllib.request.HTTPRedirectHandler):
"""A redirect would carry the Authorization header to another host; it is an error instead."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise urllib.error.HTTPError(req.full_url, code, f"redirect refused: {newurl}", headers, fp)
_ACICI = urllib.request.build_opener(_YonlendirmeYok)
MAX_BYTES = 64 * 1024
_son = {"zaman": 0.0}
def ayikla(cikti, yerel):
"""Dify output -> validated overrides. Raises ValueError with a short reason when unusable."""
if isinstance(cikti, str):
if len(cikti.encode("utf-8")) > MAX_BYTES:
raise ValueError("kurallar too large")
cikti = json.loads(cikti)
if not isinstance(cikti, dict):
raise ValueError("kurallar is not an object")
bilinmeyen = sorted(set(cikti) - set(jev_settings.POLICY_KEYS))
if bilinmeyen:
raise ValueError("unknown sections: " + ", ".join(bilinmeyen))
overrides = {k: v for k, v in cikti.items() if isinstance(v, dict)}
if len(overrides) != len(cikti):
raise ValueError("every section must be an object")
birlesik = copy.deepcopy(yerel)
for k, v in overrides.items():
birlesik[k] = jev_settings._fill(v, yerel[k])
jev_settings.validate(birlesik)
return overrides
def cek(settings, key, urlopen=_ACICI.open):
"""One fetch. Returns {"ok": bool, "overrides": {...}, "error": str}; never raises."""
if not key:
return {"ok": False, "error": "Dify key not configured"}
url = settings['policy']['endpoint'].rstrip('/') + '/workflows/run'
govde = json.dumps({"inputs": {}, "response_mode": "blocking", "user": "aiturk-codex-bridge"}).encode("utf-8")
istek = urllib.request.Request(url, data=govde, method="POST", headers={
"Authorization": "Bearer " + key, "Content-Type": "application/json", "User-Agent": UA})
try:
with urlopen(istek, timeout=30) as yanit:
ham = yanit.read(MAX_BYTES * 4 + 1)
if len(ham) > MAX_BYTES * 4:
return {"ok": False, "error": "Dify response too large"}
veri = json.loads(ham)
cikti = ((veri.get("data") or {}).get("outputs") or {}).get("kurallar")
if cikti is None:
return {"ok": False, "error": "Dify output 'kurallar' missing"}
return {"ok": True, "overrides": ayikla(cikti, settings)}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"[:300]}
def zamani_mi(settings, simdi=None):
simdi = time.time() if simdi is None else simdi
return settings['policy']['source'] == 'dify' and simdi - _son["zaman"] >= settings['policy']['refreshMinutes'] * 60
def yenile(log=lambda _m: None, urlopen=_ACICI.open, zorla=False):
"""Fetch when due; store the outcome. Local settings are read without the Dify overlay."""
try:
yerel, _ = _yerel_ayar()
except Exception as e:
log(f"karar kurallari: ayar okunamadi ({type(e).__name__})")
return None
if not zorla and not zamani_mi(yerel):
return None
_son["zaman"] = time.time()
sonuc = cek(yerel, jev_settings.policy_key(), urlopen=urlopen)
kayit = jev_settings.write_policy(sonuc)
log("karar kurallari Dify'dan alindi" if sonuc.get("ok") else f"karar kurallari alinamadi: {sonuc.get('error')}"
+ (" (son gecerli kurallar kullaniliyor)" if kayit.get("ok") else ""))
return kayit
def _yerel_ayar():
"""Settings as written locally (no Dify overlay), so a bad overlay can never validate itself."""
return jev_settings.read(overlay=False)
@@ -0,0 +1,58 @@
"""One decision-model question from the command line (14 Oct 2026).
Used by the Codex dynamic-workflow acceptance step (verifierCheck): before a reviewer's "kabul" is accepted,
the decision model is asked whether the approval cites concrete evidence. Reads the IDE's own Kademeli Geçiş
settings and key; nothing is written.
python karar_sor.py dogrulayici < {"user_request": "...", "assistant_reply": "..."}
Prints JSON {"enabled": bool, "kanitsiz": bool|null, "puan": float|null, "neden": str}. Exit 0 also when the
stage is off or the model cannot be reached; the caller decides what an unanswered check means.
"""
import json
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import jev_denetim # noqa: E402
import jev_settings # noqa: E402
SINIR = 20000
def dogrulayici(veri, anahtar=None, gonder=None):
s = jev_settings.read()[0]
v = s['verifierCheck']
if not (s['enabled'] and v['enabled']):
return {'enabled': False, 'kanitsiz': None, 'puan': None, 'neden': 'kapali'}
durum = {'user_request': str(veri.get('user_request') or '')[:SINIR], 'assistant_reply': str(veri.get('assistant_reply') or '')[:SINIR]}
ek = {'gonder': gonder} if gonder else {}
puan = jev_denetim.sorular(durum, {'kanitsiz': v['question']}, jev_denetim.anahtar() if anahtar is None else anahtar, 15, **ek)
if puan is None:
return {'enabled': True, 'kanitsiz': None, 'puan': None, 'neden': 'karar modeline sorulamadi'}
return {'enabled': True, 'kanitsiz': puan['kanitsiz'] >= v['threshold'], 'puan': round(puan['kanitsiz'], 3), 'neden': ''}
def main(argv=None, stdin=None, out=None):
argv = sys.argv[1:] if argv is None else argv
stdin, out = stdin or sys.stdin, out or sys.stdout
soru = [a for i, a in enumerate(argv) if a != '--home' and (i == 0 or argv[i - 1] != '--home')]
if soru != ['dogrulayici']:
print('kullanim: python karar_sor.py dogrulayici < {"user_request": ..., "assistant_reply": ...}', file=sys.stderr)
return 2
try:
veri = json.load(stdin)
sonuc = dogrulayici(veri if isinstance(veri, dict) else {})
except Exception as e:
sonuc = {'enabled': None, 'kanitsiz': None, 'puan': None, 'neden': f'{type(e).__name__}: {e}'[:300]}
out.write(json.dumps(sonuc, ensure_ascii=False))
return 0
if __name__ == '__main__':
# Installed copy: the settings live in the user's bridge home, not next to this file.
if '--home' not in sys.argv and not os.environ.get('AITURK_CODEX_BRIDGE_HOME'):
os.environ['AITURK_CODEX_BRIDGE_HOME'] = str(Path(os.environ.get('LOCALAPPDATA', '')) / 'TurkServis' / 'AITURK-IDE' / 'codex-bridge')
sys.exit(main())
+304 -16
View File
@@ -34,13 +34,15 @@ import model_gecis
import is_hatti import is_hatti
import internet import internet
import jev_denetim import jev_denetim
import jev_settings
import karar_politikasi
from bekci_watch import MetadataFilter from bekci_watch import MetadataFilter
# Installed only by owned native launcher, never an HTTP control endpoint. # Installed only by owned native launcher, never an HTTP control endpoint.
WATCH_OWNER = None WATCH_OWNER = None
SURUM = "17" SURUM = "20"
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
try: try:
with open(os.path.abspath(__file__), "rb") as _f: with open(os.path.abspath(__file__), "rb") as _f:
@@ -228,6 +230,23 @@ def load_ide_catalog(ag=True):
"kaynak": g.get("pencere_kaynak") or "", "claude": bool(g.get("claude"))} "kaynak": g.get("pencere_kaynak") or "", "claude": bool(g.get("claude"))}
model_map[slug] = (IDE_ROUTE, g["id"], None) model_map[slug] = (IDE_ROUTE, g["id"], None)
n += 1 n += 1
oto = _oto_ayari()
if oto and IDE_PREFIX + oto["model"] not in model_map:
# Sanal model: istek geldiginde karar modeli ayardaki combo'lardan birini secer (14 Ekim 2026).
slug = IDE_PREFIX + oto["model"]
pencereler = [IDE_BILGI.get(IDE_PREFIX + h, {}).get("pencere") for h in set(oto["targets"].values())]
pencere_oto = min(pencereler) if pencereler and all(pencereler) else None
extra_models.append({"slug": slug, "display_name": f"{oto['model']} (karar modeli seçer)",
"description": "Her turda karar modeli işin türüne göre combo seçer: "
+ ", ".join(f"{k}: {v}" for k, v in oto["targets"].items()),
"route": IDE_ROUTE, "target": oto["model"], "clone_from": clone, "priority": 299,
"reasoning_levels": levels, "kind": "combo", "context_window": pencere_oto})
IDE_BILGI[slug] = {"pencere": pencere_oto, "gorsel": all(IDE_BILGI.get(IDE_PREFIX + h, {}).get("gorsel")
for h in set(oto["targets"].values())),
"arac": True, "kaynak": "", "claude": False}
model_map[slug] = (IDE_ROUTE, oto["model"], None)
OTO_ADI[0] = oto["model"]
n += 1
else: else:
ROUTES.pop(IDE_ROUTE, None) ROUTES.pop(IDE_ROUTE, None)
MODEL_MAP = model_map MODEL_MAP = model_map
@@ -237,10 +256,141 @@ def load_ide_catalog(ag=True):
IDE_DURUM[0] = ozet IDE_DURUM[0] = ozet
log(f"AITURK IDE Codex Baglama: {ozet}") log(f"AITURK IDE Codex Baglama: {ozet}")
def _oto_ayari():
"""autoRoute acik ve Kademeli Gecis acikken onun ayari; degilse None. Ayar okunamazsa None."""
try:
s = jev_settings.read()[0]
except Exception:
return None
return s["autoRoute"] if s["enabled"] and s["autoRoute"]["enabled"] else None
# Tur basi kararlari: sohbet -> (tur anahtari, gelen slug, secilen slug). Ayni turun adimlari ayni karari kullanir.
ON_KARAR = {}
ON_KARAR_SINIR = 500
BITIS_TURLARI = {} # finishCheck kullanilan turlar (turda bir kez)
KARAR_KILIDI = threading.Lock()
OTO_ADI = ["Otomatik"] # katalogdaki sanal modelin son adi (karar asamasi hata verirse disari cikmasin)
# Acilista pencere verisi icin aga cikilmaz (IDE koprunun dinlemeye baslamasini sinirli sure bekler); # Acilista pencere verisi icin aga cikilmaz (IDE koprunun dinlemeye baslamasini sinirli sure bekler);
# ilk tam yenileme asagidaki dongunun basinda, arka planda yapilir. # ilk tam yenileme asagidaki dongunun basinda, arka planda yapilir.
load_ide_catalog(ag=False) load_ide_catalog(ag=False)
def tur_bilgisi(req, sohbet):
"""(sohbet koku, tur anahtari, tur basindaki istek, yeni tur mu). Tur, calisan bir turun icine dusmeyen
kullanici mesajiyla baslar. Arac adimlari, tur ortasinda yazilan mesaj, tek "." ve ozet istegi ayni turdadir;
yeni tur yalniz son oge yeni bir kullanici istegi oldugunda baslar."""
girdi = req.get("input") or []
kok = sohbet or "anon:" + hashlib.sha256(json.dumps(girdi[:1], ensure_ascii=False, sort_keys=True,
default=str).encode("utf-8", "replace")).hexdigest()
bas, istek = -1, ""
for i in range(len(girdi) - 1, -1, -1):
metin = jev_denetim.istek_metni(girdi[i])
if not metin or metin == ".":
continue
if not istek:
bas, istek = i, metin # tur basi bulunamazsa en son gercek istek
if not model_gecis.tur_ortasi(req, i):
bas, istek = i, metin
break
son = model_gecis.son_sira(girdi)
yeni = son >= 0 and son == bas and not model_gecis.tur_ortasi(req, son)
tur = hashlib.sha256(f"{kok}\0{bas}\0{istek}".encode("utf-8", "replace")).hexdigest()
return kok, tur, istek, yeni
def karar_asamasi(n, req, sohbet, slug):
"""14 Ekim 2026 karar asamalari: "Otomatik" modelin combo secimi (autoRoute) ve normal modelin reddedecegi
tahmin edilen isi dogrudan ayardaki katmana verme (prefilter). Karar yalniz yeni turda verilir; ayni turun
adimlari ayni karari kullanir (tur ortasinda model degisirse saglayicilar arasi gecmis bozulur). Kapali ayar,
bos istek ya da karar modeli hatasi bugunku yolu degistirmez. Sanal model adi hicbir yolda gecide gitmez."""
if not slug.startswith(IDE_PREFIX):
return slug
settings = jev_settings.read()[0]
a, p = settings["autoRoute"], settings["prefilter"]
sanal = slug == IDE_PREFIX + a["model"]
kok, tur, istek, yeni = tur_bilgisi(req, sohbet)
with KARAR_KILIDI:
kayit = ON_KARAR.get(kok)
if sanal and not (settings["enabled"] and a["enabled"]):
# Kapatilmis Otomatik: son karar ya da guclu hedef; sanal ad gecide gitmez.
secilen = kayit[2] if kayit and kayit[1] == slug else IDE_PREFIX + a["targets"]["zor"]
log(f"[{n} {slug}] Otomatik kapali; bu adim {secilen} modeline gidiyor")
req["model"] = secilen
return secilen
if not settings["enabled"]:
return slug
hedef = slug[len(IDE_PREFIX):]
ret = p["enabled"] and (sanal or not (jev_denetim.muaf_mi(hedef) or hedef == p["target"]
or hedef in jev_denetim.sonrakiler(p["target"])))
if not sanal and not ret:
return slug
if kayit and kayit[1] == slug and (kayit[0] == tur or not yeni):
secilen = kayit[2]
else:
if istek:
t0 = time.time()
sonuc = jev_denetim.on_karar(istek, sanal, ret, jev_denetim.anahtar())
puan = sonuc["puan"]
log(f"[{n} {slug}] tur basi karari ({time.time() - t0:.1f} sn): "
+ (", ".join(f"{k}={v:.2f}" for k, v in puan.items()) if puan else "karar modeline sorulamadi"))
else:
sonuc = {"hedef": a["targets"]["zor"] if sanal else None, "ret": None}
if sanal:
hedef = sonuc["hedef"]
secilen = IDE_PREFIX + hedef
if (p["enabled"] and sonuc["ret"] and not jev_denetim.muaf_mi(hedef) and hedef != p["target"]
and hedef not in jev_denetim.sonrakiler(p["target"])):
log(f"[{n} {slug}] ret on kontrolu: normal model reddedebilir, adim {p['target']} katmanindan basliyor")
secilen = IDE_PREFIX + p["target"]
req["model"] = secilen
with KARAR_KILIDI:
ON_KARAR.pop(kok, None) # yeniden ekleme sirayi tazeler: aktif sohbet en son silinir
ON_KARAR[kok] = (tur, slug, secilen)
while len(ON_KARAR) > ON_KARAR_SINIR:
ON_KARAR.pop(next(iter(ON_KARAR)), None)
if secilen != slug:
log(f"[{n} {slug}] karar: bu tur {secilen} modeline gidiyor")
req["model"] = secilen
return secilen
def bitis_kullan(tur):
"""finishCheck turda en fazla bir kez: ilk cagri True (ve isaretler), sonrakiler False."""
with KARAR_KILIDI:
if tur in BITIS_TURLARI:
return False
BITIS_TURLARI[tur] = time.time()
while len(BITIS_TURLARI) > ON_KARAR_SINIR:
BITIS_TURLARI.pop(next(iter(BITIS_TURLARI)), None)
return True
def sigan_katmanlar(sira, req, tag):
"""Istegi sigdiramayacak katman zincirden cikar (bos baglam hatasiyla bosa dusmesin). Yalniz penceresi
guvenilir (pencere.bekci_calisir) katman atlanir; zincirin son halkasi hic atlanmaz."""
tahmin, sonuc = None, []
for i, ad in enumerate(sira):
bilgi = IDE_BILGI.get(IDE_PREFIX + ad) or {}
p = bilgi.get("pencere")
if p and i < len(sira) - 1 and pencere.bekci_calisir(bilgi.get("kaynak"), p):
if tahmin is None:
tahmin = pencere.tahmini_token(req, 2.0)
if tahmin > int(p * 0.90):
log(f"{tag} katman {ad} atlandi: istek sigmaz (tahmini {tahmin} token, pencere {p})")
continue
sonuc.append(ad)
return sonuc
def karar_kurallari_dongusu():
"""Dify kural kaynagi (policy.source = dify) arka planda yenilenir; tur hic beklemez."""
time.sleep(5)
while True:
try:
karar_politikasi.yenile(log)
except Exception as e:
log(f"karar kurallari yenileme hatasi: {type(e).__name__}: {e}")
time.sleep(60)
def catalog_refresh_loop(): def catalog_refresh_loop():
"""Kataloglari arka planda yeniler; liste istegini yavaslatmaz.""" """Kataloglari arka planda yeniler; liste istegini yavaslatmaz."""
minutes = max(1, int(CFG.get("catalog_refresh_min", 5))) minutes = max(1, int(CFG.get("catalog_refresh_min", 5)))
@@ -258,6 +408,7 @@ def catalog_refresh_loop():
log(f"katalog yenileme hatasi: {e}") log(f"katalog yenileme hatasi: {e}")
threading.Thread(target=catalog_refresh_loop, daemon=True).start() threading.Thread(target=catalog_refresh_loop, daemon=True).start()
threading.Thread(target=karar_kurallari_dongusu, daemon=True).start()
# ---- AITURK is hatti: isi gonderebilen AITURK komutu (is_hatti.py). Kurulum sonradan gelebilir: yoksa 2 dk'da bir bakilir. # ---- AITURK is hatti: isi gonderebilen AITURK komutu (is_hatti.py). Kurulum sonradan gelebilir: yoksa 2 dk'da bir bakilir.
HAT_KOMUT = [None] HAT_KOMUT = [None]
@@ -265,7 +416,7 @@ HAT_KOMUT = [None]
def hat_komut_loop(): def hat_komut_loop():
while True: while True:
try: try:
if (CFG.get("aiturk_hat") or {}).get("enabled") is not False: if True: # komut her zaman aranir; secim ayarlar ekranindan sonradan acilabilir
bulunan = is_hatti.komut_bul() bulunan = is_hatti.komut_bul()
if bulunan != HAT_KOMUT[0]: if bulunan != HAT_KOMUT[0]:
HAT_KOMUT[0] = bulunan HAT_KOMUT[0] = bulunan
@@ -276,6 +427,57 @@ def hat_komut_loop():
threading.Thread(target=hat_komut_loop, daemon=True).start() threading.Thread(target=hat_komut_loop, daemon=True).start()
_HAT_AYAR = {"mtime": None, "deger": None}
def hat_ayari():
"""bridge.json "aiturk_hat" alani; ayarlar ekranindan yapilan degisiklik yeniden baslatmadan uygulanir."""
yol = os.path.join(HOME, "bridge.json")
try:
mt = os.path.getmtime(yol)
if mt != _HAT_AYAR["mtime"]:
with open(yol, encoding="utf-8") as f:
_HAT_AYAR["deger"] = (json.load(f) or {}).get("aiturk_hat")
_HAT_AYAR["mtime"] = mt
return _HAT_AYAR["deger"]
except Exception:
return CFG.get("aiturk_hat")
_LISTE_AYAR = {"mtime": None, "deger": None}
def liste_ayari():
"""bridge.json "codex_liste": Codex secicisinde gorunecek modeller (ayarlar ekrani yazar, yeniden baslatmadan uygulanir)."""
yol = os.path.join(HOME, "bridge.json")
try:
mt = os.path.getmtime(yol)
if mt != _LISTE_AYAR["mtime"]:
with open(yol, encoding="utf-8") as f:
_LISTE_AYAR["deger"] = (json.load(f) or {}).get("codex_liste")
_LISTE_AYAR["mtime"] = mt
return _LISTE_AYAR["deger"] or {}
except Exception:
return CFG.get("codex_liste") or {}
_ADAY_IZ = {"iz": None}
def aday_kaydet(sonuc):
"""Ayarlar ekrani icin aday listesi (log/son-liste/codex-adaylar.json); icerik degismediyse yazilmaz."""
if not sonuc:
return
try:
govde = json.dumps(sonuc, ensure_ascii=False, sort_keys=True)
iz = hashlib.sha256(govde.encode("utf-8")).hexdigest()
if iz == _ADAY_IZ["iz"]:
return
os.makedirs(CACHE_DIR, exist_ok=True)
yol = os.path.join(CACHE_DIR, "codex-adaylar.json")
gecici = "%s.%d.tmp" % (yol, threading.get_ident())
with open(gecici, "w", encoding="utf-8") as f:
json.dump({**sonuc, "at": time.strftime("%Y-%m-%d %H:%M:%S")}, f, ensure_ascii=False)
os.replace(gecici, yol)
_ADAY_IZ["iz"] = iz
except Exception as e:
log(f"Codex aday listesi yazilamadi: {e}")
LAST_MODELS = {} # hesap kimligi -> son basarili model listesi (chatgpt.com hata verirse bu verilir) LAST_MODELS = {} # hesap kimligi -> son basarili model listesi (chatgpt.com hata verirse bu verilir)
CACHE_DIR = os.path.join(LOGDIR, "son-liste") CACHE_DIR = os.path.join(LOGDIR, "son-liste")
@@ -943,7 +1145,7 @@ class H(http.server.BaseHTTPRequestHandler):
self.send_header("Transfer-Encoding", "chunked") self.send_header("Transfer-Encoding", "chunked")
self.end_headers() self.end_headers()
def _denetimli_akis(self, resp, tag, rewriter, izle, istek, sira, ac, hedef=None): def _denetimli_akis(self, resp, tag, rewriter, izle, istek, sira, ac, hedef=None, devam=None):
"""Katman denetimi (jev_denetim.py): cevap istemciye verilmeden once tamami okunur ve Jev'e sorulur. """Katman denetimi (jev_denetim.py): cevap istemciye verilmeden once tamami okunur ve Jev'e sorulur.
Gecerse oldugu gibi verilir; gecmezse atilir ve ayni adimi siradaki katman yapar (ac(ad): (resp, rewriter, Gecerse oldugu gibi verilir; gecmezse atilir ve ayni adimi siradaki katman yapar (ac(ad): (resp, rewriter,
tag)). Son katmanin cevabi sorulmadan akar. Beklerken SSE yorum satiri gider ki Codex akisi kopmus saymasin.""" tag)). Son katmanin cevabi sorulmadan akar. Beklerken SSE yorum satiri gider ki Codex akisi kopmus saymasin."""
@@ -955,6 +1157,8 @@ class H(http.server.BaseHTTPRequestHandler):
else: else:
self._akis_basligi(resp, resp.status) self._akis_basligi(resp, resp.status)
kalan, son_yazi, son_govde = list(sira), time.time(), b"" kalan, son_yazi, son_govde = list(sira), time.time(), b""
devam_kullanildi = False # finishCheck: son cevap ayni modele en fazla bir kez geri verilir
yedek = None # geri verilen istek basarisiz olursa teslim edilecek, Jev'den gecmis ilk cevap
def w(b): def w(b):
if b: if b:
@@ -999,7 +1203,10 @@ class H(http.server.BaseHTTPRequestHandler):
jev_done, jev_result = jev_threading.Event(), [None] jev_done, jev_result = jev_threading.Event(), [None]
def denetle(): def denetle():
try: try:
jev_result[0] = jev_denetim.sor(istek, metin, jev_denetim.anahtar()) if devam is not None and not devam_kullanildi:
jev_result[0] = jev_denetim.sor(istek, metin, jev_denetim.anahtar(), bitis=True)
else:
jev_result[0] = jev_denetim.sor(istek, metin, jev_denetim.anahtar())
finally: finally:
jev_done.set() jev_done.set()
jev_threading.Thread(target=denetle, daemon=True).start() jev_threading.Thread(target=denetle, daemon=True).start()
@@ -1011,14 +1218,32 @@ class H(http.server.BaseHTTPRequestHandler):
log(f"{tag} katman denetimi: Jev'e sorulamadi, {'siradaki katman' if gecmedi else 'cevap oldugu gibi geciyor'}") log(f"{tag} katman denetimi: Jev'e sorulamadi, {'siradaki katman' if gecmedi else 'cevap oldugu gibi geciyor'}")
else: else:
gecmedi = jev_denetim.karar(puan) gecmedi = jev_denetim.karar(puan)
log(f"{tag} katman denetimi: ret={puan['ret']:.2f} sart={puan['sart']:.2f} -> " log(f"{tag} katman denetimi: ret={puan['ret']:.2f} sart={puan['sart']:.2f}"
+ (f" bitmedi={puan['bitmedi']:.2f}" if 'bitmedi' in puan else "") + " -> "
f"{'GECMEDI' if gecmedi else 'gecti'} ({time.time() - t0:.1f} sn, {len(metin)} karakter{'' if yazi else ', yalniz komut'})") f"{'GECMEDI' if gecmedi else 'gecti'} ({time.time() - t0:.1f} sn, {len(metin)} karakter{'' if yazi else ', yalniz komut'})")
if not gecmedi and devam is not None and not devam_kullanildi and jev_denetim.bitmedi_mi(puan):
devam_kullanildi = True
try:
yeni_devam = devam(hedef, metin)
except Exception as e:
yeni_devam = None
log(f"{tag} bitis denetimi: devam istegi acilamadi, cevap oldugu gibi geciyor ({type(e).__name__}: {e})")
if yeni_devam is not None:
yedek = (parcalar, rewriter, tag)
resp, rewriter, tag = yeni_devam
log(f"{tag} bitis denetimi: is bitmeden bitti denmis, ayni modele bir kez geri verildi")
continue
else: else:
# Bos adim: ne yazi var ne arac cagrisi (hata olayi da boyledir, or. "upstream 400"). Kabul # Bos adim: ne yazi var ne arac cagrisi (hata olayi da boyledir, or. "upstream 400"). Kabul
# edilmez, siradaki katman yapar; bos yanit cevap diye verilirse Codex adimi bastan dener. # edilmez, siradaki katman yapar; bos yanit cevap diye verilirse Codex adimi bastan dener.
gecmedi = True gecmedi = True
log(f"{tag} katman denetimi: bos adim (yazi da komut da yok), siradakine geciliyor: " log(f"{tag} katman denetimi: bos adim (yazi da komut da yok), siradakine geciliyor: "
f"{b''.join(parcalar)[-200:]!r}") f"{b''.join(parcalar)[-200:]!r}")
if gecmedi and yedek is not None:
# Geri verilen istek koptu, bos dondu ya da gecmedi: Jev'den gecmis ilk cevap teslim edilir.
parcalar, rewriter, tag = yedek
gecmedi = False
log(f"{tag} bitis denetimi: geri verilen istek kullanilamadi, ilk cevap teslim ediliyor")
yeni = None yeni = None
while gecmedi and kalan and yeni is None: while gecmedi and kalan and yeni is None:
ad = kalan.pop(0) ad = kalan.pop(0)
@@ -1380,7 +1605,7 @@ class H(http.server.BaseHTTPRequestHandler):
m.update(saved) m.update(saved)
models.append(m); changed += 1 models.append(m); changed += 1
if ekler: if ekler:
ide_catalog.sirala_codex(models, ekler, ozgun_kimlikler) aday_kaydet(ide_catalog.sirala_codex(models, ekler, ozgun_kimlikler, liste_ayari()))
out = json.dumps(data, ensure_ascii=False).encode("utf-8") out = json.dumps(data, ensure_ascii=False).encode("utf-8")
except Exception as e: except Exception as e:
log(f"{tag} model listesi degistirilemedi: {e}") log(f"{tag} model listesi degistirilemedi: {e}")
@@ -1547,6 +1772,16 @@ class H(http.server.BaseHTTPRequestHandler):
if hedef and hedef != gelen: if hedef and hedef != gelen:
req["model"] = slug = hedef req["model"] = slug = hedef
log(f"[{n} {gelen}] sohbet yonlendirmesi: istek {hedef} modeline gidiyor") log(f"[{n} {gelen}] sohbet yonlendirmesi: istek {hedef} modeline gidiyor")
try:
slug = karar_asamasi(n, req, sohbet, slug)
except Exception as e:
log(f"[{n} {slug}] karar asamasi atlandi: {type(e).__name__}: {e}")
try:
if slug == IDE_PREFIX + OTO_ADI[0]:
# Sanal ad gecide gitmez: varsayilan guclu hedef.
slug = req["model"] = IDE_PREFIX + jev_settings.DEFAULTS["autoRoute"]["targets"]["zor"]
except Exception:
pass
mapping = MODEL_MAP.get(slug) mapping = MODEL_MAP.get(slug)
if CFG.get("nokta_karsila", True) and nokta_mesaji_mi(req) and model_gecis.tur_ortasi(req, model_gecis.son_sira(req["input"])): if CFG.get("nokta_karsila", True) and nokta_mesaji_mi(req) and model_gecis.tur_ortasi(req, model_gecis.son_sira(req["input"])):
# Calisan turun icine dusen nokta secimi tasimaz (olcum 10 Ekim 2026) ve cevaplanirsa isi yarida # Calisan turun icine dusen nokta secimi tasimaz (olcum 10 Ekim 2026) ve cevaplanirsa isi yarida
@@ -1571,7 +1806,7 @@ class H(http.server.BaseHTTPRequestHandler):
if CFG.get("sesle_model", True) and CFG.get("model_bilgi", True) and sohbet and model_gecis.bilgi_ekle(req): if CFG.get("sesle_model", True) and CFG.get("model_bilgi", True) and sohbet and model_gecis.bilgi_ekle(req):
govde_degisti = True govde_degisti = True
if is_hatti.masaustu_mu(kaynak) and is_hatti.ekle( if is_hatti.masaustu_mu(kaynak) and is_hatti.ekle(
req, is_hatti.talimat(CFG.get("aiturk_hat"), HAT_KOMUT[0], sohbet)): req, is_hatti.talimat(hat_ayari(), HAT_KOMUT[0], sohbet)):
govde_degisti = True govde_degisti = True
# Ek modelde yerlesik web aramasi yok (flatten_request atar): aramayi kabukla yapan komut soylenir. # Ek modelde yerlesik web aramasi yok (flatten_request atar): aramayi kabukla yapan komut soylenir.
if internet.ekle(req, internet.talimat(CFG.get("internet"), os.path.join(HERE, "ara.py"))): if internet.ekle(req, internet.talimat(CFG.get("internet"), os.path.join(HERE, "ara.py"))):
@@ -1733,15 +1968,25 @@ class H(http.server.BaseHTTPRequestHandler):
open(os.path.join(LOGDIR, f"{n:04d}_req.json"), "wb").write(data) open(os.path.join(LOGDIR, f"{n:04d}_req.json"), "wb").write(data)
hdrs = {"Authorization": f"Bearer {route['key']}", "Content-Type": "application/json", "Accept": "text/event-stream", "User-Agent": UA} hdrs = {"Authorization": f"Bearer {route['key']}", "Content-Type": "application/json", "Accept": "text/event-stream", "User-Agent": UA}
last = None last = None
# Katman denetimi (jev_denetim.py): Sesli-Hizli-Zeki dışında ek modeller Jev'e sorulur; gecmezse adim # Katman denetimi (jev_denetim.py): muaf listesi disindaki ek modeller Jev'e sorulur; gecmezse adim
# sirayla Katman-1 -> 2 -> 3 -> 4 yapar (secili model zaten katmansa ondan sonrakiler). Katman-4 sorulmadan # ayardaki zinciri izler (14 Ekim 2026: Katman-1 -> 2 -> 3; secili model zaten katmansa ondan sonrakiler).
# gecer. Katmanlar OmniRoute combolaridir: yedek her zaman AITURK gecidi (ide_route) uzerinden acilir, # Muaf katman sorulmadan gecer. Katmanlar OmniRoute combolaridir: yedek her zaman AITURK gecidi (ide_route) uzerinden acilir,
# secili model baska bir rotada olsa bile. Gecit yoksa denetim yapilmaz. Ozet isteginde ve aracsiz # secili model baska bir rotada olsa bile. Gecit yoksa denetim yapilmaz. Ozet isteginde ve aracsiz
# (rewriter yok) yolda kapalidir. # (rewriter yok) yolda kapalidir.
jev_rota = ROUTES.get(ide_route) jev_rota = ROUTES.get(ide_route)
jev_etkin = jev_denetim.etkin_mi() and not (ozet_tetik or rewriter is None or jev_rota is None or not jev_denetim.anahtar()) try:
jev_sira = jev_denetim.sonrakiler(target) if jev_etkin else [] jev_etkin = jev_denetim.etkin_mi() and not (ozet_tetik or rewriter is None or jev_rota is None or not jev_denetim.anahtar())
jev_istek = jev_denetim.son_istek(req) if jev_etkin else "" jev_sira = jev_denetim.sonrakiler(target) if jev_etkin else []
jev_istek = jev_denetim.son_istek(req) if jev_etkin else ""
except Exception as e:
# Okunamayan ayar istegi dusurmez: bu adim denetimsiz gecer.
log(f"{tag} katman denetimi ayari okunamadi, denetimsiz devam: {type(e).__name__}: {e}")
jev_etkin, jev_sira, jev_istek = False, [], ""
if jev_sira:
try:
jev_sira = sigan_katmanlar(jev_sira, req, tag)
except Exception as e:
log(f"{tag} katman pencere kontrolu atlandi: {type(e).__name__}: {e}")
def jev_ac(ad): def jev_ac(ad):
katman_req = json.loads(data) katman_req = json.loads(data)
@@ -1753,6 +1998,36 @@ class H(http.server.BaseHTTPRequestHandler):
timeout=UPSTREAM_IDLE_TIMEOUT) timeout=UPSTREAM_IDLE_TIMEOUT)
return (yanit, SseRewriter(custom, hedef=ad, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren, tool_names=tool_names), return (yanit, SseRewriter(custom, hedef=ad, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren, tool_names=tool_names),
f"[{n} {slug}=>{ad}]") f"[{n} {slug}=>{ad}]")
def jev_devam(ad, onceki):
"""finishCheck: ayni hedefe, erken biten cevap ve not eklenmis istek. Yalniz AITURK gecidindeki hedefler.
Turda en fazla bir kez (bitis_kullan)."""
if ad == target and route_name != ide_route:
raise ValueError("hedef AITURK gecidinde degil")
if not bitis_kullan(bitis_turu):
raise ValueError("bu turda zaten geri verildi")
devam_req = json.loads(data)
devam_req["model"] = ad
devam_req.setdefault("input", []).extend([
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": onceki}]},
{"type": "message", "role": "user", "content": [
{"type": "input_text", "text": jev_settings.read()[0]["finishCheck"]["note"]}]}])
jev_hdrs = {"Authorization": f"Bearer {jev_rota['key']}", "Content-Type": "application/json",
"Accept": "text/event-stream", "User-Agent": UA}
yanit = urllib.request.urlopen(urllib.request.Request(
jev_rota["url"], data=json.dumps(devam_req, ensure_ascii=False).encode("utf-8"), headers=jev_hdrs, method="POST"),
timeout=UPSTREAM_IDLE_TIMEOUT)
return (yanit, SseRewriter(custom, hedef=ad, tasma=self._tasma_ogren, aracsiz=self._aracsiz_ogren, tool_names=tool_names),
f"[{n} {slug}=>{ad} devam]")
try:
bitis_turu = tur_bilgisi(req, sohbet)[1]
jev_bitis = jev_devam if jev_etkin and jev_settings.read()[0]["finishCheck"]["enabled"] else None
if jev_bitis is not None:
with KARAR_KILIDI:
if bitis_turu in BITIS_TURLARI:
jev_bitis = None # bu turda bir kez geri verildi
except Exception:
jev_bitis = None
# A 9Router combo (Fusion-*, no provider prefix) runs a whole panel upstream. Once it may # A 9Router combo (Fusion-*, no provider prefix) runs a whole panel upstream. Once it may
# have started (timeout, 504, cut stream) resending it runs and bills it twice (28 Sep 2026); # have started (timeout, 504, cut stream) resending it runs and bills it twice (28 Sep 2026);
# only failures before the work starts (no connection, 500/502/503/529) are retried. # only failures before the work starts (no connection, 500/502/503/529) are retried.
@@ -1803,7 +2078,7 @@ class H(http.server.BaseHTTPRequestHandler):
time.sleep(min(2 * attempt, 8)); continue time.sleep(min(2 * attempt, 8)); continue
# Only the resolved exact voice target owns this sideband protocol. # Only the resolved exact voice target owns this sideband protocol.
# Other models retain their original response/Jev/stream pipeline. # Other models retain their original response/Jev/stream pipeline.
if target == 'Sesli-Hizli-Zeki': if jev_denetim.izleme_mi(target):
# Capture was taken before rewriting/request dispatch. Only this exact # Capture was taken before rewriting/request dispatch. Only this exact
# route credential may authenticate the returned public receipt. # route credential may authenticate the returned public receipt.
correlation = self.watch_correlation correlation = self.watch_correlation
@@ -1825,7 +2100,9 @@ class H(http.server.BaseHTTPRequestHandler):
log(f"{tag} ozet kopru: {akis.sonuc_notu()}") log(f"{tag} ozet kopru: {akis.sonuc_notu()}")
return sonuc return sonuc
if jev_etkin and resp.status == 200: if jev_etkin and resp.status == 200:
return self._denetimli_akis(resp, tag, rewriter, izle, jev_istek, jev_sira, jev_ac, hedef=target) # devam yalniz finishCheck acikken gecer; kapaliyken cagri eskisiyle birebir aynidir.
return self._denetimli_akis(resp, tag, rewriter, izle, jev_istek, jev_sira, jev_ac, hedef=target,
**({"devam": jev_bitis} if jev_bitis else {}))
return self._stream(resp, resp.status, tag, rewriter, izle) return self._stream(resp, resp.status, tag, rewriter, izle)
if jev_etkin: # secili modele ulasilamadi: adimi siradaki katman yapar if jev_etkin: # secili modele ulasilamadi: adimi siradaki katman yapar
return self._denetimli_akis(None, tag, rewriter, izle, jev_istek, jev_sira, jev_ac, hedef=target) return self._denetimli_akis(None, tag, rewriter, izle, jev_istek, jev_sira, jev_ac, hedef=target)
@@ -1842,13 +2119,24 @@ class WatchResponse:
def __init__(self, response, accept): def __init__(self, response, accept):
self.response, self.filter = response, MetadataFilter(accept) self.response, self.filter = response, MetadataFilter(accept)
self.done = False self.done = False
self.gecis = False # suzgec tasti (cok buyuk cerceve): kalan akis oldugu gibi gecer
def __getattr__(self, name): def __getattr__(self, name):
return getattr(self.response, name) return getattr(self.response, name)
def read1(self, size): def read1(self, size):
if self.gecis:
return self.response.read1(size)
while not self.done: while not self.done:
raw = self.response.read1(size) raw = self.response.read1(size)
if raw: if raw:
data = self.filter.feed(raw) try:
data = self.filter.feed(raw)
except ValueError:
# 256 KB'i asan tek cerceve: akisi dusurmek yerine suzgecsiz devam (yan kanal cercevesi
# bu boyda olmaz). Tamponda yan kanal varsa disari cikmaz.
data, self.filter.frames.buf = self.filter.frames.buf, b''
self.gecis = True
log("izleme suzgeci: cok buyuk SSE cercevesi, kalan akis suzgecsiz geciyor")
return b'' if b'bekci.watch' in data else data
if data: return data if data: return data
else: else:
self.done = True self.done = True
@@ -0,0 +1,119 @@
"""Transport-only handling of a worker's explicit terminal failure.
Never selects a model, evaluates content, or changes worker/Jev decisions.
If HTTP headers have already gone out, remember the failure for exactly one
identical retry. Codex then receives an HTTP error instead of re-running the
whole exhausted provider chain. Ordinary network interruptions remain retries.
"""
import hashlib
import json
import threading
import time
from collections import OrderedDict
def request_key(request, headers):
principal = "\0".join(headers.get(name, "") for name in
("Authorization", "ChatGPT-Account-Id", "session_id"))
body = json.dumps(request, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
return hashlib.sha256((principal + "\0" + body).encode()).digest()
class FailureReplays:
def __init__(self, ttl=120, capacity=128, clock=time.monotonic):
self.ttl, self.capacity, self.clock = ttl, capacity, clock
self.items = OrderedDict()
self.lock = threading.Lock()
def remember(self, key, error):
with self.lock:
self.items[key] = (self.clock() + self.ttl, error)
self.items.move_to_end(key)
while len(self.items) > self.capacity:
self.items.popitem(last=False)
def take(self, key):
with self.lock:
entry = self.items.pop(key, None)
return entry[1] if entry and entry[0] > self.clock() else None
def error_body(error):
# Preserve the actual provider failure; don't relabel it invalid_prompt,
# fabricate response.completed, or expose a rejected model response.
return {"error": {"type": "upstream_terminal_error", "code": error["code"],
"message": "Model yanıtı hata ile sonlandı. " + error["message"]}}
class Observer:
"""Bounded SSE observer. Forwarded stream bytes are never rewritten."""
LIMIT = 65536
def __init__(self):
self.tail = b""
self.data = []
self.size = 0
self.discard = False
self.discard_line = False
self.comment_seen = False
self.payload_seen = False
self.completed = False
self.error = None
def feed(self, chunk):
# read1() supplies small chunks, but also bound arbitrary caller input.
for offset in range(0, len(chunk), 4096):
self.tail += chunk[offset:offset + 4096]
while b"\n" in self.tail:
line, self.tail = self.tail.split(b"\n", 1)
if self.discard_line:
self.discard_line = False
continue
self._line(line.rstrip(b"\r"))
if len(self.tail) > self.LIMIT:
self.tail = b""
self.discard = self.discard_line = True
def _line(self, line):
if line.startswith(b":"):
self.comment_seen = True
elif line.startswith(b"data:"):
self.size += len(line)
if self.size > self.LIMIT:
self.discard = True
self.data.clear()
elif not self.discard:
self.data.append(line[5:].lstrip())
elif not line:
if self.data and not self.discard:
self._event(b"\n".join(self.data))
elif self.discard:
self.payload_seen = True
self.data, self.size, self.discard = [], 0, False
def _event(self, data):
try:
event = json.loads(data)
except (ValueError, UnicodeError):
self.payload_seen = True
return
if not isinstance(event, dict):
self.payload_seen = True
return
kind = event.get("type")
response = event.get("response")
error = event.get("error") if kind == "error" else response.get("error") if isinstance(response, dict) else None
# Known worker terminal errors only. Preserve context/rate-limit and
# other native error classifications rather than guessing semantics.
is_failure = (kind == "error" and isinstance(error, dict)
and error.get("type") == "upstream_error")
is_failure |= (kind == "response.failed" and isinstance(error, dict)
and error.get("code") == "jev_no_fallback")
if is_failure:
self.error = {"code": str(error.get("code") or "upstream_error")[:100],
"message": str(error.get("message") or "Sağlayıcı yanıtı tamamlanamadı.")[:2000]}
else:
self.payload_seen = True
if kind == "response.completed":
self.completed = True
self.error = None
@@ -0,0 +1,83 @@
"""Codex secicisinde gorunen liste: kullanicinin one aldigi, gizledigi modeller ve 100'luk secici siniri."""
import unittest
import ide_catalog as c
def liste(*slugs, ozgun=("gpt-6-sol", "gpt-6-astra")):
models = [{"slug": s, "display_name": s, "priority": i} for i, s in enumerate(ozgun)]
models += [{"slug": "ide/" + s, "display_name": s, "priority": 0} for s in slugs]
extras = [{"slug": "ide/" + s, "kind": "combo" if s.startswith("Takim") else "model"} for s in slugs]
return models, extras
class CodexListe(unittest.TestCase):
def test_no_setting_keeps_the_old_behaviour(self):
models, extras = liste("agnes-agnes-2.0-flash", "agnes-agnes-2.0-flash-high", "gemini-gemini-2.5-flash-preview-tts")
self.assertIsNone(c.sirala_codex(models, extras))
self.assertEqual(len(models), 5)
def test_default_hides_media_and_effort_copies_whose_base_is_listed(self):
models, extras = liste("agnes-agnes-2.0-flash", "agnes-agnes-2.0-flash-high", "agy-gemini-3.8-flash-high",
"gemini-gemini-2.5-flash-preview-tts", "gemini-gemini-3.1-flash-image", "gemini-aqa",
"claude-claude-sonnet-5-5")
sonuc = c.sirala_codex(models, extras, (), {})
slugs = [m["slug"] for m in models]
self.assertNotIn("ide/agnes-agnes-2.0-flash-high", slugs) # ana model listede: kopya gizlenir
self.assertIn("ide/agy-gemini-3.8-flash-high", slugs) # ana modeli yok: kalir
self.assertFalse([s for s in slugs if "tts" in s or "image" in s or "aqa" in s])
nedenler = {a["slug"]: a["neden"] for a in sonuc["adaylar"]}
self.assertEqual(nedenler["ide/agnes-agnes-2.0-flash-high"], "efor")
self.assertEqual(nedenler["ide/gemini-aqa"], "medya")
self.assertEqual(nedenler["ide/claude-claude-sonnet-5-5"], "")
self.assertEqual(sonuc["ozgun_sayisi"], 2)
def test_switches_off_show_everything(self):
models, extras = liste("agnes-agnes-2.0-flash", "agnes-agnes-2.0-flash-high", "gemini-gemini-2.5-flash-preview-tts")
c.sirala_codex(models, extras, (), {"medya_gizle": False, "efor_gizle": False})
self.assertEqual(len(models), 5)
def test_pinned_models_follow_the_subscription_models_in_the_given_order(self):
models, extras = liste("agnes-a", "claude-claude-opus-5-5", "Takim-Hizli", "nanogpt-glm-uncensored", "zeta-x")
originals = [dict(m) for m in models if not m["slug"].startswith("ide/")]
sonuc = c.sirala_codex(models, extras, (), {"one_al": ["ide/nanogpt-glm-uncensored", "ide/claude-claude-opus-5-5", "ide/yok"]})
self.assertEqual([m["slug"] for m in models[:4]],
["gpt-6-sol", "gpt-6-astra", "ide/nanogpt-glm-uncensored", "ide/claude-claude-opus-5-5"])
self.assertEqual([m for m in models if not m["slug"].startswith("ide/")], originals)
self.assertEqual([m["priority"] for m in models[2:]], list(range(2, 2 + len(models) - 2)))
sira = {a["slug"]: a["sira"] for a in sonuc["adaylar"]}
self.assertEqual(sira["ide/nanogpt-glm-uncensored"], 2)
def test_hidden_models_leave_and_a_pin_overrides_the_automatic_filters(self):
models, extras = liste("agnes-a", "agnes-a-high", "gemini-veo-3", "claude-x")
c.sirala_codex(models, extras, (), {"gizli": ["ide/claude-x"], "one_al": ["ide/agnes-a-high", "ide/gemini-veo-3"]})
slugs = [m["slug"] for m in models]
self.assertNotIn("ide/claude-x", slugs)
self.assertEqual(slugs[2:4], ["ide/agnes-a-high", "ide/gemini-veo-3"])
def test_menu_limit_counts_the_subscription_models_first(self):
slugs = ["model-%03d" % i for i in range(120)]
models, extras = liste(*slugs)
sonuc = c.sirala_codex(models, extras, (), {"one_al": ["ide/model-119"]})
aday = {a["slug"]: a for a in sonuc["adaylar"]}
self.assertTrue(aday["ide/model-119"]["menude"]) # one alindi: 3. sirada
self.assertEqual(aday["ide/model-119"]["sira"], 2)
menude = [a for a in sonuc["adaylar"] if a["menude"]]
self.assertEqual(len(menude), 100 - 2) # 2 ozgun + 98 ek model = 100
self.assertFalse(aday["ide/model-118"]["menude"])
def test_broken_setting_falls_back_to_defaults(self):
self.assertEqual(c.liste_ayari("bozuk"), {"medya_gizle": True, "efor_gizle": True, "one_al": [], "gizli": []})
self.assertEqual(c.liste_ayari({"one_al": ["a", "a", 3, " "], "gizli": "x"})["one_al"], ["a"])
def test_media_detection_does_not_catch_chat_models(self):
for sohbet in ("ide/claude-claude-opus-5-5", "ide/agnes-agnes-2.5-flash", "ide/gemini-gemini-3.8-flash",
"ide/nanogpt-z-ai-glm-5.3-flash-uncensored", "ide/bekci-sesli-bekci", "ide/Chat-Model"):
self.assertFalse(c.medya_mi(sohbet), sohbet)
for medya in ("ide/agnes-agnes-video-2.5", "ide/gemini-lyria-3-clip-preview", "ide/gemini-gemini-embedding-001",
"ide/gemini-gemini-3.5-transcribe", "ide/gemini-nano-banana-pro-preview", "ide/agnes-agnes-image-2.5-flash"):
self.assertTrue(c.medya_mi(medya), medya)
if __name__ == "__main__":
unittest.main()
@@ -457,6 +457,7 @@ class Liste(unittest.TestCase):
env = {"WATCH_OWNER": None, "WatchResponse": lambda response, accept: response, "json": json, "copy": copy, "hashlib": hashlib, "time": time, "log": lambda m: None, "CFG": {}, env = {"WATCH_OWNER": None, "WatchResponse": lambda response, accept: response, "json": json, "copy": copy, "hashlib": hashlib, "time": time, "log": lambda m: None, "CFG": {},
"decompress": lambda raw, enc: raw, "EXTRA_MODELS": [], "IDE_PREFIX": "ide/", "SLIM_CLONES": False, "decompress": lambda raw, enc: raw, "EXTRA_MODELS": [], "IDE_PREFIX": "ide/", "SLIM_CLONES": False,
"SLIM_FIELDS": [], "ide_catalog": ide_catalog, "LAST_MODELS": {}, "save_last_models": Mock(), "SLIM_FIELDS": [], "ide_catalog": ide_catalog, "LAST_MODELS": {}, "save_last_models": Mock(),
"liste_ayari": lambda: {}, "aday_kaydet": Mock(),
"RESP_HOP": {"content-length", "content-encoding"}, "REQ_HOP": {"host"}, "CHATGPT": "https://example.invalid", "RESP_HOP": {"content-length", "content-encoding"}, "REQ_HOP": {"host"}, "CHATGPT": "https://example.invalid",
"UPSTREAM_IDLE_TIMEOUT": 1, "UPSTREAM_IDLE_TIMEOUT": 1,
"urllib": types.SimpleNamespace(request=types.SimpleNamespace(Request=urllib.request.Request, urlopen=Mock()), "urllib": types.SimpleNamespace(request=types.SimpleNamespace(Request=urllib.request.Request, urlopen=Mock()),
@@ -11,24 +11,41 @@ class Hat(unittest.TestCase):
self.assertFalse(h.masaustu_mu(other), other) self.assertFalse(h.masaustu_mu(other), other)
def test_no_instruction_when_switched_off_or_without_a_send_command(self): def test_no_instruction_when_switched_off_or_without_a_send_command(self):
self.assertIsNone(h.talimat(None)) self.assertIsNone(h.talimat({"secim": "buyuk"}, zamanla=""))
self.assertIsNone(h.talimat({})) self.assertIsNone(h.talimat({"secim": "buyuk", "komut": " "}, zamanla="z"))
self.assertIsNone(h.talimat({"enabled": True, "komut": " "})) self.assertIsNone(h.talimat({"secim": "kapali", "komut": "x"}, "C:/a.exe", zamanla="z"))
self.assertIsNone(h.talimat({"enabled": False, "komut": "aiturk gonder"})) self.assertIsNone(h.talimat({"enabled": False, "komut": "aiturk gonder"}))
self.assertIsNone(h.talimat({"enabled": False}, "C:/a/aiturk.exe", "s1")) self.assertIsNone(h.talimat({"enabled": False}, "C:/a/aiturk.exe", "s1"))
text = h.talimat({"enabled": True, "komut": "aiturk gonder --dosya is.md"}) text = h.talimat({"secim": "buyuk", "komut": "aiturk gonder --dosya is.md"})
self.assertIn("aiturk gonder --dosya is.md", text) self.assertIn("aiturk gonder --dosya is.md", text)
self.assertIn("sen yap", text) self.assertIn("sen yap", text)
self.assertEqual(h.talimat({"enabled": True, "komut": "x", "talimat": "ozel {komut}"}), "ozel x") self.assertEqual(h.talimat({"enabled": True, "komut": "x", "talimat": "ozel {komut}"}), "ozel x")
def test_on_by_default_once_a_send_command_exists_on_this_computer(self): def test_on_by_default_once_a_send_command_exists_on_this_computer(self):
text = h.talimat(None, "C:/Program/aiturk.exe", "019a-bc_d") text = h.talimat({"secim": "buyuk"}, "C:/Program/aiturk.exe", "019a-bc_d")
self.assertIn('"C:/Program/aiturk.exe" is --sohbet 019a-bc_d --dosya', text) self.assertIn('"C:/Program/aiturk.exe" is --sohbet 019a-bc_d --dosya', text)
self.assertIn('"C:/Program/aiturk.exe" is --dosya', h.talimat({}, "C:/Program/aiturk.exe", "")) self.assertIn('"C:/Program/aiturk.exe" is --dosya', h.talimat({"talimat": h.ESKI}, "C:/Program/aiturk.exe", ""))
# Sohbet kimligi komut satirina yazilir: kabuk karakteri tasiyamaz. # Sohbet kimligi komut satirina yazilir: kabuk karakteri tasiyamaz.
self.assertIn("--sohbet abrm-rf ", h.komut_satiri("x", 'a"b; rm -rf /')) self.assertIn("--sohbet abrm-rf ", h.komut_satiri("x", 'a"b; rm -rf /'))
# Ayardaki komut bulunanin onune gecer. # Ayardaki komut bulunanin onune gecer.
self.assertIn("elle", h.talimat({"komut": "elle"}, "C:/Program/aiturk.exe")) self.assertIn("elle", h.talimat({"secim": "buyuk", "komut": "elle"}, "C:/Program/aiturk.exe"))
def test_default_sends_repeating_work_to_aiturk_and_delegates_the_rest(self):
z = h.zamanla_satiri({"HERMES_HOME": "C:/ide/agent"})
self.assertIn("cron create", z)
self.assertIn("$env:HERMES_HOME='C:/ide/agent'", z)
self.assertEqual(h.zamanla_satiri({}), "")
for ayar in (None, {}, {"secim": "onerilen"}, {"secim": "bilinmeyen"}):
text = h.talimat(ayar, "C:/a.exe", "s1", zamanla="")
self.assertIn('"C:/a.exe" is --sohbet s1', text)
self.assertIn("Kodlama ve diğer işleri AITURK'e verme", text)
self.assertIn("alt ajan", text)
self.assertNotIn("kendin yap", text)
self.assertNotIn("{", text)
self.assertIsNone(h.talimat(None, None, zamanla=z))
self.assertEqual(h.talimat({"secim": "ozel", "talimat": "kendi kuralim"}, zamanla=""), "kendi kuralim")
self.assertIn("ozel z", h.talimat({"secim": "ozel", "talimat": "ozel {zamanla}"}, zamanla="z"))
self.assertIn("alt ajan", h.talimat({"secim": "ozel", "talimat": " "}, "C:/a.exe"))
def test_the_first_candidate_that_knows_the_command_is_used(self): def test_the_first_candidate_that_knows_the_command_is_used(self):
import os import os
@@ -58,7 +75,7 @@ class Hat(unittest.TestCase):
def test_instruction_is_put_first_once_and_never_repeated(self): def test_instruction_is_put_first_once_and_never_repeated(self):
req = {"input": [{"type": "message", "role": "user", "content": "selam"}]} req = {"input": [{"type": "message", "role": "user", "content": "selam"}]}
text = h.talimat({"enabled": True, "komut": "aiturk gonder"}) text = h.talimat({"secim": "buyuk", "komut": "aiturk gonder"})
self.assertTrue(h.ekle(req, text)) self.assertTrue(h.ekle(req, text))
self.assertEqual((req["input"][0]["role"], len(req["input"])), ("developer", 2)) self.assertEqual((req["input"][0]["role"], len(req["input"])), ("developer", 2))
self.assertFalse(h.ekle(req, text)) self.assertFalse(h.ekle(req, text))
@@ -1,5 +1,7 @@
"""Layer check (11 Oct 2026): Jev passes or blocks each step of Katman-1..3; a blocked step is redone by the next """Layer check (11 Oct 2026): Jev passes or blocks each step of Katman-1..3; a blocked step is redone by the next
layer. Katman-4 is never asked. No exceptions.""" layer. The last layer is never asked. No exceptions.
14 Oct 2026 defaults: Katman-1 -> Katman-2 -> Katman-3; Katman-3, Chat-Model and bekci-sesli-bekci always pass."""
import ast import ast
import io import io
import json import json
@@ -60,18 +62,19 @@ class Karar(unittest.TestCase):
class Katman(unittest.TestCase): class Katman(unittest.TestCase):
def test_a_non_layer_model_falls_through_every_layer_from_the_top(self): def test_a_non_layer_model_falls_through_every_layer_from_the_top(self):
self.assertEqual(jev_denetim.sonrakiler("Uncensored-Uygulama"), self.assertEqual(jev_denetim.sonrakiler("Uncensored-Uygulama"), ["Katman-1", "Katman-2", "Katman-3"])
["Katman-1", "Katman-2", "Katman-3", "Katman-4"]) self.assertEqual(jev_denetim.sonrakiler("ide/some-model"), ["Katman-1", "Katman-2", "Katman-3"])
self.assertEqual(jev_denetim.sonrakiler("ide/some-model"),
["Katman-1", "Katman-2", "Katman-3", "Katman-4"])
def test_a_selected_layer_only_tries_the_ones_after_it(self): def test_a_selected_layer_only_tries_the_ones_after_it(self):
self.assertEqual(jev_denetim.sonrakiler("Katman-1"), ["Katman-2", "Katman-3", "Katman-4"]) self.assertEqual(jev_denetim.sonrakiler("Katman-1"), ["Katman-2", "Katman-3"])
self.assertEqual(jev_denetim.sonrakiler("Katman-2"), ["Katman-3", "Katman-4"]) self.assertEqual(jev_denetim.sonrakiler("Katman-2"), ["Katman-3"])
self.assertEqual(jev_denetim.sonrakiler("Katman-3"), ["Katman-4"]) # The always-pass last layer only uses the general fallback on transport failure; no revisits.
self.assertEqual(jev_denetim.sonrakiler("Katman-3"), ["Katman-1", "Katman-2"])
def test_last_layer_is_never_asked(self): def test_last_layer_is_never_asked(self):
self.assertTrue(jev_denetim.muaf_mi("Katman-4")) self.assertTrue(jev_denetim.muaf_mi("Katman-3"))
self.assertTrue(jev_denetim.muaf_mi("Chat-Model"))
self.assertFalse(jev_denetim.muaf_mi("Katman-4"))
self.assertEqual(jev_denetim.sonrakiler(None), []) self.assertEqual(jev_denetim.sonrakiler(None), [])
@@ -139,7 +142,7 @@ class Akis(unittest.TestCase):
self.h.send_response = Mock() self.h.send_response = Mock()
self.h.send_header = Mock() self.h.send_header = Mock()
self.h.end_headers = Mock() self.h.end_headers = Mock()
self.cevap = {"Katman-1": sse("bir"), "Katman-2": sse("iki"), "Katman-3": sse("uc"), "Katman-4": sse("dort")} self.cevap = {"Katman-1": sse("bir"), "Katman-2": sse("iki"), "Katman-3": sse("uc")}
def sor(self, istek, metin, key): def sor(self, istek, metin, key):
self.asked.append(metin) self.asked.append(metin)
@@ -162,7 +165,7 @@ class Akis(unittest.TestCase):
raw = rest[n + 2:] raw = rest[n + 2:]
return out return out
def calistir(self, body, sira=("Katman-2", "Katman-3", "Katman-4")): def calistir(self, body, sira=("Katman-2", "Katman-3")):
self.h._denetimli_akis(Yanit(body), "[1]", None, None, "yap", list(sira), self.ac) self.h._denetimli_akis(Yanit(body), "[1]", None, None, "yap", list(sira), self.ac)
def test_a_passed_step_is_delivered_unchanged(self): def test_a_passed_step_is_delivered_unchanged(self):
@@ -176,7 +179,7 @@ class Akis(unittest.TestCase):
body = sse("Bunu yapamam.") body = sse("Bunu yapamam.")
self.puanlar = [{"ret": 0.9, "sart": 0.0}] self.puanlar = [{"ret": 0.9, "sart": 0.0}]
self.h._denetimli_akis(Yanit(body), "[1]", None, None, "yap", self.h._denetimli_akis(Yanit(body), "[1]", None, None, "yap",
list(jev_denetim.KATMAN), self.ac, hedef="Sesli-Hizli-Zeki") list(jev_denetim.KATMAN), self.ac, hedef="Chat-Model")
self.assertEqual(self.sent(), body) self.assertEqual(self.sent(), body)
self.assertEqual(self.asked, []) self.assertEqual(self.asked, [])
self.assertEqual(self.opened, []) self.assertEqual(self.opened, [])
@@ -184,14 +187,13 @@ class Akis(unittest.TestCase):
def test_voice_transport_failure_still_checks_the_fallback_model(self): def test_voice_transport_failure_still_checks_the_fallback_model(self):
self.puanlar = [{"ret": 0.9, "sart": 0.0}] self.puanlar = [{"ret": 0.9, "sart": 0.0}]
self.h._denetimli_akis(None, "[1]", None, None, "yap", self.h._denetimli_akis(None, "[1]", None, None, "yap",
list(jev_denetim.KATMAN), self.ac, hedef="Sesli-Hizli-Zeki") list(jev_denetim.KATMAN), self.ac, hedef="Chat-Model")
self.assertEqual(self.opened, ["Katman-1", "Katman-2"]) self.assertEqual(self.opened, ["Katman-1", "Katman-2"])
self.assertEqual(self.asked, ["bir", "iki"]) self.assertEqual(self.asked, ["bir", "iki"])
self.assertEqual(self.sent(), self.cevap["Katman-2"]) self.assertEqual(self.sent(), self.cevap["Katman-2"])
def test_voice_lookalikes_are_still_checked(self): def test_voice_lookalikes_are_still_checked(self):
for target in ("sesli-hizli-zeki", "Sesli-Hizli-Zeki-v2", "ide/Sesli-Hizli-Zeki", for target in ("chat-model", "Chat-Model-v2", "ide/Chat-Model", "Chat-Model ", "Other-Combo"):
"Sesli-Hizli-Zeki ", "Other-Combo"):
with self.subTest(target=target): with self.subTest(target=target):
self.setUp() self.setUp()
self.puanlar = [{"ret": 0.9, "sart": 0.0}] self.puanlar = [{"ret": 0.9, "sart": 0.0}]
@@ -202,7 +204,7 @@ class Akis(unittest.TestCase):
def test_a_blocked_non_layer_model_goes_to_layer_1_first(self): def test_a_blocked_non_layer_model_goes_to_layer_1_first(self):
self.puanlar = [{"ret": 0.9, "sart": 0.0}] # the selected (non-layer) model is blocked self.puanlar = [{"ret": 0.9, "sart": 0.0}] # the selected (non-layer) model is blocked
self.calistir(sse("Bunu yapamam."), sira=("Katman-1", "Katman-2", "Katman-3", "Katman-4")) self.calistir(sse("Bunu yapamam."), sira=("Katman-1", "Katman-2", "Katman-3"))
self.assertEqual(self.sent(), self.cevap["Katman-1"]) self.assertEqual(self.sent(), self.cevap["Katman-1"])
self.assertEqual(self.opened, ["Katman-1"]) self.assertEqual(self.opened, ["Katman-1"])
self.assertEqual(self.asked, ["Bunu yapamam.", "bir"]) self.assertEqual(self.asked, ["Bunu yapamam.", "bir"])
@@ -216,17 +218,17 @@ class Akis(unittest.TestCase):
self.assertEqual(self.asked, ["Bunu yapamam.", "iki"]) self.assertEqual(self.asked, ["Bunu yapamam.", "iki"])
self.h.send_response.assert_called_once() self.h.send_response.assert_called_once()
def test_every_layer_blocked_ends_at_layer_4_unasked(self): def test_every_layer_blocked_ends_at_layer_3_unasked(self):
self.puanlar = [{"ret": 0.9, "sart": 0.0}] * 3 self.puanlar = [{"ret": 0.9, "sart": 0.0}] * 2
self.calistir(sse("hayir")) self.calistir(sse("hayir"))
self.assertEqual(self.sent(), self.cevap["Katman-4"]) self.assertEqual(self.sent(), self.cevap["Katman-3"])
self.assertEqual(self.opened, ["Katman-2", "Katman-3", "Katman-4"]) self.assertEqual(self.opened, ["Katman-2", "Katman-3"])
self.assertEqual(self.asked, ["hayir", "iki", "uc"]) # layer 4 is never asked self.assertEqual(self.asked, ["hayir", "iki"]) # the always-pass last layer is never asked
def test_blocked_layer_3_goes_straight_to_layer_4(self): def test_blocked_layer_2_goes_straight_to_layer_3(self):
self.puanlar = [{"ret": 0.0, "sart": 0.8}] self.puanlar = [{"ret": 0.0, "sart": 0.8}]
self.calistir(sse("kendi sartimla"), sira=("Katman-4",)) self.calistir(sse("kendi sartimla"), sira=("Katman-3",))
self.assertEqual(self.sent(), self.cevap["Katman-4"]) self.assertEqual(self.sent(), self.cevap["Katman-3"])
self.assertEqual(self.asked, ["kendi sartimla"]) self.assertEqual(self.asked, ["kendi sartimla"])
def test_a_layer_that_cannot_be_opened_is_skipped(self): def test_a_layer_that_cannot_be_opened_is_skipped(self):
@@ -329,9 +331,9 @@ class Akis(unittest.TestCase):
def test_when_no_layer_opens_the_blocked_answer_is_not_delivered(self): def test_when_no_layer_opens_the_blocked_answer_is_not_delivered(self):
self.puanlar = [{"ret": 0.9, "sart": 0.0}] self.puanlar = [{"ret": 0.9, "sart": 0.0}]
self.cevap.update({"Katman-2": None, "Katman-3": None, "Katman-4": None}) self.cevap.update({"Katman-2": None, "Katman-3": None})
self.calistir(sse("Bunu yapamam.")) self.calistir(sse("Bunu yapamam."))
self.assertEqual(self.opened, ["Katman-2", "Katman-3", "Katman-4"]) self.assertEqual(self.opened, ["Katman-2", "Katman-3"])
self.assertNotIn(b"yapamam", self.h.wfile.getvalue()) self.assertNotIn(b"yapamam", self.h.wfile.getvalue())
self.assertIn(b'response.failed', self.sent()) self.assertIn(b'response.failed', self.sent())
@@ -42,6 +42,28 @@ class Settings(unittest.TestCase):
self.save(rejectionRules=[{'from': 'A', 'to': 'B'}, {'from': 'B', 'to': 'A'}]) self.save(rejectionRules=[{'from': 'A', 'to': 'B'}, {'from': 'B', 'to': 'A'}])
self.assertEqual(jev_denetim.sonrakiler('A'), ['B']) self.assertEqual(jev_denetim.sonrakiler('A'), ['B'])
def test_stored_ide_prefix_on_model_names_is_stripped(self):
# A hand- or agent-edited settings file may store a model name carrying the gateway's own
# 'ide/' prefix. The bridge adds 'ide/' again when it routes, so a stored prefix would make
# 'ide/ide/...', an unknown model whose context window the bridge mis-reads before silently
# dropping the request. read() must strip the stray prefix so routing stays correct.
self.save(
fallbackModel='ide/abliteration-abliterated-model-large-v2',
exemptModels=['ide/claude-claude-opus-5-5', 'agnes-agnes-1.5-flash'],
prefilter={**self.settings['prefilter'], 'target': 'ide/ide/abliteration-abliterated-model-large-v2'},
)
settings, _ = jev_settings.read()
self.assertEqual(settings['fallbackModel'], 'abliteration-abliterated-model-large-v2')
self.assertEqual(settings['prefilter']['target'], 'abliteration-abliterated-model-large-v2')
self.assertEqual(settings['exemptModels'], ['claude-claude-opus-5-5', 'agnes-agnes-1.5-flash'])
self.assertTrue(jev_denetim.muaf_mi('claude-claude-opus-5-5'))
def test_strip_ide_leaves_normal_names_and_removes_only_prefix(self):
self.assertEqual(jev_settings._strip_ide('claude-claude-opus-5-5'), 'claude-claude-opus-5-5')
self.assertEqual(jev_settings._strip_ide('ide/x'), 'x')
self.assertEqual(jev_settings._strip_ide('ide/ide/x'), 'x')
self.assertEqual(jev_settings._strip_ide('provider-ide/x'), 'provider-ide/x')
def test_prompts_model_criteria_and_thresholds_are_applied(self): def test_prompts_model_criteria_and_thresholds_are_applied(self):
self.save(model='jev-custom', questions={'ret': 'question A', 'sart': 'question B'}, thresholds={'ret': .9, 'sart': .8}, criteria={'ret': {'true': 'bad', 'false': 'good'}, 'sart': {'true': '', 'false': ''}}) self.save(model='jev-custom', questions={'ret': 'question A', 'sart': 'question B'}, thresholds={'ret': .9, 'sart': .8}, criteria={'ret': {'true': 'bad', 'false': 'good'}, 'sart': {'true': '', 'false': ''}})
captured = [] captured = []
@@ -0,0 +1,550 @@
"""Decision stages (14 Oct 2026): settings schema 2, turn-start decisions, layer window filter, finish check
and Dify rules. Every new stage is off by default; the tests turn them on explicitly."""
import ast
import copy
import hashlib
import io
import json
import threading
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
import jev_denetim
import jev_settings
import karar_politikasi
import model_gecis
import pencere
import yarim
from test_jev_denetim import Yanit, method, sse, user
SOURCE = Path(__file__).with_name("kopru.py").read_text(encoding="utf-8")
TREE = ast.parse(SOURCE)
def fonksiyon(name):
return next(n for n in TREE.body if isinstance(n, ast.FunctionDef) and n.name == name)
class Ev(unittest.TestCase):
"""A temporary bridge home with a settings file."""
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.home = Path(self.tmp.name)
p = patch.object(jev_settings, 'home', return_value=self.home)
p.start()
self.addCleanup(p.stop)
self.settings = copy.deepcopy(jev_settings.DEFAULTS)
def save(self, settings=None, **extra):
data = {'settings': settings if settings is not None else self.settings}
data.update(extra)
(self.home / 'jev-settings.json').write_text(json.dumps(data), encoding='utf-8')
class Ayarlar(Ev):
def test_a_schema_1_file_gets_every_new_stage_disabled(self):
eski = {k: v for k, v in self.settings.items() if k not in jev_settings.SECTIONS}
self.save(eski)
s, _ = jev_settings.read()
for bolum in ('prefilter', 'autoRoute', 'finishCheck', 'verifierCheck'):
self.assertFalse(s[bolum]['enabled'], bolum)
self.assertEqual(s['policy']['source'], 'local')
self.assertEqual(s['watchModels'], jev_settings.DEFAULTS['watchModels'])
def test_a_partial_section_keeps_written_values_and_fills_the_rest(self):
self.settings['prefilter'] = {'enabled': True, 'threshold': 0.8}
self.save()
p = jev_settings.read()[0]['prefilter']
self.assertEqual((p['enabled'], p['threshold']), (True, 0.8))
self.assertEqual(p['target'], jev_settings.DEFAULTS['prefilter']['target'])
def test_bad_values_are_rejected(self):
for bolum, alan, deger in (('prefilter', 'threshold', 1.5), ('prefilter', 'timeoutSeconds', 0),
('autoRoute', 'model', ''), ('finishCheck', 'note', ''),
('policy', 'endpoint', 'http://dify.example'), ('policy', 'source', 'web')):
with self.subTest(bolum=bolum, alan=alan):
s = copy.deepcopy(self.settings)
s[bolum][alan] = deger
self.save(s)
with self.assertRaises(ValueError):
jev_settings.read()
def test_automatic_model_cannot_target_itself(self):
self.settings['autoRoute']['targets']['zor'] = 'Otomatik'
self.save()
with self.assertRaises(ValueError):
jev_settings.read()
def test_watch_channel_is_an_exact_name_from_settings(self):
self.settings['watchModels'] = ['Chat-Model']
self.save()
self.assertTrue(jev_denetim.izleme_mi('Chat-Model'))
for ad in ('chat-model', 'Chat-Model ', 'ide/Chat-Model', 'Sesli-Hizli-Zeki'):
self.assertFalse(jev_denetim.izleme_mi(ad), ad)
class Politika(Ev):
def test_dify_rules_apply_only_when_the_source_is_dify_and_they_are_valid(self):
(self.home / jev_settings.POLICY_FILE).write_text(json.dumps(
{'ok': True, 'overrides': {'prefilter': {'enabled': True, 'target': 'Katman-3'}}}), encoding='utf-8')
self.save()
self.assertFalse(jev_settings.read()[0]['prefilter']['enabled']) # source local
self.settings['policy']['source'] = 'dify'
self.save()
p = jev_settings.read()[0]['prefilter']
self.assertEqual((p['enabled'], p['target']), (True, 'Katman-3'))
self.assertFalse(jev_settings.read(overlay=False)[0]['prefilter']['enabled'])
(self.home / jev_settings.POLICY_FILE).write_text(json.dumps(
{'ok': True, 'overrides': {'prefilter': {'threshold': 7}}}), encoding='utf-8')
self.assertFalse(jev_settings.read()[0]['prefilter']['enabled']) # invalid rules: local in force
def test_rules_cannot_change_the_chain_or_unknown_sections(self):
s = jev_settings.read(overlay=False)[0]
with self.assertRaises(ValueError):
karar_politikasi.ayikla({'exemptModels': {'x': 1}}, s)
with self.assertRaises(ValueError):
karar_politikasi.ayikla({'autoRoute': {'targets': {'zor': 'Otomatik'}}}, s)
with self.assertRaises(ValueError):
karar_politikasi.ayikla('[1, 2]', s)
self.assertEqual(karar_politikasi.ayikla('{"autoRoute": {"enabled": true}}', s), {'autoRoute': {'enabled': True}})
def test_fetch_reads_the_kurallar_output_and_never_raises(self):
s = jev_settings.read(overlay=False)[0]
def yanit(govde):
r = Mock()
r.__enter__ = lambda self_: self_
r.__exit__ = lambda *a: False
r.read = lambda n=-1: json.dumps(govde).encode()
return r
istekler = []
def urlopen(req, timeout):
istekler.append(req)
return yanit({'data': {'outputs': {'kurallar': {'finishCheck': {'enabled': True}}}}})
sonuc = karar_politikasi.cek(s, 'app-key', urlopen=urlopen)
self.assertEqual(sonuc, {'ok': True, 'overrides': {'finishCheck': {'enabled': True}}})
self.assertTrue(istekler[0].full_url.endswith('/workflows/run'))
self.assertEqual(istekler[0].get_header('Authorization'), 'Bearer app-key')
self.assertFalse(karar_politikasi.cek(s, '', urlopen=urlopen)['ok'])
self.assertFalse(karar_politikasi.cek(s, 'k', urlopen=Mock(side_effect=OSError('kapali')))['ok'])
bos = karar_politikasi.cek(s, 'k', urlopen=lambda req, timeout: yanit({'data': {'outputs': {}}}))
self.assertEqual(bos['ok'], False)
def test_a_failed_fetch_keeps_the_last_good_rules(self):
jev_settings.write_policy({'ok': True, 'overrides': {'finishCheck': {'enabled': True}}})
kayit = jev_settings.write_policy({'ok': False, 'error': 'Dify kapali'})
self.assertTrue(kayit['ok'])
self.assertEqual(kayit['overrides'], {'finishCheck': {'enabled': True}})
self.assertEqual(kayit['error'], 'Dify kapali')
self.settings['policy']['source'] = 'dify'
self.save()
self.assertTrue(jev_settings.read()[0]['finishCheck']['enabled'])
class OnKarar(Ev):
def gonder(self, cevap):
self.govdeler = []
def f(govde, key):
self.govdeler.append(govde)
if isinstance(cevap, Exception):
raise cevap
return {'answers': {k: {'noul': v} for k, v in cevap.items()}}
return f
def test_automatic_routing_picks_research_then_hard_then_simple(self):
self.settings['autoRoute']['enabled'] = True
self.save()
t = self.settings['autoRoute']['targets']
for puan, hedef in (({'zor': 0.9, 'arastirma': 0.8}, t['arastirma']), ({'zor': 0.9, 'arastirma': 0.1}, t['zor']),
({'zor': 0.1, 'arastirma': 0.1}, t['basit'])):
sonuc = jev_denetim.on_karar('istek', True, False, 'k', gonder=self.gonder(puan))
self.assertEqual(sonuc['hedef'], hedef)
self.assertEqual(set(self.govdeler[0]['questions']), {'zor', 'arastirma'})
self.assertNotIn('assistant_reply', self.govdeler[0]['state'])
def test_no_decision_falls_to_the_strong_target(self):
self.settings['autoRoute']['enabled'] = True
self.save()
sonuc = jev_denetim.on_karar('istek', True, False, 'k', gonder=self.gonder(OSError('yok')))
self.assertEqual((sonuc['hedef'], sonuc['ret']), (self.settings['autoRoute']['targets']['zor'], None))
def test_refusal_question_goes_in_the_same_call(self):
self.settings['autoRoute']['enabled'] = True
self.settings['prefilter']['enabled'] = True
self.save()
sonuc = jev_denetim.on_karar('istek', True, True, 'k', gonder=self.gonder({'zor': 0.1, 'arastirma': 0.1, 'ret': 0.9}))
self.assertEqual(len(self.govdeler), 1)
self.assertEqual(set(self.govdeler[0]['questions']), {'zor', 'arastirma', 'ret'})
self.assertTrue(sonuc['ret'])
def test_a_missing_answer_is_no_decision(self):
self.settings['prefilter']['enabled'] = True
self.save()
sonuc = jev_denetim.on_karar('istek', False, True, 'k', gonder=self.gonder({}))
self.assertIsNone(sonuc['ret'])
class TurBasi(Ev):
"""kopru.karar_asamasi through its own source."""
def setUp(self):
super().setUp()
self.kararlar = []
self.logs = []
sahte_denetim = Mock(wraps=jev_denetim)
sahte_denetim.anahtar = lambda: 'k'
sahte_denetim.on_karar = self.on_karar
sahte_denetim.muaf_mi = jev_denetim.muaf_mi
sahte_denetim.sonrakiler = jev_denetim.sonrakiler
sahte_denetim.son_istek = jev_denetim.son_istek
self.cevap = {'hedef': None, 'ret': None, 'puan': None}
sahte_denetim.son_istek_sirasi = jev_denetim.son_istek_sirasi
sahte_denetim.istek_metni = jev_denetim.istek_metni
self.env = {'IDE_PREFIX': 'ide/', 'ON_KARAR': {}, 'ON_KARAR_SINIR': 3, 'jev_settings': jev_settings,
'jev_denetim': sahte_denetim, 'model_gecis': model_gecis, 'hashlib': hashlib, 'time': time,
'json': json, 'KARAR_KILIDI': threading.Lock(), 'BITIS_TURLARI': {}, 'log': self.logs.append}
govde = [fonksiyon(a) for a in ('tur_bilgisi', 'karar_asamasi', 'bitis_kullan')]
exec(compile(ast.Module(body=govde, type_ignores=[]), 'kopru.py', 'exec'), self.env)
self.karar = self.env['karar_asamasi']
def on_karar(self, istek, oto, ret, key):
self.kararlar.append((istek, oto, ret))
return dict(self.cevap)
def istek(self, model, *girdi):
return {'model': model, 'prompt_cache_key': 'sohbet-1', 'input': list(girdi)}
def test_everything_off_changes_nothing_and_asks_nothing(self):
self.save()
for model in ('ide/Ajan', 'ide/codex-gpt-6-astra', 'ide/Chat-Model'):
req = self.istek(model, user('yap'))
self.assertEqual(self.karar(1, req, 'sohbet-1', model), model)
self.assertEqual(req['model'], model)
self.assertEqual(self.kararlar, [])
def test_automatic_choice_is_kept_for_the_tool_steps_of_the_same_turn(self):
self.settings['autoRoute']['enabled'] = True
self.save()
self.cevap = {'hedef': 'Arastirma', 'ret': None, 'puan': {'zor': 0.2, 'arastirma': 0.9}}
req = self.istek('ide/Otomatik', user('kaynaklari karsilastir'))
self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/Otomatik'), 'ide/Arastirma')
self.assertEqual(req['model'], 'ide/Arastirma')
arac = {'type': 'function_call_output', 'call_id': 'c1', 'output': 'ok'}
req2 = self.istek('ide/Otomatik', user('kaynaklari karsilastir'), {'type': 'function_call', 'call_id': 'c1'}, arac)
self.assertEqual(self.karar(2, req2, 'sohbet-1', 'ide/Otomatik'), 'ide/Arastirma')
self.assertEqual(len(self.kararlar), 1) # same turn: no second question
son_cevap = {'type': 'message', 'role': 'assistant', 'content': [{'type': 'output_text', 'text': 'Rapor hazir.'}]}
req3 = self.istek('ide/Otomatik', user('kaynaklari karsilastir'), arac, son_cevap, user('simdi kodu duzelt'))
self.cevap = {'hedef': 'Ajan', 'ret': None, 'puan': {'zor': 0.9, 'arastirma': 0.1}}
self.assertEqual(self.karar(3, req3, 'sohbet-1', 'ide/Otomatik'), 'ide/Ajan')
self.assertEqual(len(self.kararlar), 2) # new user message: new decision
def test_predicted_refusal_starts_at_the_configured_layer(self):
self.settings['prefilter']['enabled'] = True
self.save()
self.cevap = {'hedef': None, 'ret': True, 'puan': {'ret': 0.9}}
req = self.istek('ide/codex-gpt-6-astra', user('sert bir roast yaz'))
self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/codex-gpt-6-astra'), 'ide/Katman-2')
self.assertEqual(self.kararlar, [('sert bir roast yaz', False, True)])
def test_exempt_and_later_layers_are_not_asked(self):
self.settings['prefilter']['enabled'] = True
self.save()
for model in ('ide/Chat-Model', 'ide/Katman-2', 'ide/Katman-3', 'codex/gpt-6-astra'):
req = self.istek(model, user('yap'))
self.assertEqual(self.karar(1, req, 'sohbet-1', model), model)
self.assertEqual(self.kararlar, [])
def test_no_refusal_or_no_decision_keeps_the_model(self):
self.settings['prefilter']['enabled'] = True
self.save()
for cevap in ({'hedef': None, 'ret': False, 'puan': {'ret': 0.1}}, {'hedef': None, 'ret': None, 'puan': None}):
self.env['ON_KARAR'].clear()
self.cevap = cevap
req = self.istek('ide/Ajan', user('yap'))
self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/Ajan'), 'ide/Ajan')
self.assertEqual(req['model'], 'ide/Ajan')
def test_master_switch_off_disables_every_stage(self):
self.settings['enabled'] = False
self.settings['prefilter']['enabled'] = True
self.settings['autoRoute']['enabled'] = True
self.save()
req = self.istek('ide/Otomatik', user('yap'))
# The virtual name never leaves the bridge; nothing is asked while the main switch is off.
self.assertEqual(self.karar(1, req, 'sohbet-1', 'ide/Otomatik'), 'ide/' + self.settings['autoRoute']['targets']['zor'])
self.assertEqual(self.kararlar, [])
def test_a_message_typed_mid_turn_or_a_dot_keeps_the_choice(self):
self.settings['autoRoute']['enabled'] = True
self.save()
self.cevap = {'hedef': 'Arastirma', 'ret': None, 'puan': {'zor': 0.2, 'arastirma': 0.9}}
bas = user('kaynaklari karsilastir')
cagri = {'type': 'function_call', 'call_id': 'c1', 'name': 'shell', 'arguments': '{}'}
cikti = {'type': 'function_call_output', 'call_id': 'c1', 'output': 'ok'}
self.assertEqual(self.karar(1, self.istek('ide/Otomatik', bas), 'sohbet-1', 'ide/Otomatik'), 'ide/Arastirma')
self.cevap = {'hedef': 'Workflow-Kesif', 'ret': None, 'puan': {'zor': 0.1, 'arastirma': 0.1}}
for ek in (user('.'), user('bir de sunu ekle'), {'type': 'compaction_trigger'}):
req = self.istek('ide/Otomatik', bas, cagri, cikti, ek)
self.assertEqual(self.karar(2, req, 'sohbet-1', 'ide/Otomatik'), 'ide/Arastirma', ek)
self.assertEqual(len(self.kararlar), 1)
def test_a_disabled_automatic_model_never_reaches_the_gateway(self):
self.save() # autoRoute off
req = self.istek('ide/Otomatik', user('yap'))
secilen = self.karar(1, req, 'sohbet-1', 'ide/Otomatik')
self.assertEqual(secilen, 'ide/' + self.settings['autoRoute']['targets']['zor'])
self.assertEqual(req['model'], secilen)
self.assertEqual(self.kararlar, [])
self.settings['autoRoute']['enabled'] = True
self.settings['enabled'] = False # main switch off
self.save()
self.assertNotEqual(self.karar(2, self.istek('ide/Otomatik', user('yap')), 'sohbet-1', 'ide/Otomatik'), 'ide/Otomatik')
def test_without_a_conversation_key_the_tool_steps_still_keep_the_choice(self):
self.settings['autoRoute']['enabled'] = True
self.save()
self.cevap = {'hedef': 'Ajan', 'ret': None, 'puan': {'zor': 0.9, 'arastirma': 0.1}}
bas = user('kodu duzelt')
self.assertEqual(self.karar(1, {'model': 'ide/Otomatik', 'input': [bas]}, '', 'ide/Otomatik'), 'ide/Ajan')
cikti = {'type': 'function_call_output', 'call_id': 'c1', 'output': 'ok'}
req = {'model': 'ide/Otomatik', 'input': [bas, {'type': 'function_call', 'call_id': 'c1'}, cikti]}
self.assertEqual(self.karar(2, req, '', 'ide/Otomatik'), 'ide/Ajan')
self.assertEqual(len(self.kararlar), 1)
def test_the_finish_retry_is_used_once_per_turn(self):
self.assertTrue(self.env['bitis_kullan']('tur-1'))
self.assertFalse(self.env['bitis_kullan']('tur-1'))
self.assertTrue(self.env['bitis_kullan']('tur-2'))
def test_turn_memory_is_bounded(self):
self.settings['prefilter']['enabled'] = True
self.save()
self.cevap = {'hedef': None, 'ret': False, 'puan': {'ret': 0.1}}
for i in range(6):
self.karar(i, {'model': 'ide/Ajan', 'prompt_cache_key': f's{i}', 'input': [user('yap')]}, f's{i}', 'ide/Ajan')
self.assertLessEqual(len(self.env['ON_KARAR']), 3)
class KatmanPenceresi(unittest.TestCase):
def suzgec(self, bilgi):
logs = []
env = {'IDE_PREFIX': 'ide/', 'IDE_BILGI': bilgi, 'pencere': pencere, 'log': logs.append}
exec(compile(ast.Module(body=[fonksiyon('sigan_katmanlar')], type_ignores=[]), 'kopru.py', 'exec'), env)
return env['sigan_katmanlar'], logs
def test_a_layer_too_small_for_the_request_is_skipped(self):
f, logs = self.suzgec({'ide/Katman-2': {'pencere': 1000, 'kaynak': 'katalog'}, 'ide/Katman-3': {'pencere': 10 ** 6}})
req = {'input': [user('x' * 20000)]}
self.assertEqual(f(['Katman-2', 'Katman-3', 'Bilinmeyen'], req, '[1]'), ['Katman-3', 'Bilinmeyen'])
self.assertIn('Katman-2', logs[0])
def test_the_last_layer_and_untrusted_windows_are_never_skipped(self):
req = {'input': [user('x' * 20000)]}
f, _ = self.suzgec({'ide/Katman-2': {'pencere': 1000, 'kaynak': 'katalog'}, 'ide/Katman-3': {'pencere': 1000, 'kaynak': 'katalog'}})
self.assertEqual(f(['Katman-2', 'Katman-3'], req, '[1]'), ['Katman-3']) # last one stays
f, _ = self.suzgec({'ide/Katman-2': {'pencere': 1000, 'kaynak': 'varsayilan'}})
self.assertEqual(f(['Katman-2', 'Katman-3'], req, '[1]'), ['Katman-2', 'Katman-3']) # placeholder window
class BitisDenetimi(Ev):
"""_denetimli_akis with finishCheck: an answer that stops early goes back to the same model once."""
def setUp(self):
super().setUp()
self.settings['finishCheck']['enabled'] = True
self.save()
self.asked, self.logs, self.devamlar = [], [], []
self.puanlar = []
sahte = Mock()
sahte.anahtar = lambda: 'k'
sahte.sor = self.sor
sahte.karar = jev_denetim.karar
sahte.bitmedi_mi = jev_denetim.bitmedi_mi
sahte.hazirlik_mi = jev_denetim.hazirlik_mi
sahte.muaf_mi = jev_denetim.muaf_mi
sahte.hata_gecis = jev_denetim.hata_gecis
env = {'jev_denetim': sahte, 'yarim': yarim, 'time': time, 'http': __import__('http.client'),
'log': self.logs.append, 'RESP_HOP': set(), 'json': json}
adlar = ('_akis_basligi', '_denetimli_akis', '_stream')
exec(compile(ast.Module(body=[method(a) for a in adlar], type_ignores=[]), 'kopru.py', 'exec'), env)
self.h = type('H', (), {a: env[a] for a in adlar})()
self.h.wfile = io.BytesIO()
self.h.send_response = self.h.send_header = self.h.end_headers = Mock()
def sor(self, istek, metin, key, bitis=False):
self.asked.append((metin, bitis))
return self.puanlar.pop(0) if self.puanlar else {'ret': 0.0, 'sart': 0.0}
def devam(self, ad, onceki):
self.devamlar.append((ad, onceki))
return Yanit(sse('gercekten bitti, cikti: OK')), None, f'[{ad} devam]'
def calistir(self, body):
self.h._denetimli_akis(Yanit(body), '[1]', None, None, 'yap', ['Katman-1'], Mock(), hedef='Ajan', devam=self.devam)
return self.gonderilen()
def gonderilen(self):
raw, out = self.h.wfile.getvalue(), b''
while raw:
size, _, rest = raw.partition(b'\r\n')
n = int(size, 16)
if n == 0:
break
out += rest[:n]
raw = rest[n + 2:]
return out
def test_an_early_stop_goes_back_once_and_the_retry_is_delivered(self):
self.puanlar = [{'ret': 0.0, 'sart': 0.0, 'bitmedi': 0.9}, {'ret': 0.0, 'sart': 0.0, 'bitmedi': 0.9}]
out = self.calistir(sse('Bitti.'))
self.assertEqual(self.devamlar, [('Ajan', 'Bitti.')])
self.assertIn(b'gercekten bitti', out)
self.assertNotIn(b'"Bitti."', out)
self.assertEqual([b for _, b in self.asked], [True, False]) # the retry is not asked again
def test_a_finished_answer_is_delivered_unchanged(self):
self.puanlar = [{'ret': 0.0, 'sart': 0.0, 'bitmedi': 0.1}]
out = self.calistir(sse('Tamam, test ciktisi: 3 passed.'))
self.assertEqual(self.devamlar, [])
self.assertIn(b'3 passed', out)
def test_a_refusal_still_goes_down_the_chain_not_back(self):
self.puanlar = [{'ret': 0.9, 'sart': 0.0, 'bitmedi': 0.9}]
ac = Mock(return_value=(Yanit(sse('katman')), None, '[K1]'))
self.h._denetimli_akis(Yanit(sse('Yapamam.')), '[1]', None, None, 'yap', ['Katman-1'], ac, hedef='Ajan', devam=self.devam)
self.assertEqual(self.devamlar, [])
ac.assert_called_once_with('Katman-1')
def test_a_failed_retry_delivers_the_first_answer(self):
self.puanlar = [{'ret': 0.0, 'sart': 0.0, 'bitmedi': 0.9}]
def bozuk(ad, onceki):
raise OSError('acilamadi')
self.h._denetimli_akis(Yanit(sse('Bitti.')), '[1]', None, None, 'yap', [], Mock(), hedef='Ajan', devam=bozuk)
self.assertIn(b'Bitti.', self.gonderilen())
def test_a_broken_or_refused_retry_delivers_the_approved_first_answer(self):
for ikinci in (sse(), sse('Yapamam.')):
with self.subTest(ikinci=ikinci[:40]):
self.setUp()
self.puanlar = [{'ret': 0.0, 'sart': 0.0, 'bitmedi': 0.9}, {'ret': 0.9, 'sart': 0.0}]
ac = Mock(return_value=(Yanit(sse('katman')), None, '[K1]'))
self.h._denetimli_akis(Yanit(sse('Ilk cevap.')), '[1]', None, None, 'yap', ['Katman-1'], ac,
hedef='Ajan', devam=lambda ad, onceki: (Yanit(ikinci), None, '[devam]'))
self.assertIn(b'Ilk cevap.', self.gonderilen())
ac.assert_not_called()
def test_without_the_stage_the_finish_question_is_not_sent(self):
govdeler = []
def gonder(govde, key):
govdeler.append(govde)
return {'answers': {'ret': {'noul': 0.1}, 'sart': {'noul': 0.1}}}
puan = jev_denetim.sor('i', 'c', 'k', gonder=gonder)
self.assertNotIn('bitmedi', govdeler[0]['questions'])
self.assertNotIn('bitmedi', puan)
puan = jev_denetim.sor('i', 'c', 'k', gonder=gonder, bitis=True)
self.assertIn('bitmedi', govdeler[1]['questions'])
self.assertEqual(set(puan), {'ret', 'sart'}) # unanswered extra question: main verdict stands
self.assertFalse(jev_denetim.bitmedi_mi(puan))
class DogrulayiciElemesi(Ev):
def test_off_by_default_and_asked_only_when_enabled(self):
import io as _io
import karar_sor
self.save()
out = _io.StringIO()
self.assertEqual(karar_sor.main(['dogrulayici'], _io.StringIO('{"user_request": "x", "assistant_reply": "kabul"}'), out), 0)
self.assertEqual(json.loads(out.getvalue())['enabled'], False)
self.settings['verifierCheck']['enabled'] = True
self.save()
govdeler = []
def gonder(govde, key):
govdeler.append(govde)
return {'answers': {'kanitsiz': {'noul': 0.92}}}
sonuc = karar_sor.dogrulayici({'user_request': 'olcut', 'assistant_reply': '{"karar":"kabul","bulgular":[]}'}, 'k', gonder)
self.assertEqual((sonuc['enabled'], sonuc['kanitsiz'], sonuc['puan']), (True, True, 0.92))
self.assertEqual(set(govdeler[0]['questions']), {'kanitsiz'})
bozuk = karar_sor.dogrulayici({'user_request': 'o', 'assistant_reply': 'r'}, 'k', Mock(side_effect=OSError('yok')))
self.assertIsNone(bozuk['kanitsiz'])
def test_wrong_usage_exits_2(self):
import io as _io
import karar_sor
self.assertEqual(karar_sor.main(['baska'], _io.StringIO('{}'), _io.StringIO()), 2)
class Inceleme(Ev):
def test_the_correction_channel_needs_an_always_pass_target_while_checks_are_on(self):
self.settings['watchModels'] = ['Chat-Model', 'Ajan']
self.save()
self.assertTrue(jev_denetim.izleme_mi('Chat-Model'))
self.assertFalse(jev_denetim.izleme_mi('Ajan')) # checked target: no sideband
self.settings['enabled'] = False
self.save()
self.assertTrue(jev_denetim.izleme_mi('Ajan'))
def test_a_turn_start_question_does_not_wait_in_the_queue_beyond_its_timeout(self):
self.settings['maxConcurrent'] = 1
self.settings['queueWaitSeconds'] = 3600
self.save()
s = jev_settings.read()[0]
self.assertTrue(jev_settings.acquire(s)) # the only slot is taken
try:
t0 = time.monotonic()
self.assertIsNone(jev_denetim.sorular({'user_request': 'x'}, {'zor': 'q'}, 'k', 1, gonder=lambda g, k: {}))
self.assertLess(time.monotonic() - t0, 3)
finally:
jev_settings.release()
def test_the_prefilter_target_cannot_be_the_automatic_model(self):
self.settings['prefilter']['target'] = self.settings['autoRoute']['model']
self.save()
with self.assertRaises(ValueError):
jev_settings.read()
def test_dify_redirects_are_refused_so_the_key_never_moves(self):
import urllib.error
with self.assertRaises(urllib.error.HTTPError):
karar_politikasi._YonlendirmeYok().redirect_request(
Mock(full_url='https://dify.example/v1/workflows/run'), None, 302, 'Found', {}, 'http://evil.example/')
class IzlemeSuzgeci(unittest.TestCase):
def test_a_huge_frame_passes_through_instead_of_breaking_the_stream(self):
tree_sinif = next(n for n in TREE.body if isinstance(n, ast.ClassDef) and n.name == 'WatchResponse')
from bekci_watch import MetadataFilter
logs = []
env = {'MetadataFilter': MetadataFilter, 'log': logs.append}
exec(compile(ast.Module(body=[tree_sinif], type_ignores=[]), 'kopru.py', 'exec'), env)
buyuk = b'data: ' + b'x' * 300000
parcalar = [buyuk[:200000], buyuk[200000:], b'\n\n', b'']
class Ham:
def read1(self, n):
return parcalar.pop(0)
w = env['WatchResponse'](Ham(), lambda m: None)
cikti = b''
while True:
b = w.read1(65536)
if not b and w.done or not parcalar and not b:
break
cikti += b
self.assertIn(b'x' * 1000, cikti)
self.assertEqual(cikti.count(b'x'), 300000)
self.assertTrue(logs)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,143 @@
"""Real-socket regressions for terminal worker errors (no provider calls)."""
import json
import unittest
import test_ayirici as fixtures
import terminal_stream
ERROR = b'event: error\ndata: {"type":"error","error":{"type":"upstream_error","message":"upstream 503"}}\n\n'
COMPLETE = b'event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_ok","status":"completed","output":[]}}\n\n'
class TerminalStream(unittest.TestCase):
setUp = fixtures.Ayirici.setUp
tearDown = fixtures.Ayirici.tearDown
iste = fixtures.Ayirici.iste
def request(self, token="a", text="check"):
body = json.dumps({"model": "ide/test", "input": [{"role": "user", "content": text}]})
return self.iste("POST", "/backend-api/codex/responses", body,
{"Authorization": "Bearer " + token})
def worker(self, payload):
def respond(h, body):
if h.path.endswith("/prepare-transfer"):
return fixtures.duz(200, json.dumps({"request": json.loads(body)["request"]}).encode())(h, body)
return fixtures.duz(200, payload, "text/event-stream")(h, body)
self.kopru.cevap = respond
def inference_count(self):
return sum(x["yol"].endswith("/responses") for x in self.kopru.gelen)
def test_error_only_stream_is_an_honest_http_failure(self):
self.worker(ERROR)
status, body, _ = self.request()
self.assertEqual(status, 400)
self.assertIn("upstream 503", json.loads(body)["error"]["message"])
self.assertNotIn(b"response.completed", body)
self.assertEqual(self.inference_count(), 1)
def test_error_after_heartbeat_stops_automatic_replay_once(self):
def respond(h, body):
if h.path.endswith("/prepare-transfer"):
return fixtures.duz(200, json.dumps({"request": json.loads(body)["request"]}).encode())(h, body)
h.send_response(200)
h.send_header("Content-Type", "text/event-stream")
h.send_header("Transfer-Encoding", "chunked")
h.end_headers()
for chunk in (b": keepalive\n\n", ERROR):
h.wfile.write(b"%x\r\n" % len(chunk) + chunk + b"\r\n")
h.wfile.flush()
fixtures.time.sleep(.08)
h.wfile.write(b"0\r\n\r\n")
self.kopru.cevap = respond
status, body, _ = self.request()
self.assertEqual((status, body), (200, b": keepalive\n\n" + ERROR))
status, body, _ = self.request()
self.assertEqual(status, 400)
self.assertIn(b"upstream 503", body)
self.assertEqual(self.inference_count(), 1)
# A later explicit retry is allowed; the one-shot guard is consumed.
self.worker(COMPLETE)
self.assertEqual(self.request()[:2], (200, COMPLETE))
self.assertEqual(self.inference_count(), 2)
def test_native_error_stream_is_not_intercepted(self):
self.ozgun.cevap = fixtures.duz(200, ERROR, "text/event-stream")
body = json.dumps({"model": "gpt-6-astra", "input": []})
self.assertEqual(self.iste("POST", "/backend-api/codex/responses", body)[:2], (200, ERROR))
def test_success_stream_is_byte_identical(self):
self.worker(COMPLETE)
self.assertEqual(self.request()[:2], (200, COMPLETE))
self.assertEqual(self.request()[:2], (200, COMPLETE))
self.assertEqual(self.inference_count(), 2)
def test_eof_without_upstream_error_keeps_network_retry(self):
self.worker(b": keepalive\n\n")
self.assertEqual(self.request()[0], 200)
self.assertEqual(self.request()[0], 200)
self.assertEqual(self.inference_count(), 2)
def test_worker_exhausted_failure_is_terminal_without_changing_its_reason(self):
self.worker(b'event: response.failed\ndata: {"type":"response.failed","response":{"status":"failed","error":{"code":"jev_no_fallback","message":"No accepted response"}}}\n\n')
status, body, _ = self.request()
self.assertEqual(status, 400)
self.assertEqual(json.loads(body)["error"]["code"], "jev_no_fallback")
def test_existing_context_error_classification_is_preserved(self):
data = b'event: response.failed\ndata: {"type":"response.failed","response":{"error":{"code":"context_length_exceeded","message":"Context full"}}}\n\n'
self.worker(data)
self.assertEqual(self.request()[:2], (200, data))
class ObserverTests(unittest.TestCase):
def test_fragmented_crlf_and_multiline_json(self):
data = b'event: error\r\ndata: {"type":"error",\r\ndata: "error":{"type":"upstream_error","message":"503"}}\r\n\r\n'
observer = terminal_stream.Observer()
for byte in data:
observer.feed(bytes([byte]))
self.assertEqual(observer.error['message'], '503')
def test_large_and_malformed_frames_are_bounded_and_do_not_crash(self):
observer = terminal_stream.Observer()
observer.feed(b'data: ' + b'x'*200000)
self.assertLessEqual(len(observer.tail), observer.LIMIT)
observer.feed(b'\n\ndata: {"response":[]}\n\n' + ERROR)
self.assertEqual(observer.error['message'], 'upstream 503')
def test_completed_stream_does_not_poison_next_request(self):
observer = terminal_stream.Observer()
observer.feed(ERROR + COMPLETE)
self.assertIsNone(observer.error)
self.assertTrue(observer.completed)
def test_one_shot_expiry_and_capacity(self):
now = [10]
cache = terminal_stream.FailureReplays(ttl=5, capacity=2, clock=lambda: now[0])
cache.remember(b'a', {'code': 'a'})
self.assertEqual(cache.take(b'a'), {'code': 'a'})
self.assertIsNone(cache.take(b'a'))
cache.remember(b'a', {})
now[0] = 16
self.assertIsNone(cache.take(b'a'))
for key in (b'a', b'b', b'c'):
cache.remember(key, {'code': 'error'})
self.assertIsNone(cache.take(b'a'))
self.assertEqual(len(cache.items), 2)
def test_request_identity_separates_accounts_sessions_models_and_inputs(self):
base = {'model': 'ide/a', 'input': []}
headers = {'Authorization': 'Bearer a', 'session_id': 'one'}
key = terminal_stream.request_key(base, headers)
self.assertEqual(key, terminal_stream.request_key(dict(reversed(list(base.items()))), headers))
for request, other in [({'model': 'ide/b', 'input': []}, headers),
({'model': 'ide/a', 'input': ['next']}, headers),
(base, dict(headers, Authorization='Bearer b')),
(base, dict(headers, session_id='two'))]:
self.assertNotEqual(key, terminal_stream.request_key(request, other))
if __name__ == "__main__":
unittest.main()
+68 -1
View File
@@ -31,12 +31,33 @@ export function publicMessages(rows) {
return messages return messages
} }
// Browser actions the extension may offer the IDE agent. The backend applies
// its own allowlist again; this one keeps unknown names off the wire.
export const BROWSER_CAPABILITIES = Object.freeze(['browser_navigate', 'browser_snapshot', 'browser_click',
'browser_type', 'browser_scroll', 'browser_back', 'browser_press', 'browser_get_images', 'browser_vision'])
const COMMAND_ID = /^[a-f0-9]{16,64}$/
export function validateRequest(message) { export function validateRequest(message) {
if (!message || typeof message.id !== 'string' || message.id.length > 80 || if (!message || typeof message.id !== 'string' || message.id.length > 80 ||
!['status', 'sessions.list', 'session.open', 'session.history', 'message.send', 'message.stop'].includes(message.action)) { !['status', 'sessions.list', 'session.open', 'session.history', 'message.send', 'message.stop',
'browser.register', 'browser.result'].includes(message.action)) {
throw new Error('Desteklenmeyen eklenti isteği.') throw new Error('Desteklenmeyen eklenti isteği.')
} }
const params = message.params || {} const params = message.params || {}
if (message.action === 'browser.register') {
if (typeof params.enabled !== 'boolean') throw new Error('Tarayıcı denetimi ayarı geçersiz.')
if (params.enabled && (!Array.isArray(params.capabilities) || !params.capabilities.length ||
params.capabilities.some(name => !BROWSER_CAPABILITIES.includes(name)) ||
!/^[a-zA-Z0-9_.:-]{1,120}$/.test(params.controllerId || '') ||
!/^[a-zA-Z0-9_.:-]{1,120}$/.test(params.browserProfileId || ''))) {
throw new Error('Tarayıcı denetimi kaydı geçersiz.')
}
}
if (message.action === 'browser.result' &&
(typeof params.commandId !== 'string' || !COMMAND_ID.test(params.commandId) || typeof params.ok !== 'boolean' ||
(!params.ok && (typeof params.error !== 'string' || params.error.length > 4000)))) {
throw new Error('Tarayıcı komut sonucu geçersiz.')
}
if (['session.open', 'session.history', 'message.send', 'message.stop'].includes(message.action) && if (['session.open', 'session.history', 'message.send', 'message.stop'].includes(message.action) &&
(typeof params.sessionId !== 'string' || !/^[a-zA-Z0-9_.:-]{1,160}$/.test(params.sessionId))) { (typeof params.sessionId !== 'string' || !/^[a-zA-Z0-9_.:-]{1,160}$/.test(params.sessionId))) {
throw new Error('Geçersiz konuşma kimliği.') throw new Error('Geçersiz konuşma kimliği.')
@@ -57,6 +78,8 @@ export class ChromeGatewayClient {
this.nextId = 0 this.nextId = 0
this.selected = null this.selected = null
this.closed = false this.closed = false
// True once this connection offered the user's browser to the IDE agent.
this.controller = false
} }
async connect() { async connect() {
@@ -92,6 +115,8 @@ export class ChromeGatewayClient {
} }
this.pending.clear() this.pending.clear()
this.selected = null this.selected = null
// The backend drops a local browser controller with its transport.
this.controller = false
if (!this.closed) this.emit({ event: 'disconnected', message: 'IDE bağlantısı kesildi. Yeniden bağlanın.' }) if (!this.closed) this.emit({ event: 'disconnected', message: 'IDE bağlantısı kesildi. Yeniden bağlanın.' })
}) })
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
@@ -135,6 +160,10 @@ export class ChromeGatewayClient {
} }
onEvent(event) { onEvent(event) {
if (this.controller && ['browser.controller.command', 'browser.controller.cancel'].includes(event?.type)) {
this.onControllerEvent(event)
return
}
const selected = this.selected const selected = this.selected
if (!selected || event?.session_id !== selected.runtimeId) return if (!selected || event?.session_id !== selected.runtimeId) return
const payload = event.payload || {} const payload = event.payload || {}
@@ -152,6 +181,26 @@ export class ChromeGatewayClient {
} }
} }
onControllerEvent(event) {
const payload = event.payload || {}
const commandId = payload.command_id
if (typeof commandId !== 'string' || !COMMAND_ID.test(commandId)) return
if (event.type === 'browser.controller.cancel') {
this.emit({ event: 'browser.cancel', commandId })
return
}
const refuse = error => void this.rpc('browser.controller.local.result',
{ command_id: commandId, ok: false, error }).catch(() => {})
if (!BROWSER_CAPABILITIES.includes(payload.action)) return refuse('Chrome bu tarayıcı işlemini desteklemiyor.')
const args = payload.arguments && typeof payload.arguments === 'object' && !Array.isArray(payload.arguments)
? payload.arguments : {}
// A frame over the native limit would drop the whole connection; refuse
// the one oversized command instead.
if (Buffer.byteLength(JSON.stringify(args)) > 256 * 1024) return refuse('Tarayıcı komutu çok büyük.')
this.emit({ event: 'browser.command', commandId, action: payload.action, arguments: args,
sessionId: typeof event.session_id === 'string' ? event.session_id.slice(0, 160) : '' })
}
async handle(message) { async handle(message) {
const params = validateRequest(message) const params = validateRequest(message)
await this.connect() await this.connect()
@@ -193,6 +242,24 @@ export class ChromeGatewayClient {
} }
case 'session.history': case 'session.history':
return this.history(params.sessionId) return this.history(params.sessionId)
case 'browser.register': {
if (!params.enabled) {
this.controller = false
const result = await this.rpc('browser.controller.local.detach', {})
return { registered: false, detached: Boolean(result?.detached) }
}
const result = await this.rpc('browser.controller.local.register', { protocol_version: 1,
controller_id: params.controllerId, browser_profile_id: params.browserProfileId,
capabilities: params.capabilities, profile: this.backend.profile || 'default' })
this.controller = true
return { registered: true, capabilities: Array.isArray(result?.capabilities) ? result.capabilities : [] }
}
case 'browser.result': {
const result = await this.rpc('browser.controller.local.result', params.ok
? { command_id: params.commandId, ok: true, result: params.result ?? null }
: { command_id: params.commandId, ok: false, error: params.error })
return { accepted: Boolean(result?.accepted) }
}
case 'message.send': case 'message.send':
case 'message.stop': { case 'message.stop': {
if (this.selected?.storedId !== params.sessionId) throw new Error('Önce bu konuşmayı seçin.') if (this.selected?.storedId !== params.sessionId) throw new Error('Önce bu konuşmayı seçin.')
@@ -225,12 +225,17 @@ describe('preference file', () => {
}) })
it('writes the contract shape and keeps unknown fields', () => { it('writes the contract shape and keeps unknown fields', () => {
expect(JSON.parse(serializeCodexLinkSettings({}, false))).toEqual({ enabled: false, kapsam: 'ide' }) expect(JSON.parse(serializeCodexLinkSettings({}, false))).toEqual({
enabled: false,
mod: 'dogrudan',
kapsam: 'ide'
})
const { stored } = parseCodexLinkSettings('{"enabled": false, "kapsam": "hepsi", "gelecek": {"a": 1}}') const { stored } = parseCodexLinkSettings('{"enabled": false, "kapsam": "hepsi", "gelecek": {"a": 1}}')
expect(JSON.parse(serializeCodexLinkSettings(stored, true))).toEqual({ expect(JSON.parse(serializeCodexLinkSettings(stored, true))).toEqual({
enabled: true, enabled: true,
mod: 'dogrudan',
gelecek: { a: 1 }, gelecek: { a: 1 },
kapsam: 'hepsi' kapsam: 'hepsi'
}) })
@@ -308,3 +313,17 @@ describe('bridge configuration and command', () => {
expect(codexAutostartPlan(`${deep}\\pythonw.exe`, launch, `${deep}\\baslat.pyw`)).toBeNull() expect(codexAutostartPlan(`${deep}\\pythonw.exe`, launch, `${deep}\\baslat.pyw`)).toBeNull()
}) })
}) })
describe('mode preference', () => {
it('uses direct for missing, invalid and malformed modes', () => {
for (const raw of [null, '{', '{}', '{"mod":"bad"}', '{"mod":false}']) {
expect(parseCodexLinkSettings(raw).mod).toBe('dogrudan')
}
expect(parseCodexLinkSettings('{"mod":"aiturk"}').mod).toBe('aiturk')
})
it('preserves other fields when changing mode', () => {
const stored = { enabled: false, mod: 'aiturk', kapsam: 'hepsi', future: { a: 1 } }
expect(JSON.parse(serializeCodexLinkSettings(stored, false, 'dogrudan'))).toEqual({ ...stored, mod: 'dogrudan' })
expect(JSON.parse(serializeCodexLinkSettings(stored, true)).mod).toBe('aiturk')
})
})
+34 -9
View File
@@ -27,7 +27,7 @@ export const CODEX_LINK_REFRESH_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama
export const CODEX_LINK_MARKER = '# AITURK IDE Codex Baglama' export const CODEX_LINK_MARKER = '# AITURK IDE Codex Baglama'
// Must equal SURUM in assets/codex-bridge/bridge.py; a running bridge that reports // Must equal SURUM in assets/codex-bridge/bridge.py; a running bridge that reports
// another version is restarted so an updated IDE never keeps an old bridge alive. // another version is restarted so an updated IDE never keeps an old bridge alive.
export const CODEX_BRIDGE_VERSION = '17' export const CODEX_BRIDGE_VERSION = '21'
export const CODEX_LINK_LINE = `openai_base_url = "${CODEX_LINK_ORIGIN}/backend-api/codex" ${CODEX_LINK_MARKER}` export const CODEX_LINK_LINE = `openai_base_url = "${CODEX_LINK_ORIGIN}/backend-api/codex" ${CODEX_LINK_MARKER}`
export const CODEX_LINK_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' export const CODEX_LINK_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
@@ -300,6 +300,7 @@ export function removeCodexLinkLine(source: string): CodexConfigEdit {
export interface CodexLinkSettings { export interface CodexLinkSettings {
enabled: boolean enabled: boolean
mod: 'dogrudan' | 'aiturk'
/** Every field of the stored object, so a rewrite keeps what it did not set. */ /** Every field of the stored object, so a rewrite keeps what it did not set. */
stored: Record<string, unknown> stored: Record<string, unknown>
} }
@@ -307,26 +308,34 @@ export interface CodexLinkSettings {
/** Missing, unreadable or malformed means ON: the feature is on by default. */ /** Missing, unreadable or malformed means ON: the feature is on by default. */
export function parseCodexLinkSettings(raw: null | string | undefined): CodexLinkSettings { export function parseCodexLinkSettings(raw: null | string | undefined): CodexLinkSettings {
if (typeof raw !== 'string') { if (typeof raw !== 'string') {
return { enabled: true, stored: {} } return { enabled: true, mod: 'dogrudan', stored: {} }
} }
try { try {
const parsed = JSON.parse(raw) const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { enabled: true, stored: {} } return { enabled: true, mod: 'dogrudan', stored: {} }
} }
return { enabled: parsed.enabled !== false, stored: parsed as Record<string, unknown> } return {
enabled: parsed.enabled !== false,
mod: parsed.mod === 'aiturk' ? 'aiturk' : 'dogrudan',
stored: parsed as Record<string, unknown>
}
} catch { } catch {
return { enabled: true, stored: {} } return { enabled: true, mod: 'dogrudan', stored: {} }
} }
} }
export function serializeCodexLinkSettings(stored: Record<string, unknown>, enabled: boolean): string { export function serializeCodexLinkSettings(
stored: Record<string, unknown>,
enabled: boolean,
mod: 'dogrudan' | 'aiturk' = stored.mod === 'aiturk' ? 'aiturk' : 'dogrudan'
): string {
const kapsam = typeof stored.kapsam === 'string' && stored.kapsam ? stored.kapsam : 'ide' const kapsam = typeof stored.kapsam === 'string' && stored.kapsam ? stored.kapsam : 'ide'
return `${JSON.stringify({ ...stored, enabled, kapsam }, null, 2)}\n` return `${JSON.stringify({ ...stored, enabled, mod, kapsam }, null, 2)}\n`
} }
// ── Bridge configuration ──────────────────────────────────────────────────── // ── Bridge configuration ────────────────────────────────────────────────────
@@ -343,8 +352,24 @@ export function codexBridgeConfig() {
} }
} }
export function serializeCodexBridgeConfig(): string { /** Keeps the user's own choices (Settings → AITURK iş hattı) across the rewrite. */
return `${JSON.stringify(codexBridgeConfig(), null, 2)}\n` export function serializeCodexBridgeConfig(previous?: null | string): string {
const config: Record<string, unknown> = codexBridgeConfig()
try {
const old = previous ? JSON.parse(previous) : null
// The user's own choices from Settings → Codex Bağlama survive the IDE rewriting the file.
for (const key of ['aiturk_hat', 'codex_liste']) {
if (old && typeof old === 'object' && old[key] && typeof old[key] === 'object') {
config[key] = old[key]
}
}
} catch {
// An unreadable file has nothing worth keeping.
}
return `${JSON.stringify(config, null, 2)}\n`
} }
// ── Bridge command line ───────────────────────────────────────────────────── // ── Bridge command line ─────────────────────────────────────────────────────
@@ -5,6 +5,8 @@
* code into its program. See codex-link.ts for what the fields mean in action. * code into its program. See codex-link.ts for what the fields mean in action.
*/ */
export type CodexLinkMode = 'dogrudan' | 'aiturk'
export type CodexLinkIdeState = 'anahtar-yok' | 'kapali' | 'tamam' export type CodexLinkIdeState = 'anahtar-yok' | 'kapali' | 'tamam'
/** Stable codes; the renderer owns the wording. Detail goes to the desktop log. */ /** Stable codes; the renderer owns the wording. Detail goes to the desktop log. */
@@ -18,6 +20,7 @@ export type CodexLinkError =
export interface CodexLinkStatus { export interface CodexLinkStatus {
enabled: boolean enabled: boolean
mod: CodexLinkMode
/** Codex's `config.toml` exists on this machine. */ /** Codex's `config.toml` exists on this machine. */
codexFound: boolean codexFound: boolean
/** The IDE's own Python environment exists (first-run setup finished). */ /** The IDE's own Python environment exists (first-run setup finished). */
@@ -34,6 +37,7 @@ export interface CodexLinkStatus {
export interface CodexLinkPatch { export interface CodexLinkPatch {
enabled?: boolean enabled?: boolean
mod?: CodexLinkMode
/** "Sync now": re-run the apply without changing the preference. */ /** "Sync now": re-run the apply without changing the preference. */
sync?: boolean sync?: boolean
} }
+52 -3
View File
@@ -52,6 +52,7 @@ function createWorld(patch: Partial<World> & { files?: Map<string, string> } = {
events: [], events: [],
files: new Map([ files: new Map([
[PATHS.codexConfig, USER_CONFIG], [PATHS.codexConfig, USER_CONFIG],
[PATHS.settingsFile, JSON.stringify({ enabled: true, mod: 'aiturk' })],
[PATHS.bridgeScript, '# bridge'], [PATHS.bridgeScript, '# bridge'],
[PATHS.pythonw, ''], [PATHS.pythonw, ''],
[PATHS.python, ''] [PATHS.python, '']
@@ -192,6 +193,7 @@ describe('applyCodexLink', () => {
expect(status).toEqual({ expect(status).toEqual({
enabled: true, enabled: true,
mod: 'aiturk',
codexFound: true, codexFound: true,
venvReady: true, venvReady: true,
foreign: false, foreign: false,
@@ -328,7 +330,11 @@ describe('applyCodexLink', () => {
'autostart clear', 'autostart clear',
'stop' 'stop'
]) ])
expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({ enabled: false, kapsam: 'ide' }) expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({
enabled: false,
mod: 'aiturk',
kapsam: 'ide'
})
// Byte for byte what the user had before the feature touched it. // Byte for byte what the user had before the feature touched it.
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG) expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(world.autostart).toBeNull() expect(world.autostart).toBeNull()
@@ -344,7 +350,8 @@ describe('applyCodexLink', () => {
world.configWriteFails = true world.configWriteFails = true
expect(await link.apply({ enabled: false })).toMatchObject({ expect(await link.apply({ enabled: false })).toMatchObject({
bridgeRunning: true, lastError: 'config-write-failed' bridgeRunning: true,
lastError: 'config-write-failed'
}) })
expect(world.files.get(PATHS.codexConfig)).toBe(linkedConfig) expect(world.files.get(PATHS.codexConfig)).toBe(linkedConfig)
expect(world.autostart).toBe(autostart) expect(world.autostart).toBe(autostart)
@@ -358,7 +365,7 @@ describe('applyCodexLink', () => {
it('stays off across applies and comes back when turned on', async () => { it('stays off across applies and comes back when turned on', async () => {
const { link, world } = createWorld() const { link, world } = createWorld()
world.files.set(PATHS.settingsFile, '{"enabled": false, "kapsam": "ide", "not": "ours"}') world.files.set(PATHS.settingsFile, '{"enabled": false, "mod": "aiturk", "kapsam": "ide", "not": "ours"}')
expect(await link.apply()).toMatchObject({ enabled: false, bridgeRunning: false }) expect(await link.apply()).toMatchObject({ enabled: false, bridgeRunning: false })
expect(spawns(world)).toEqual([]) expect(spawns(world)).toEqual([])
@@ -367,6 +374,7 @@ describe('applyCodexLink', () => {
expect(await link.apply({ enabled: true })).toMatchObject({ enabled: true, bridgeRunning: true }) expect(await link.apply({ enabled: true })).toMatchObject({ enabled: true, bridgeRunning: true })
expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({ expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({
enabled: true, enabled: true,
mod: 'aiturk',
kapsam: 'ide', kapsam: 'ide',
not: 'ours' not: 'ours'
}) })
@@ -424,3 +432,44 @@ describe('getStatus', () => {
expect(effects(world)).toEqual([]) expect(effects(world)).toEqual([])
}) })
}) })
describe('connection mode', () => {
it('defaults to direct, starts bridge and autostart without writing a line', async () => {
const { link, world } = createWorld()
world.files.delete(PATHS.settingsFile)
expect(await link.apply()).toMatchObject({ mod: 'dogrudan', bridgeRunning: true })
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(spawns(world)).toHaveLength(1)
expect(world.autostart).not.toBeNull()
})
it('removes the old line and preserves the current bridge in direct mode', async () => {
const { link, world } = createWorld({ listening: true })
world.files.set(PATHS.settingsFile, '{"mod":"dogrudan"}')
world.files.set(PATHS.codexConfig, `${CODEX_LINK_LINE}\r\n${USER_CONFIG}`)
await link.apply()
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(spawns(world)).toHaveLength(0)
expect(world.events).not.toContain('stop')
expect(world.autostart).not.toBeNull()
})
it('switches both ways, persists mode and preserves unrelated preferences', async () => {
const { link, world } = createWorld({ listening: true })
world.files.set(PATHS.settingsFile, '{"mod":"dogrudan","custom":{"keep":1}}')
await link.apply({ mod: 'aiturk' })
expect(world.files.get(PATHS.codexConfig)).toContain(CODEX_LINK_LINE)
await link.apply({ mod: 'dogrudan' })
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(JSON.parse(world.files.get(PATHS.settingsFile)!)).toMatchObject({ mod: 'dogrudan', custom: { keep: 1 } })
await link.apply()
expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG)
expect(world.events).not.toContain('stop')
})
it.each(['dogrudan', 'aiturk'] as const)('preserves foreign URL in %s', async mod => {
const { link, world } = createWorld()
const foreign = 'openai_base_url = "https://other.example"\n'
world.files.set(PATHS.codexConfig, foreign)
await link.apply({ mod })
expect(world.files.get(PATHS.codexConfig)).toBe(foreign)
expect(spawns(world)).toHaveLength(0)
})
})
+22 -4
View File
@@ -186,6 +186,7 @@ export function createCodexLink(deps: CodexLinkDeps): CodexLink {
bridge: BridgeProbe bridge: BridgeProbe
): CodexLinkStatus => ({ ): CodexLinkStatus => ({
enabled, enabled,
mod: readSettings().mod,
codexFound: config.found, codexFound: config.found,
venvReady: interpreter() !== null, venvReady: interpreter() !== null,
foreign: config.owner === 'foreign', foreign: config.owner === 'foreign',
@@ -285,7 +286,16 @@ export function createCodexLink(deps: CodexLinkDeps): CodexLink {
} }
try { try {
deps.writeTextAtomic(path.join(paths.bridgeHome, 'bridge.json'), serializeCodexBridgeConfig()) const bridgeJson = path.join(paths.bridgeHome, 'bridge.json')
let previous: null | string = null
try {
previous = deps.readText(bridgeJson)
} catch {
previous = null
}
deps.writeTextAtomic(bridgeJson, serializeCodexBridgeConfig(previous))
} catch (error) { } catch (error) {
fail('bridge-config-failed', error) fail('bridge-config-failed', error)
@@ -329,7 +339,8 @@ export function createCodexLink(deps: CodexLinkDeps): CodexLink {
return bridge return bridge
} }
const edit = addCodexLinkLine(config.text ?? '') const edit =
readSettings().mod === 'aiturk' ? addCodexLinkLine(config.text ?? '') : removeCodexLinkLine(config.text ?? '')
if (edit.changed) { if (edit.changed) {
try { try {
@@ -364,9 +375,16 @@ export function createCodexLink(deps: CodexLinkDeps): CodexLink {
const run = async (patch?: CodexLinkPatch): Promise<CodexLinkStatus> => { const run = async (patch?: CodexLinkPatch): Promise<CodexLinkStatus> => {
lastError = null lastError = null
if (typeof patch?.enabled === 'boolean') { if (typeof patch?.enabled === 'boolean' || patch?.mod === 'aiturk' || patch?.mod === 'dogrudan') {
try { try {
deps.writeTextAtomic(paths.settingsFile, serializeCodexLinkSettings(readSettings().stored, patch.enabled)) deps.writeTextAtomic(
paths.settingsFile,
serializeCodexLinkSettings(
readSettings().stored,
patch?.enabled ?? readSettings().enabled,
patch?.mod ?? readSettings().mod
)
)
} catch (error) { } catch (error) {
// The preference did not stick, so do not act on it either: what the // The preference did not stick, so do not act on it either: what the
// machine does must match what the file says after a restart. // machine does must match what the file says after a restart.
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { serializeCodexBridgeConfig } from './codex-link-config'
import { onizle, readAdaylar, readCodexListe, writeCodexListe } from './codex-liste-settings'
const aday = (slug: string, extra: Record<string, unknown> = {}) => ({ slug, ad: slug, kind: 'model', medya: false, efor: false, neden: '', sira: null, menude: false, ...extra })
describe('Codex model picker list setting', () => {
it('defaults to hiding media models and effort copies, with nothing pinned or hidden', () => {
expect(readCodexListe(null)).toEqual({ medya_gizle: true, efor_gizle: true, one_al: [], gizli: [] })
expect(readCodexListe('bozuk').medya_gizle).toBe(true)
expect(readCodexListe('{"codex_liste":{"efor_gizle":false,"one_al":["a","a",3]}}')).toEqual({ medya_gizle: true, efor_gizle: false, one_al: ['a'], gizli: [] })
})
it('writes only codex_liste, keeps the rest of bridge.json, and a pin wins over hiding', () => {
const raw = '{"port":8795,"aiturk_hat":{"secim":"onerilen"}}'
const out = JSON.parse(writeCodexListe(raw, { medya_gizle: false, efor_gizle: true, one_al: ['x'], gizli: ['x', 'y'] }))
expect(out).toEqual({ port: 8795, aiturk_hat: { secim: 'onerilen' }, codex_liste: { medya_gizle: false, efor_gizle: true, one_al: ['x'], gizli: ['y'] } })
expect(() => writeCodexListe('{bozuk', { one_al: [], gizli: [] })).toThrow()
expect(() => writeCodexListe(null, { one_al: 'x', gizli: [] })).toThrow()
})
it('reads the candidate list the bridge wrote and survives a missing or broken file', () => {
expect(readAdaylar(null)).toEqual({ adaylar: [], ozgunSayisi: 0, sinir: 100, at: null })
const st = readAdaylar(JSON.stringify({ sinir: 100, ozgun_sayisi: 9, at: 't', adaylar: [aday('ide/a', { neden: 'medya', medya: true }), { yanlis: 1 }] }))
expect(st.ozgunSayisi).toBe(9)
expect(st.adaylar).toHaveLength(1)
expect(st.adaylar[0].neden).toBe('medya')
})
it('previews the menu like the bridge: pins first, filters, and the 100 limit counts subscription models', () => {
const adaylar = [aday('ide/a'), aday('ide/b', { efor: true }), aday('ide/c', { medya: true }), ...Array.from({ length: 100 }, (_, i) => aday(`ide/m${i}`))]
const sonuc = onizle({ adaylar, ozgunSayisi: 2, sinir: 100 }, { medya_gizle: true, efor_gizle: true, one_al: ['ide/m99', 'ide/c'], gizli: ['ide/a'] })
const bul = (s: string) => sonuc.find(a => a.slug === s)!
expect(bul('ide/m99')).toMatchObject({ sira: 2, menude: true, neden: '' })
expect(bul('ide/c')).toMatchObject({ sira: 3, neden: '' }) // pinned media model is shown
expect(bul('ide/a').neden).toBe('gizli')
expect(bul('ide/b').neden).toBe('efor')
expect(sonuc.filter(a => a.menude)).toHaveLength(98)
expect(bul('ide/m98').menude).toBe(false)
})
it('survives the IDE rewriting bridge.json together with the work routing choice', () => {
const previous = JSON.stringify({ port: 1, aiturk_hat: { secim: 'kapali' }, codex_liste: { medya_gizle: false, efor_gizle: true, one_al: ['ide/x'], gizli: [] } })
const out = JSON.parse(serializeCodexBridgeConfig(previous))
expect(out.aiturk_hat).toEqual({ secim: 'kapali' })
expect(out.codex_liste).toEqual({ medya_gizle: false, efor_gizle: true, one_al: ['ide/x'], gizli: [] })
expect(JSON.parse(serializeCodexBridgeConfig('bozuk')).codex_liste).toBeUndefined()
})
})
@@ -0,0 +1,115 @@
import fs from 'node:fs'
import path from 'node:path'
import { type CodexAday, CODEX_LISTE_VARSAYILAN, CODEX_SECICI_SINIRI, type CodexListeSettings, type CodexListeState } from './codex-liste-types'
export type { CodexAday, CodexListeSettings, CodexListeState } from './codex-liste-types'
export { onizle } from './codex-liste-types'
const SINIR = CODEX_SECICI_SINIRI
const MAX_SLUGS = 2000
const VARSAYILAN: CodexListeSettings = CODEX_LISTE_VARSAYILAN
function slugs(value: unknown): string[] {
if (!Array.isArray(value)) return []
return [...new Set(value.filter((s): s is string => typeof s === 'string' && s.trim() !== ''))].slice(0, MAX_SLUGS)
}
export function readCodexListe(raw: null | string): CodexListeSettings {
try {
const liste = raw ? JSON.parse(raw)?.codex_liste : null
if (!liste || typeof liste !== 'object') return { ...VARSAYILAN, one_al: [], gizli: [] }
return {
medya_gizle: liste.medya_gizle !== false,
efor_gizle: liste.efor_gizle !== false,
one_al: slugs(liste.one_al),
gizli: slugs(liste.gizli)
}
} catch {
return { ...VARSAYILAN, one_al: [], gizli: [] }
}
}
export function writeCodexListe(raw: null | string, value: unknown): string {
const v = value as Partial<CodexListeSettings> | null
if (!v || typeof v !== 'object') throw new Error('Invalid setting')
if (!Array.isArray(v.one_al) || !Array.isArray(v.gizli)) throw new Error('Invalid model list')
let config: Record<string, unknown> = {}
try {
const parsed = raw ? JSON.parse(raw) : {}
config = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
throw new Error('bridge.json is not valid JSON')
}
const liste: CodexListeSettings = {
medya_gizle: v.medya_gizle !== false,
efor_gizle: v.efor_gizle !== false,
one_al: slugs(v.one_al),
gizli: slugs(v.gizli).filter(s => !slugs(v.one_al).includes(s))
}
return `${JSON.stringify({ ...config, codex_liste: liste }, null, 2)}\n`
}
export function readAdaylar(raw: null | string): Omit<CodexListeState, 'ayar'> {
const bos = { adaylar: [], ozgunSayisi: 0, sinir: SINIR, at: null }
try {
const veri = raw ? JSON.parse(raw) : null
if (!veri || !Array.isArray(veri.adaylar)) return bos
const adaylar: CodexAday[] = veri.adaylar
.filter((a: unknown): a is Record<string, unknown> => Boolean(a) && typeof (a as { slug?: unknown }).slug === 'string')
.map((a: Record<string, unknown>) => ({
slug: String(a.slug),
ad: typeof a.ad === 'string' ? a.ad : String(a.slug),
kind: typeof a.kind === 'string' ? a.kind : 'model',
medya: a.medya === true,
efor: a.efor === true,
neden: (['efor', 'gizli', 'medya'].includes(String(a.neden)) ? a.neden : '') as CodexAday['neden'],
sira: typeof a.sira === 'number' ? a.sira : null,
menude: a.menude === true
}))
return {
adaylar,
ozgunSayisi: typeof veri.ozgun_sayisi === 'number' ? veri.ozgun_sayisi : 0,
sinir: typeof veri.sinir === 'number' ? veri.sinir : SINIR,
at: typeof veri.at === 'string' ? veri.at : null
}
} catch {
return bos
}
}
export function createCodexListeStore(directory: string) {
const file = path.join(directory, 'bridge.json')
const adayFile = path.join(directory, 'log', 'son-liste', 'codex-adaylar.json')
const read = (f: string) => (fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : null)
const state = (raw: null | string): CodexListeState => ({ ayar: readCodexListe(raw), ...readAdaylar(read(adayFile)) })
return {
get: (): CodexListeState => state(read(file)),
save(value: unknown): CodexListeState {
const text = writeCodexListe(read(file), value)
fs.mkdirSync(directory, { recursive: true })
const tmp = `${file}.${process.pid}.tmp`
try {
fs.writeFileSync(tmp, text, 'utf8')
fs.renameSync(tmp, file)
} finally {
if (fs.existsSync(tmp)) fs.unlinkSync(tmp)
}
return state(text)
}
}
}
@@ -0,0 +1,62 @@
/** Pure part of the Codex model-list setting (no Node imports), shared by the main process and the
* settings screen. */
/** Which added models Codex's model picker shows. The Codex desktop app asks for one page of at most
* 100 models and never asks for the next page, so the user chooses which models fill it. Stored in the
* Codex bridge's `bridge.json` under `codex_liste`; the bridge re-reads it when the file changes and writes
* the candidate list it built to `log/son-liste/codex-adaylar.json`. */
export interface CodexListeSettings {
medya_gizle: boolean
efor_gizle: boolean
one_al: string[]
gizli: string[]
}
export interface CodexAday {
slug: string
ad: string
kind: string
medya: boolean
efor: boolean
neden: '' | 'efor' | 'gizli' | 'medya'
sira: null | number
menude: boolean
}
export interface CodexListeState {
ayar: CodexListeSettings
adaylar: CodexAday[]
ozgunSayisi: number
sinir: number
at: null | string
}
export const CODEX_SECICI_SINIRI = 100
export const CODEX_LISTE_VARSAYILAN: CodexListeSettings = { medya_gizle: true, efor_gizle: true, one_al: [], gizli: [] }
/** The menu the setting produces, computed the way the bridge does it, so the screen can show the effect
* before the bridge rebuilds the list. Candidates arrive in the bridge's base order. */
export function onizle(state: Pick<CodexListeState, 'adaylar' | 'ozgunSayisi' | 'sinir'>, ayar: CodexListeSettings) {
const one = ayar.one_al.filter(s => state.adaylar.some(a => a.slug === s))
const gizli = new Set(ayar.gizli)
const neden = (a: CodexAday): CodexAday['neden'] => {
if (gizli.has(a.slug)) return 'gizli'
if (one.includes(a.slug)) return ''
if (a.medya && ayar.medya_gizle) return 'medya'
if (a.efor && ayar.efor_gizle) return 'efor'
return ''
}
const kalan = state.adaylar.filter(a => !neden(a))
const sirali = [...one.map(s => kalan.find(a => a.slug === s)).filter((a): a is CodexAday => Boolean(a)), ...kalan.filter(a => !one.includes(a.slug))]
const yer = new Map(sirali.map((a, i) => [a.slug, state.ozgunSayisi + i]))
return state.adaylar.map(a => {
const n = neden(a)
const sira = n ? null : (yer.get(a.slug) ?? null)
return { ...a, neden: n, sira, menude: sira !== null && sira < state.sinir }
})
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { readIsHatti, writeIsHatti } from './is-hatti-settings'
describe('AITURK iş hattı settings', () => {
it('defaults to the recommended rule and reads older files the way the bridge does', () => {
expect(readIsHatti(null)).toEqual({ secim: 'onerilen', talimat: '' })
expect(readIsHatti('{"aiturk_hat":{"enabled":false}}').secim).toBe('kapali')
expect(readIsHatti('{"aiturk_hat":{"talimat":"x"}}')).toEqual({ secim: 'ozel', talimat: 'x' })
expect(readIsHatti('bozuk').secim).toBe('onerilen')
})
it('writes only aiturk_hat and keeps everything else', () => {
const raw = '{"port":8795,"aiturk_hat":{"enabled":false,"komut":"k"}}'
const out = JSON.parse(writeIsHatti(raw, { secim: 'buyuk', talimat: '' }))
expect(out).toEqual({ port: 8795, aiturk_hat: { komut: 'k', secim: 'buyuk', talimat: '' } })
})
it('rejects an empty custom text and unknown choices', () => {
expect(() => writeIsHatti(null, { secim: 'ozel', talimat: ' ' })).toThrow()
expect(() => writeIsHatti(null, { secim: 'x', talimat: '' })).toThrow()
expect(() => writeIsHatti('{bozuk', { secim: 'onerilen', talimat: '' })).toThrow()
})
})
@@ -0,0 +1,82 @@
import fs from 'node:fs'
import path from 'node:path'
/** "AITURK iş hattı" choice, stored in the Codex bridge's `bridge.json` under `aiturk_hat`.
* The bridge re-reads the field when the file changes, so no restart is needed. */
export type IsHattiSecim = 'onerilen' | 'buyuk' | 'kapali' | 'ozel'
export interface IsHattiSettings {
secim: IsHattiSecim
talimat: string
}
const SECIMLER: IsHattiSecim[] = ['onerilen', 'buyuk', 'kapali', 'ozel']
const MAX_TALIMAT = 20000
export function readIsHatti(raw: null | string): IsHattiSettings {
try {
const hat = raw ? JSON.parse(raw)?.aiturk_hat : null
if (!hat || typeof hat !== 'object') {
return { secim: 'onerilen', talimat: '' }
}
const talimat = typeof hat.talimat === 'string' ? hat.talimat : ''
if (SECIMLER.includes(hat.secim)) {
return { secim: hat.secim, talimat }
}
// Written before this screen existed: same meaning the bridge gives it.
return { secim: hat.enabled === false ? 'kapali' : talimat.trim() ? 'ozel' : 'onerilen', talimat }
} catch {
return { secim: 'onerilen', talimat: '' }
}
}
export function writeIsHatti(raw: null | string, value: unknown): string {
const v = value as Partial<IsHattiSettings> | null
if (!v || !SECIMLER.includes(v.secim as IsHattiSecim)) throw new Error('Invalid choice')
const talimat = typeof v.talimat === 'string' ? v.talimat : ''
if (talimat.length > MAX_TALIMAT) throw new Error('Instruction too long')
if (v.secim === 'ozel' && !talimat.trim()) throw new Error('Custom instruction is empty')
let config: Record<string, unknown> = {}
try {
const parsed = raw ? JSON.parse(raw) : {}
config = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
throw new Error('bridge.json is not valid JSON')
}
const old = config.aiturk_hat && typeof config.aiturk_hat === 'object' ? (config.aiturk_hat as Record<string, unknown>) : {}
const hat: Record<string, unknown> = { ...old, secim: v.secim, talimat }
delete hat.enabled
return `${JSON.stringify({ ...config, aiturk_hat: hat }, null, 2)}\n`
}
export function createIsHattiStore(directory: string) {
const file = path.join(directory, 'bridge.json')
const read = () => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null)
return {
get: (): IsHattiSettings => readIsHatti(read()),
save(value: unknown): IsHattiSettings {
const text = writeIsHatti(read(), value)
fs.mkdirSync(directory, { recursive: true })
const tmp = `${file}.${process.pid}.tmp`
try {
fs.writeFileSync(tmp, text, 'utf8')
fs.renameSync(tmp, file)
} finally {
if (fs.existsSync(tmp)) fs.unlinkSync(tmp)
}
return readIsHatti(text)
}
}
}
+25
View File
@@ -380,6 +380,8 @@ import { collectSshConfigHosts, parseSshGOutput } from './ssh-config'
import { createSshProbeConnection, pickLocalPort, redactSecrets, SshConnection } from './ssh-connection' import { createSshProbeConnection, pickLocalPort, redactSecrets, SshConnection } from './ssh-connection'
import { createStagedTransitionStore } from './staged-transition' import { createStagedTransitionStore } from './staged-transition'
import { createCompressionStore } from './compression-settings' import { createCompressionStore } from './compression-settings'
import { createCodexListeStore } from './codex-liste-settings'
import { createIsHattiStore } from './is-hatti-settings'
import { createStreamThrottle } from './stream-throttle' import { createStreamThrottle } from './stream-throttle'
import { registerTerminalIpc } from './terminal-ipc' import { registerTerminalIpc } from './terminal-ipc'
import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width' import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width'
@@ -17195,6 +17197,8 @@ ipcMain.handle('hermes:quick-entry:settings:set', async (_event, patch) => {
// a Run-key value). All decisions live in electron/codex-link.ts. // a Run-key value). All decisions live in electron/codex-link.ts.
const AITURK_IDE_DATA_ROOT = path.dirname(HERMES_HOME) const AITURK_IDE_DATA_ROOT = path.dirname(HERMES_HOME)
const compressionStore = createCompressionStore(path.join(AITURK_IDE_DATA_ROOT, 'codex-bridge')) const compressionStore = createCompressionStore(path.join(AITURK_IDE_DATA_ROOT, 'codex-bridge'))
const isHattiStore = createIsHattiStore(path.join(AITURK_IDE_DATA_ROOT, 'codex-bridge'))
const codexListeStore = createCodexListeStore(path.join(AITURK_IDE_DATA_ROOT, 'codex-bridge'))
const stagedTransition = createStagedTransitionStore( const stagedTransition = createStagedTransitionStore(
path.join(AITURK_IDE_DATA_ROOT, 'codex-bridge'), path.join(AITURK_IDE_DATA_ROOT, 'codex-bridge'),
path.join(app.getPath('home'), '.typesafe', 'typesafe.env'), path.join(app.getPath('home'), '.typesafe', 'typesafe.env'),
@@ -17307,6 +17311,26 @@ ipcMain.handle('aiturk:compression:set', async (event, settings) => {
return compressionState() return compressionState()
}) })
ipcMain.handle('aiturk:is-hatti:get', async event => {
assertCodexLinkSender(event)
return isHattiStore.get()
})
ipcMain.handle('aiturk:is-hatti:set', async (event, settings) => {
assertCodexLinkSender(event)
return isHattiStore.save(settings)
})
ipcMain.handle('aiturk:codex-liste:get', async event => {
assertCodexLinkSender(event)
return codexListeStore.get()
})
ipcMain.handle('aiturk:codex-liste:set', async (event, settings) => {
assertCodexLinkSender(event)
return codexListeStore.save(settings)
})
ipcMain.handle('aiturk:staged-transition:set', async (event, patch) => { ipcMain.handle('aiturk:staged-transition:set', async (event, patch) => {
assertCodexLinkSender(event) assertCodexLinkSender(event)
stagedTransition.save(patch) stagedTransition.save(patch)
@@ -17318,6 +17342,7 @@ ipcMain.handle('aiturk:codex-link:set', async (event, patch) => {
return codexLink.apply({ return codexLink.apply({
enabled: typeof patch?.enabled === 'boolean' ? patch.enabled : undefined, enabled: typeof patch?.enabled === 'boolean' ? patch.enabled : undefined,
mod: patch?.mod === 'aiturk' || patch?.mod === 'dogrudan' ? patch.mod : undefined,
sync: patch?.sync === true sync: patch?.sync === true
}) })
}) })
+4
View File
@@ -297,6 +297,10 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
codexLink: { codexLink: {
getCompression: () => ipcRenderer.invoke('aiturk:compression:get'), getCompression: () => ipcRenderer.invoke('aiturk:compression:get'),
setCompression: settings => ipcRenderer.invoke('aiturk:compression:set', settings), setCompression: settings => ipcRenderer.invoke('aiturk:compression:set', settings),
getIsHatti: () => ipcRenderer.invoke('aiturk:is-hatti:get'),
setIsHatti: settings => ipcRenderer.invoke('aiturk:is-hatti:set', settings),
getCodexListe: () => ipcRenderer.invoke('aiturk:codex-liste:get'),
setCodexListe: settings => ipcRenderer.invoke('aiturk:codex-liste:set', settings),
getStagedTransition: () => ipcRenderer.invoke('aiturk:staged-transition:get'), getStagedTransition: () => ipcRenderer.invoke('aiturk:staged-transition:get'),
setStagedTransition: patch => ipcRenderer.invoke('aiturk:staged-transition:set', patch), setStagedTransition: patch => ipcRenderer.invoke('aiturk:staged-transition:set', patch),
get: () => ipcRenderer.invoke('aiturk:codex-link:get'), get: () => ipcRenderer.invoke('aiturk:codex-link:get'),
@@ -12,11 +12,35 @@ export interface StagedTransitionSettings {
maxConcurrent: number maxConcurrent: number
queueWaitSeconds: number queueWaitSeconds: number
failureAction: 'pass' | 'next' failureAction: 'pass' | 'next'
// Schema 2 (14 Oct 2026). A schema 1 file gets these from the defaults; every stage starts disabled.
watchModels: string[]
prefilter: { enabled: boolean; question: string; threshold: number; target: string; timeoutSeconds: number }
autoRoute: {
enabled: boolean
model: string
timeoutSeconds: number
questions: { zor: string; arastirma: string }
thresholds: { zor: number; arastirma: number }
targets: { basit: string; zor: string; arastirma: string }
}
finishCheck: { enabled: boolean; question: string; threshold: number; note: string }
verifierCheck: { enabled: boolean; question: string; threshold: number }
policy: { source: 'local' | 'dify'; endpoint: string; refreshMinutes: number }
}
export interface StagedTransitionPolicyStatus {
fetchedAt?: string
lastGoodAt?: string
ok: boolean
error?: string
sections: string[]
} }
export interface StagedTransitionState { export interface StagedTransitionState {
settings: StagedTransitionSettings settings: StagedTransitionSettings
keyConfigured: boolean keyConfigured: boolean
policyKeyConfigured?: boolean
policyStatus?: StagedTransitionPolicyStatus | null
models?: { id: string; name: string }[] models?: { id: string; name: string }[]
modelsAvailable?: boolean modelsAvailable?: boolean
} }
@@ -24,4 +48,5 @@ export interface StagedTransitionState {
export interface StagedTransitionPatch { export interface StagedTransitionPatch {
settings: StagedTransitionSettings settings: StagedTransitionSettings
apiKey?: string apiKey?: string
policyApiKey?: string
} }
@@ -4,7 +4,12 @@ import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it } from 'vitest'
import { createStagedTransitionStore, stagedTransitionDefaults, validateStagedTransition } from './staged-transition' import {
createStagedTransitionStore,
readPolicyStatus,
stagedTransitionDefaults,
validateStagedTransition
} from './staged-transition'
const dirs: string[] = [] const dirs: string[] = []
afterEach(() => { afterEach(() => {
@@ -50,4 +55,63 @@ describe('staged transition settings', () => {
expect(() => validateStagedTransition({ ...before.settings, thresholds: { ret: NaN, sart: 0.7 } })).toThrow() expect(() => validateStagedTransition({ ...before.settings, thresholds: { ret: NaN, sart: 0.7 } })).toThrow()
expect(() => validateStagedTransition({ ...before.settings, endpoint: 'http://example.com' })).toThrow() expect(() => validateStagedTransition({ ...before.settings, endpoint: 'http://example.com' })).toThrow()
}) })
it('loads a schema 1 file with every new stage disabled and keeps unknown-free output', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jev-settings-test-'))
dirs.push(dir)
const old = structuredClone(stagedTransitionDefaults) as unknown as Record<string, unknown>
for (const key of ['watchModels', 'prefilter', 'autoRoute', 'finishCheck', 'verifierCheck', 'policy']) {
delete old[key]
}
fs.writeFileSync(path.join(dir, 'jev-settings.json'), JSON.stringify({ settings: old, protectedKey: 'k' }))
const store = createStagedTransitionStore(dir, path.join(dir, 'missing.env'), x => x)
const state = store.get()
expect(state.settings.prefilter.enabled).toBe(false)
expect(state.settings.autoRoute.enabled).toBe(false)
expect(state.settings.finishCheck.enabled).toBe(false)
expect(state.settings.verifierCheck.enabled).toBe(false)
expect(state.settings.policy.source).toBe('local')
expect(state.keyConfigured).toBe(true)
const partial = { ...structuredClone(stagedTransitionDefaults), prefilter: { enabled: true, threshold: 0.8 } }
const filled = validateStagedTransition(partial)
expect(filled.prefilter.enabled).toBe(true)
expect(filled.prefilter.threshold).toBe(0.8)
expect(filled.prefilter.target).toBe(stagedTransitionDefaults.prefilter.target)
})
it('keeps the Dify key protected and reports the last rules fetch', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jev-settings-test-'))
dirs.push(dir)
const store = createStagedTransitionStore(dir, path.join(dir, 'missing.env'), () => 'protected-value')
const settings = { ...structuredClone(stagedTransitionDefaults), policy: { ...stagedTransitionDefaults.policy, source: 'dify' as const } }
const saved = store.save({ settings, policyApiKey: 'app-dummy-secret' })
expect(saved.policyKeyConfigured).toBe(true)
expect(fs.readFileSync(path.join(dir, 'jev-settings.json'), 'utf8')).not.toContain('app-dummy-secret')
expect(store.save({ settings }).policyKeyConfigured).toBe(true)
expect(store.save({ settings, policyApiKey: '' }).policyKeyConfigured).toBe(false)
expect(readPolicyStatus(dir)).toBeNull()
fs.writeFileSync(
path.join(dir, 'jev-policy.json'),
JSON.stringify({ fetchedAt: 'now', ok: true, error: 'Dify down', overrides: { autoRoute: { enabled: true } } })
)
expect(store.get().policyStatus).toEqual({ fetchedAt: 'now', lastGoodAt: undefined, ok: true, error: 'Dify down', sections: ['autoRoute'] })
})
it('rejects invalid decision stage values', () => {
const base = structuredClone(stagedTransitionDefaults)
expect(() => validateStagedTransition({ ...base, prefilter: { ...base.prefilter, threshold: 2 } })).toThrow()
expect(() => validateStagedTransition({ ...base, prefilter: { ...base.prefilter, timeoutSeconds: 0 } })).toThrow()
expect(() =>
validateStagedTransition({ ...base, autoRoute: { ...base.autoRoute, targets: { ...base.autoRoute.targets, zor: 'Otomatik' } } })
).toThrow()
expect(() => validateStagedTransition({ ...base, finishCheck: { ...base.finishCheck, note: '' } })).toThrow()
expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, endpoint: 'http://dify.example' } })).toThrow()
expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, source: 'web' } })).toThrow()
expect(() => validateStagedTransition({ ...base, watchModels: ['a', 'a'] })).toThrow()
for (const endpoint of ['https:dify.example/v1', 'https:/dify.example', 'https:///dify.example', 'ftp://dify.example']) {
expect(() => validateStagedTransition({ ...base, policy: { ...base.policy, endpoint } })).toThrow()
expect(() => validateStagedTransition({ ...base, endpoint })).toThrow()
}
expect(() =>
validateStagedTransition({ ...base, prefilter: { ...base.prefilter, target: base.autoRoute.model } })
).toThrow()
})
}) })
+190 -35
View File
@@ -3,16 +3,78 @@ import path from 'node:path'
import defaults from '../assets/codex-bridge/jev-settings-defaults.json' import defaults from '../assets/codex-bridge/jev-settings-defaults.json'
import type { StagedTransitionPatch, StagedTransitionSettings, StagedTransitionState } from './staged-transition-types' import type {
StagedTransitionPatch,
StagedTransitionPolicyStatus,
StagedTransitionSettings,
StagedTransitionState
} from './staged-transition-types'
export const stagedTransitionDefaults = defaults as StagedTransitionSettings export const stagedTransitionDefaults = defaults as StagedTransitionSettings
export function validateStagedTransition(value: unknown): StagedTransitionSettings { // Sections added in schema 2 (14 Oct 2026). Mirrors codex-bridge/jev_settings.py: a missing section or
if (!value || typeof value !== 'object') { // field comes from the defaults, so a schema 1 file stays valid and every new stage starts disabled.
const SECTIONS = ['watchModels', 'prefilter', 'autoRoute', 'finishCheck', 'verifierCheck', 'policy'] as const
const POLICY_FILE = 'jev-policy.json'
type Plain = Record<string, unknown>
const isPlain = (value: unknown): value is Plain => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
function fill(value: unknown, fallback: unknown): unknown {
if (!isPlain(fallback) || !isPlain(value)) {
return value
}
const out: Plain = { ...value }
for (const [key, def] of Object.entries(fallback)) {
out[key] = key in value ? fill(value[key], def) : structuredClone(def)
}
return out
}
export function withSchemaDefaults(value: unknown): unknown {
if (!isPlain(value)) {
return value
}
const out: Plain = { ...value }
const defs = stagedTransitionDefaults as unknown as Plain
for (const section of SECTIONS) {
out[section] = section in value ? fill(value[section], defs[section]) : structuredClone(defs[section])
}
return out
}
const text = (s: unknown, max = 10000): s is string => typeof s === 'string' && s.trim().length > 0 && s.length <= max
const probability = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n) && n >= 0 && n <= 1
const integer = (n: unknown, min: number, max: number): n is number =>
typeof n === 'number' && Number.isInteger(n) && n >= min && n <= max
// Same acceptance as the bridge (urllib.parse.urlsplit): the literal https:// prefix and a host. WHATWG URL alone
// also accepts "https:host" and "https:/host", which the bridge rejects.
function https(value: unknown): value is string {
if (!text(value, 2048) || !/^https:\/\/[^/?#\s]/i.test(value.trim())) {
return false
}
let url: URL
try {
url = new URL(value.trim())
} catch {
return false
}
return url.protocol === 'https:' && Boolean(url.hostname) && !url.username && !url.password && !url.hash
}
export function validateStagedTransition(input: unknown): StagedTransitionSettings {
if (!input || typeof input !== 'object') {
throw new Error('Invalid settings') throw new Error('Invalid settings')
} }
const v = value as StagedTransitionSettings const v = withSchemaDefaults(input) as StagedTransitionSettings
const text = (s: unknown, max = 10000): s is string => typeof s === 'string' && s.trim().length > 0 && s.length <= max
const list = (s: unknown, allowEmpty: boolean) => const list = (s: unknown, allowEmpty: boolean) =>
Array.isArray(s) && Array.isArray(s) &&
@@ -21,15 +83,13 @@ export function validateStagedTransition(value: unknown): StagedTransitionSettin
s.every(x => text(x, 256)) && s.every(x => text(x, 256)) &&
new Set(s.map(x => x.trim())).size === s.length new Set(s.map(x => x.trim())).size === s.length
const seconds = (n: unknown) => typeof n === 'number' && Number.isInteger(n) && n >= 1 && n <= 3600 const seconds = (n: unknown) => integer(n, 1, 3600)
if (typeof v.enabled !== 'boolean' || !text(v.endpoint, 2048) || !text(v.model, 256)) { if (typeof v.enabled !== 'boolean' || !text(v.endpoint, 2048) || !text(v.model, 256)) {
throw new Error('Invalid connection settings') throw new Error('Invalid connection settings')
} }
const url = new URL(v.endpoint) if (!https(v.endpoint)) {
if (url.protocol !== 'https:' || url.username || url.password || url.hash) {
throw new Error('HTTPS endpoint required') throw new Error('HTTPS endpoint required')
} }
@@ -60,13 +120,7 @@ export function validateStagedTransition(value: unknown): StagedTransitionSettin
} }
for (const id of ['ret', 'sart'] as const) { for (const id of ['ret', 'sart'] as const) {
if ( if (!text(v.questions?.[id]) || !probability(v.thresholds?.[id])) {
!text(v.questions?.[id]) ||
typeof v.thresholds?.[id] !== 'number' ||
!Number.isFinite(v.thresholds[id]) ||
v.thresholds[id] < 0 ||
v.thresholds[id] > 1
) {
throw new Error('Invalid evaluation rule') throw new Error('Invalid evaluation rule')
} }
@@ -80,28 +134,117 @@ export function validateStagedTransition(value: unknown): StagedTransitionSettin
if ( if (
!seconds(v.timeoutSeconds) || !seconds(v.timeoutSeconds) ||
!seconds(v.queueWaitSeconds) || !seconds(v.queueWaitSeconds) ||
!Number.isInteger(v.maxConcurrent) || !integer(v.maxConcurrent, 0, 512) ||
v.maxConcurrent < 0 ||
v.maxConcurrent > 512 ||
!['pass', 'next'].includes(v.failureAction) !['pass', 'next'].includes(v.failureAction)
) { ) {
throw new Error('Invalid request limits') throw new Error('Invalid request limits')
} }
if (!list(v.watchModels, true)) {
throw new Error('Invalid watch models')
}
const p = v.prefilter
if (
typeof p?.enabled !== 'boolean' ||
!text(p.question) ||
!probability(p.threshold) ||
!text(p.target, 256) ||
!integer(p.timeoutSeconds, 1, 60)
) {
throw new Error('Invalid prefilter')
}
const a = v.autoRoute
if (
typeof a?.enabled !== 'boolean' ||
!text(a.model, 256) ||
!integer(a.timeoutSeconds, 1, 60) ||
(['zor', 'arastirma'] as const).some(k => !text(a.questions?.[k]) || !probability(a.thresholds?.[k])) ||
(['basit', 'zor', 'arastirma'] as const).some(k => !text(a.targets?.[k], 256))
) {
throw new Error('Invalid automatic routing')
}
if (Object.values(a.targets).some(t => t.trim() === a.model.trim()) || p.target.trim() === a.model.trim()) {
throw new Error('Automatic routing cannot target itself')
}
const f = v.finishCheck
if (
typeof f?.enabled !== 'boolean' ||
!text(f.question) ||
!probability(f.threshold) ||
!text(f.note, 4000)
) {
throw new Error('Invalid finish check')
}
const c = v.verifierCheck
if (typeof c?.enabled !== 'boolean' || !text(c.question) || !probability(c.threshold)) {
throw new Error('Invalid verifier check')
}
const pol = v.policy
if (!['local', 'dify'].includes(pol?.source) || !https(pol.endpoint) || !integer(pol.refreshMinutes, 1, 1440)) {
throw new Error('Invalid policy source')
}
const trimmed = (s: string) => s.trim()
return { return {
enabled: v.enabled, enabled: v.enabled,
endpoint: v.endpoint.trim(), endpoint: v.endpoint.trim(),
model: v.model.trim(), model: v.model.trim(),
fallbackModel: v.fallbackModel.trim(), fallbackModel: v.fallbackModel.trim(),
rejectionRules: v.rejectionRules.map(r => ({ from: r.from.trim(), to: r.to.trim() })), rejectionRules: v.rejectionRules.map(r => ({ from: r.from.trim(), to: r.to.trim() })),
exemptModels: v.exemptModels.map(x => x.trim()), exemptModels: v.exemptModels.map(trimmed),
questions: { ...v.questions }, questions: { ...v.questions },
thresholds: { ...v.thresholds }, thresholds: { ...v.thresholds },
criteria: structuredClone(v.criteria), criteria: structuredClone(v.criteria),
timeoutSeconds: v.timeoutSeconds, timeoutSeconds: v.timeoutSeconds,
maxConcurrent: v.maxConcurrent, maxConcurrent: v.maxConcurrent,
queueWaitSeconds: v.queueWaitSeconds, queueWaitSeconds: v.queueWaitSeconds,
failureAction: v.failureAction failureAction: v.failureAction,
watchModels: v.watchModels.map(trimmed),
prefilter: { ...p, target: p.target.trim() },
autoRoute: {
enabled: a.enabled,
model: a.model.trim(),
timeoutSeconds: a.timeoutSeconds,
questions: { zor: a.questions.zor, arastirma: a.questions.arastirma },
thresholds: { zor: a.thresholds.zor, arastirma: a.thresholds.arastirma },
targets: { basit: a.targets.basit.trim(), zor: a.targets.zor.trim(), arastirma: a.targets.arastirma.trim() }
},
finishCheck: { enabled: f.enabled, question: f.question, threshold: f.threshold, note: f.note },
verifierCheck: { enabled: c.enabled, question: c.question, threshold: c.threshold },
policy: { source: pol.source, endpoint: pol.endpoint.trim(), refreshMinutes: pol.refreshMinutes }
}
}
// The bridge writes jev-policy.json after each Dify fetch; only a summary reaches the renderer.
export function readPolicyStatus(directory: string): StagedTransitionPolicyStatus | null {
try {
const data = JSON.parse(fs.readFileSync(path.join(directory, POLICY_FILE), 'utf8'))
if (!isPlain(data)) {
return null
}
return {
fetchedAt: typeof data.fetchedAt === 'string' ? data.fetchedAt : undefined,
lastGoodAt: typeof data.lastGoodAt === 'string' ? data.lastGoodAt : undefined,
ok: data.ok === true,
error: typeof data.error === 'string' && data.error ? data.error.slice(0, 500) : undefined,
sections: isPlain(data.overrides) ? Object.keys(data.overrides) : []
}
} catch {
return null
} }
} }
@@ -112,13 +255,17 @@ export function createStagedTransitionStore(
) { ) {
const file = path.join(directory, 'jev-settings.json') const file = path.join(directory, 'jev-settings.json')
const read = (): { settings: StagedTransitionSettings; protectedKey?: string } => { const read = (): { settings: StagedTransitionSettings; protectedKey?: string; protectedPolicyKey?: string } => {
if (!fs.existsSync(file)) { if (!fs.existsSync(file)) {
return { settings: structuredClone(stagedTransitionDefaults) } return { settings: structuredClone(stagedTransitionDefaults) }
} }
const data = JSON.parse(fs.readFileSync(file, 'utf8')) const data = JSON.parse(fs.readFileSync(file, 'utf8'))
return { settings: validateStagedTransition(data.settings), protectedKey: data.protectedKey } return {
settings: validateStagedTransition(data.settings),
protectedKey: data.protectedKey,
protectedPolicyKey: data.protectedPolicyKey
}
} }
const state = (): StagedTransitionState => { const state = (): StagedTransitionState => {
@@ -135,32 +282,40 @@ export function createStagedTransitionStore(
) )
} }
return { settings: current.settings, keyConfigured: Boolean(current.protectedKey) || legacy } return {
settings: current.settings,
keyConfigured: Boolean(current.protectedKey) || legacy,
policyKeyConfigured: Boolean(current.protectedPolicyKey),
policyStatus: readPolicyStatus(directory)
}
}
const secret = (value: unknown, name: string) => {
if (value !== undefined && (typeof value !== 'string' || value.length > 8192)) {
throw new Error(`Invalid ${name}`)
}
} }
return { return {
get: state, get: state,
save(patch: StagedTransitionPatch): StagedTransitionState { save(patch: StagedTransitionPatch): StagedTransitionState {
const settings = validateStagedTransition(patch?.settings) const settings = validateStagedTransition(patch?.settings)
secret(patch.apiKey, 'API key')
if (patch.apiKey !== undefined && (typeof patch.apiKey !== 'string' || patch.apiKey.length > 8192)) { secret(patch.policyApiKey, 'policy key')
throw new Error('Invalid API key')
}
const current = read() const current = read()
const protectedKey = const update = (value: string | undefined, previous: string | undefined) =>
patch.apiKey === undefined value === undefined ? previous : value.trim() ? protect(value.trim()) : undefined
? current.protectedKey
: patch.apiKey.trim() const protectedKey = update(patch.apiKey, current.protectedKey)
? protect(patch.apiKey.trim()) const protectedPolicyKey = update(patch.policyApiKey, current.protectedPolicyKey)
: undefined
fs.mkdirSync(directory, { recursive: true }) fs.mkdirSync(directory, { recursive: true })
const tmp = `${file}.${process.pid}.tmp` const tmp = `${file}.${process.pid}.tmp`
try { try {
fs.writeFileSync(tmp, JSON.stringify({ settings, protectedKey }, null, 2), { mode: 0o600 }) fs.writeFileSync(tmp, JSON.stringify({ settings, protectedKey, protectedPolicyKey }, null, 2), { mode: 0o600 })
fs.renameSync(tmp, file) fs.renameSync(tmp, file)
} finally { } finally {
if (fs.existsSync(tmp)) { if (fs.existsSync(tmp)) {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "aiturk-ide", "name": "aiturk-ide",
"productName": "AITURK IDE", "productName": "AITURK IDE",
"private": true, "private": true,
"version": "1.0.0-beta.36", "version": "1.0.0-beta.43",
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.", "description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
"author": "AITURK / TurkServis", "author": "AITURK / TurkServis",
"repository": { "repository": {
@@ -12,6 +12,7 @@ vi.mock('@/store/notifications', () => ({ notifyError }))
const RUNNING: CodexLinkStatus = { const RUNNING: CodexLinkStatus = {
enabled: true, enabled: true,
mod: 'aiturk',
codexFound: true, codexFound: true,
venvReady: true, venvReady: true,
foreign: false, foreign: false,
@@ -198,3 +199,18 @@ describe('Codex Link settings', () => {
expect(get).not.toHaveBeenCalled() expect(get).not.toHaveBeenCalled()
}) })
}) })
describe('connection mode controls', () => {
it('sends the mode and renders the saved direct preference', async () => {
get.mockResolvedValue({ ...RUNNING, mod: 'dogrudan' })
set.mockResolvedValue({ ...RUNNING, mod: 'aiturk' })
renderPage()
const direct = await screen.findByRole('radio', { name: 'Doğrudan (önerilen): abonelik modelleri köprüden bağımsız' })
await waitFor(() => expect((direct as HTMLInputElement).checked).toBe(true))
fireEvent.click(screen.getByRole('radio', { name: 'AITURK: combolar Codex listesinde, tüm modeller köprüden' }))
await waitFor(() => expect(set).toHaveBeenCalledWith({ mod: 'aiturk' }))
await waitFor(() => expect((direct as HTMLInputElement).checked).toBe(false))
expect(screen.getByText("Codex'i yeniden başlatın")).toBeTruthy()
})
})
@@ -12,6 +12,8 @@ import type { CodexLinkPatch, CodexLinkStatus } from '../../../electron/codex-li
import { EmptyState, ListRow, ListRowSkeleton, SettingsContent, SettingsSection } from './primitives' import { EmptyState, ListRow, ListRowSkeleton, SettingsContent, SettingsSection } from './primitives'
import { CompressionSettingsView } from './compression-settings' import { CompressionSettingsView } from './compression-settings'
import { CodexListeSettingsView } from './codex-liste-settings'
import { IsHattiSettingsView } from './is-hatti-settings'
type Tone = 'muted' | 'ok' | 'warn' type Tone = 'muted' | 'ok' | 'warn'
@@ -57,7 +59,7 @@ export function codexLinkStatusLines(status: CodexLinkStatus, copy: CodexLinkCop
if (status.ideDurum === 'anahtar-yok') { if (status.ideDurum === 'anahtar-yok') {
lines.push({ id: 'ide', text: copy.signIn, tone: 'warn' }) lines.push({ id: 'ide', text: copy.signIn, tone: 'warn' })
} else if (status.ideDurum === 'tamam' && status.ideModel !== null) { } else if (status.mod === 'aiturk' && status.ideDurum === 'tamam' && status.ideModel !== null) {
lines.push({ id: 'ide', text: copy.models(status.ideModel), tone: 'ok' }) lines.push({ id: 'ide', text: copy.models(status.ideModel), tone: 'ok' })
} }
@@ -170,6 +172,27 @@ export function CodexLinkSettings() {
title={copy.title} title={copy.title}
/> />
<ListRow
title={copy.modeTitle}
below={
<fieldset disabled={!status || busy !== null} className="grid gap-2 py-2">
{(['dogrudan', 'aiturk'] as const).map(mod => (
<label key={mod} className="flex min-h-11 cursor-pointer items-center gap-2">
<input
type="radio"
name="codex-link-mode"
value={mod}
checked={(status?.mod ?? 'dogrudan') === mod}
onChange={() => void change({ mod }, 'toggle')}
/>
<span>{mod === 'dogrudan' ? copy.directMode : copy.aiturkMode}</span>
</label>
))}
<p className="text-(--ui-text-tertiary)">{copy.modeRestartNote}</p>
</fieldset>
}
/>
{!status && !loadFailed && <ListRowSkeleton wide />} {!status && !loadFailed && <ListRowSkeleton wide />}
{loadFailed && !status && ( {loadFailed && !status && (
@@ -230,6 +253,8 @@ export function CodexLinkSettings() {
{copy.restartNote} {copy.restartNote}
</p> </p>
</SettingsSection> </SettingsSection>
<IsHattiSettingsView />
<CodexListeSettingsView />
<CompressionSettingsView /> <CompressionSettingsView />
</SettingsContent> </SettingsContent>
) )
@@ -0,0 +1,48 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n/context'
import { CodexListeSettingsView } from './codex-liste-settings'
afterEach(() => {
cleanup()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
const aday = (slug: string, extra: Record<string, unknown> = {}) => ({ slug, ad: slug.replace('ide/', ''), kind: 'model', medya: false, efor: false, neden: '', sira: null, menude: false, ...extra })
it('counts the menu with the subscription models, pins and hides, and saves the choice', async () => {
const adaylar = [aday('ide/agnes'), aday('ide/agnes-high', { efor: true }), aday('ide/veo', { medya: true }), ...Array.from({ length: 99 }, (_, i) => aday(`ide/m${String(i).padStart(2, '0')}`))]
const save = vi.fn(async (ayar: unknown) => ({ ayar, adaylar, ozgunSayisi: 2, sinir: 100, at: 't' }))
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
codexLink: {
getCodexListe: async () => ({ ayar: { medya_gizle: true, efor_gizle: true, one_al: [], gizli: [] }, adaylar, ozgunSayisi: 2, sinir: 100, at: 't' }),
setCodexListe: save
}
}
render(<I18nProvider configClient={null} initialLocale="tr"><CodexListeSettingsView /></I18nProvider>)
// 2 subscription + 98 of the 100 shown candidates fit; agnes-high and veo are hidden.
expect(await screen.findByText('Menüde 100 / 100 · menüye sığmayan 2 · gizli 2')).toBeTruthy()
fireEvent.change(screen.getByRole('textbox', { name: 'Model ara' }), { target: { value: 'm98' } })
const satir = screen.getByText('m98').closest('li')!
expect(within(satir).getByText('Menüye sığmıyor')).toBeTruthy()
fireEvent.click(within(satir).getByRole('button', { name: 'Öne al' }))
expect(within(satir).getByText('Menüde, 3. sıra')).toBeTruthy()
fireEvent.change(screen.getByRole('textbox', { name: 'Model ara' }), { target: { value: 'agnes' } })
fireEvent.click(within(screen.getByText('agnes').closest('li')!).getByRole('button', { name: 'Gizle' }))
fireEvent.click(screen.getByRole('switch', { name: 'Düşük/orta/yüksek kopyalarını gizle' }))
fireEvent.click(screen.getByRole('button', { name: 'Kaydet' }))
await waitFor(() => expect(save).toHaveBeenCalledWith({ medya_gizle: true, efor_gizle: false, one_al: ['ide/m98'], gizli: ['ide/agnes'] }))
expect(await screen.findByText(/Codex model listesini birkaç dakika içinde yeniler/)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Varsayılana dön' }))
await waitFor(() => expect(save).toHaveBeenLastCalledWith({ medya_gizle: true, efor_gizle: true, one_al: [], gizli: [] }))
})
it('explains when the bridge has not built the list yet', async () => {
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
codexLink: { getCodexListe: async () => ({ ayar: { medya_gizle: true, efor_gizle: true, one_al: [], gizli: [] }, adaylar: [], ozgunSayisi: 0, sinir: 100, at: null }), setCodexListe: vi.fn() }
}
render(<I18nProvider configClient={null} initialLocale="tr"><CodexListeSettingsView /></I18nProvider>)
expect(await screen.findByText(/Model listesi henüz oluşmadı/)).toBeTruthy()
})
@@ -0,0 +1,209 @@
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { useI18n } from '@/i18n/context'
import { Link } from '@/lib/icons'
import {
CODEX_LISTE_VARSAYILAN,
type CodexAday,
type CodexListeSettings,
type CodexListeState,
onizle
} from '../../../electron/codex-liste-types'
import { ListRow, SettingsSection } from './primitives'
type Filtre = 'hepsi' | 'menude' | 'disarida' | 'gizli'
/** Which added models fill the Codex model picker. The Codex desktop app shows at most 100 models (its own
* fixed limit); the user pins, hides and filters to choose which ones make it. */
export function CodexListeSettingsView() {
const api = window.hermesDesktop?.codexLink
const { locale } = useI18n()
const tr = locale === 'tr'
const t = (a: string, b: string) => (tr ? a : b)
const [state, setState] = useState<CodexListeState | null>(null)
const [draft, setDraft] = useState<CodexListeSettings | null>(null)
const [arama, setArama] = useState('')
const [filtre, setFiltre] = useState<Filtre>('hepsi')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('')
const [failed, setFailed] = useState(false)
useEffect(() => {
let active = true
api?.getCodexListe?.().then(
next => {
if (active) {
setState(next)
setDraft(next.ayar)
}
},
() => {
if (active) {
setFailed(true)
setMessage(tr ? 'Codex model listesi ayarı okunamadı.' : 'Could not load the Codex model list setting.')
}
}
)
return () => {
active = false
}
}, [api, tr])
const onizleme = useMemo(() => (state && draft ? onizle(state, draft) : []), [state, draft])
if (!api?.getCodexListe || !api.setCodexListe) return null
const menude = onizleme.filter(a => a.menude).length
const disarida = onizleme.filter(a => !a.neden && !a.menude).length
const gizlenen = onizleme.filter(a => a.neden).length
const sinir = state?.sinir ?? 100
const ozgun = state?.ozgunSayisi ?? 0
const sirali = [...onizleme].sort((a, b) => (a.sira ?? 1e9) - (b.sira ?? 1e9))
const aranan = arama.trim().toLocaleLowerCase(locale)
const gorunen = sirali.filter(a => {
if (aranan && !`${a.ad} ${a.slug}`.toLocaleLowerCase(locale).includes(aranan)) return false
if (filtre === 'menude') return a.menude
if (filtre === 'disarida') return !a.neden && !a.menude
if (filtre === 'gizli') return Boolean(a.neden)
return true
})
const degistir = (patch: Partial<CodexListeSettings>) => draft && setDraft({ ...draft, ...patch })
const oneAl = (slug: string) => {
if (!draft) return
const pinned = draft.one_al.includes(slug)
degistir({ one_al: pinned ? draft.one_al.filter(s => s !== slug) : [...draft.one_al, slug], gizli: draft.gizli.filter(s => s !== slug) })
}
const yukari = (slug: string) => {
if (!draft) return
const i = draft.one_al.indexOf(slug)
if (i <= 0) return
const next = [...draft.one_al]
;[next[i - 1], next[i]] = [next[i], next[i - 1]]
degistir({ one_al: next })
}
const gizle = (slug: string) => {
if (!draft) return
const hidden = draft.gizli.includes(slug)
degistir({ gizli: hidden ? draft.gizli.filter(s => s !== slug) : [...draft.gizli, slug], one_al: draft.one_al.filter(s => s !== slug) })
}
const save = async (next: CodexListeSettings) => {
setBusy(true)
setMessage('')
setFailed(false)
try {
const saved = await api.setCodexListe!(next)
setState({ ...saved, adaylar: state?.adaylar ?? saved.adaylar })
setDraft(saved.ayar)
setMessage(t('Kaydedildi. Codex model listesini birkaç dakika içinde yeniler (en geç 5 dakika sonra Codex penceresine dönünce).', 'Saved. Codex refreshes its model list within a few minutes (at the latest when you return to its window after 5 minutes).'))
} catch {
setFailed(true)
setMessage(t('Kaydedilemedi.', 'Could not save.'))
} finally {
setBusy(false)
}
}
const durum = (a: CodexAday) => {
if (a.neden === 'gizli') return t('Gizli', 'Hidden')
if (a.neden === 'medya') return t('Resim/ses modeli, gizli', 'Image/audio model, hidden')
if (a.neden === 'efor') return t('Düşük/orta/yüksek kopyası, gizli', 'Effort copy, hidden')
if (a.menude) return t(`Menüde, ${(a.sira ?? 0) + 1}. sıra`, `In the menu, position ${(a.sira ?? 0) + 1}`)
return t('Menüye sığmıyor', 'Does not fit in the menu')
}
return (
<SettingsSection icon={Link} title={t('Codex’te görünecek modeller', 'Models shown in Codex')}>
<p className="text-sm text-(--ui-text-secondary)">
{t(
`Codex’in model menüsü en çok ${sinir} model gösterir; bu sınır Codex uygulamasının kendi sınırıdır. Codex’in kendi ${ozgun} modeli her zaman başta durur. Kalan yerlere hangi modellerin gireceğini burada seçin: öne aldıklarınız başa gelir, gizledikleriniz çıkar. Menüde olmayan bir modeli Codex’e “modeli … yap” diyerek yine seçebilirsiniz.`,
`The Codex model menu shows at most ${sinir} models; this is the Codex app’s own limit. Codex’s own ${ozgun} models always come first. Choose which models fill the rest: pinned ones come first, hidden ones are removed. You can still pick a model that is not in the menu by asking Codex to switch to it.`
)}
</p>
{state && draft && (
<>
<p className="text-sm font-medium" role="status">
{t(`Menüde ${ozgun + menude} / ${sinir} · menüye sığmayan ${disarida} · gizli ${gizlenen}`, `In the menu ${ozgun + menude} / ${sinir} · not fitting ${disarida} · hidden ${gizlenen}`)}
</p>
<ListRow
action={<Switch aria-label={t('Resim, ses ve video modellerini gizle', 'Hide image, audio and video models')} checked={draft.medya_gizle} disabled={busy} onCheckedChange={v => degistir({ medya_gizle: v })} />}
description={t('Bunlar Codex sohbetinde çalışmaz.', 'These do not work in a Codex chat.')}
title={t('Resim, ses ve video modellerini gizle', 'Hide image, audio and video models')}
/>
<ListRow
action={<Switch aria-label={t('Düşük/orta/yüksek kopyalarını gizle', 'Hide low/medium/high copies')} checked={draft.efor_gizle} disabled={busy} onCheckedChange={v => degistir({ efor_gizle: v })} />}
description={t('Ana modeli listede olan “-low”, “-high” gibi kopyalar gizlenir; düşünme seviyesini Codex’in kendi seçicisinden seçersiniz.', 'Copies such as “-low” or “-high” whose base model is listed are hidden; pick the reasoning level in Codex’s own selector.')}
title={t('Düşük/orta/yüksek kopyalarını gizle', 'Hide low/medium/high copies')}
/>
{state.adaylar.length === 0 ? (
<p className="text-sm text-(--ui-text-secondary)">
{t('Model listesi henüz oluşmadı. Codex’i açıp model menüsüne bir kez bakın, sonra bu sayfayı yeniden açın.', 'The model list has not been built yet. Open the Codex model menu once, then reopen this page.')}
</p>
) : (
<>
<div className="flex flex-wrap gap-2">
<Input aria-label={t('Model ara', 'Search models')} className="min-h-11 min-w-0 flex-1" onChange={e => setArama(e.target.value)} placeholder={t('Model ara', 'Search models')} value={arama} />
<select aria-label={t('Göster', 'Show')} className="min-h-11 rounded-md border bg-(--ui-bg) p-2" onChange={e => setFiltre(e.target.value as Filtre)} value={filtre}>
<option value="hepsi">{t('Hepsi', 'All')}</option>
<option value="menude">{t('Menüde olanlar', 'In the menu')}</option>
<option value="disarida">{t('Menüye sığmayanlar', 'Not fitting')}</option>
<option value="gizli">{t('Gizlenenler', 'Hidden')}</option>
</select>
</div>
<ul aria-label={t('Modeller', 'Models')} className="max-h-[32rem] divide-y overflow-y-auto rounded-md border">
{gorunen.map(a => {
const pinned = draft.one_al.includes(a.slug)
const hidden = draft.gizli.includes(a.slug)
return (
<li className="flex flex-wrap items-center gap-2 p-2" key={a.slug}>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{a.ad}</div>
<div className="truncate text-xs text-(--ui-text-tertiary)">{durum(a)}</div>
</div>
{pinned && draft.one_al.indexOf(a.slug) > 0 && (
<Button aria-label={t(`${a.ad} bir yukarı`, `Move ${a.ad} up`)} className="min-h-11" disabled={busy} onClick={() => yukari(a.slug)} size="sm" variant="ghost">
↑
</Button>
)}
<Button aria-pressed={pinned} className="min-h-11" disabled={busy} onClick={() => oneAl(a.slug)} size="sm" variant={pinned ? 'default' : 'secondary'}>
{pinned ? t('Öne alındı', 'Pinned') : t('Öne al', 'Pin')}
</Button>
<Button aria-pressed={hidden} className="min-h-11" disabled={busy} onClick={() => gizle(a.slug)} size="sm" variant="ghost">
{hidden ? t('Göster', 'Show') : t('Gizle', 'Hide')}
</Button>
</li>
)
})}
</ul>
</>
)}
<div className="flex gap-2">
<Button disabled={busy} onClick={() => void save(draft)}>
{busy ? t('Kaydediliyor…', 'Saving…') : t('Kaydet', 'Save')}
</Button>
<Button disabled={busy} onClick={() => void save({ ...CODEX_LISTE_VARSAYILAN, one_al: [], gizli: [] })} variant="secondary">
{t('Varsayılana dön', 'Restore default')}
</Button>
</div>
</>
)}
{message && <p role={failed ? 'alert' : 'status'}>{message}</p>}
</SettingsSection>
)
}
@@ -0,0 +1,30 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { I18nProvider } from '@/i18n/context'
import { IsHattiSettingsView } from './is-hatti-settings'
afterEach(() => {
cleanup()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
it('shows the custom text box only for custom text and saves the choice', async () => {
const save = vi.fn(async (s: unknown) => s)
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
codexLink: { getIsHatti: async () => ({ secim: 'onerilen', talimat: '' }), setIsHatti: save }
}
render(<I18nProvider configClient={null} initialLocale="tr"><IsHattiSettingsView /></I18nProvider>)
const select = await screen.findByRole('combobox', { name: 'İş dağıtımı' })
expect(screen.queryByRole('textbox')).toBeNull()
fireEvent.change(select, { target: { value: 'ozel' } })
const box = screen.getByRole('textbox', { name: 'Codex’e eklenecek kural' })
expect((screen.getByRole('button', { name: 'Kaydet' }) as HTMLButtonElement).disabled).toBe(true)
fireEvent.change(box, { target: { value: 'kendi kuralım' } })
fireEvent.click(screen.getByRole('button', { name: 'Kaydet' }))
await waitFor(() => expect(save).toHaveBeenCalledWith({ secim: 'ozel', talimat: 'kendi kuralım' }))
expect(await screen.findByText(/yeniden başlatma gerekmez/)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Varsayılana dön' }))
await waitFor(() => expect(save).toHaveBeenLastCalledWith({ secim: 'onerilen', talimat: 'kendi kuralım' }))
})
@@ -0,0 +1,119 @@
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { useI18n } from '@/i18n/context'
import { Link } from '@/lib/icons'
import type { IsHattiSecim, IsHattiSettings } from '../../../electron/is-hatti-settings'
import { ListRow, SettingsSection } from './primitives'
const SECENEKLER: { value: IsHattiSecim; tr: string; en: string; aciklama: string; desc: string }[] = [
{ value: 'onerilen', tr: 'Önerilen', en: 'Recommended', aciklama: 'Her gün, saat başı gibi tekrarlı işler AITURK IDE botuna gider. Kodlama ve diğer işler Codex’te kalır: Codex işi kendisi yapmaz, kolay işi bir alt ajana, zor işi alt ajan ekibine verir; sonucu doğrulayıp size aktarır.', desc: 'Repeating or scheduled work goes to the AITURK IDE bot. Coding and other work stays in Codex, which hands easy work to one subagent and hard work to a subagent team, then checks and reports the result.' },
{ value: 'buyuk', tr: 'Büyük işleri AITURK’e gönder', en: 'Send large work to AITURK', aciklama: 'Eski davranış: birkaç adımlı ya da çok dosyalı işler AITURK’e gönderilir, küçük işleri Codex yapar.', desc: 'Previous behaviour: multi-step or multi-file work goes to AITURK; Codex does small tasks.' },
{ value: 'kapali', tr: 'Hiçbir işi AITURK’e gönderme', en: 'Never send work to AITURK', aciklama: 'Codex’e iş dağıtımı hakkında hiçbir not eklenmez; her işi Codex yapar.', desc: 'No routing note is added; Codex does every task.' },
{ value: 'ozel', tr: 'Kendi metnim', en: 'Custom text', aciklama: 'Codex’e eklenecek kuralı siz yazarsınız.', desc: 'You write the rule given to Codex.' }
]
export function IsHattiSettingsView() {
const api = window.hermesDesktop?.codexLink
const { locale } = useI18n()
const tr = locale === 'tr'
const t = (a: string, b: string) => (tr ? a : b)
const [draft, setDraft] = useState<IsHattiSettings | null>(null)
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('')
const [failed, setFailed] = useState(false)
useEffect(() => {
let active = true
api?.getIsHatti?.().then(
next => active && setDraft(next),
() => {
if (active) {
setFailed(true)
setMessage(tr ? 'İş dağıtımı ayarı okunamadı.' : 'Could not load the work routing setting.')
}
}
)
return () => {
active = false
}
}, [api, tr])
if (!api?.getIsHatti || !api.setIsHatti) return null
const save = async (next: IsHattiSettings) => {
setBusy(true)
setMessage('')
setFailed(false)
try {
setDraft(await api.setIsHatti!(next))
setMessage(t('Kaydedildi. Codex’in bir sonraki mesajından itibaren geçerli; yeniden başlatma gerekmez.', 'Saved. Applies from Codex’s next message; no restart needed.'))
} catch {
setFailed(true)
setMessage(t('Kaydedilemedi. Kendi metninizi seçtiyseniz kutunun boş olmadığını kontrol edin.', 'Could not save. If you chose custom text, make sure it is not empty.'))
} finally {
setBusy(false)
}
}
const secili = SECENEKLER.find(s => s.value === draft?.secim)
return (
<SettingsSection icon={Link} title={t('AITURK iş hattı', 'AITURK work routing')}>
<p className="text-sm text-(--ui-text-secondary)">
{t('Codex masaüstü uygulamasının hangi işleri AITURK’e devredeceğini seçin. Kullanıcı “Codex” derse iş AITURK’e devredilmez.', 'Choose which work the Codex desktop app hands to AITURK. Saying “Codex” keeps the task in Codex.')}
</p>
{draft && (
<>
<ListRow
action={
<select
aria-label={t('İş dağıtımı', 'Work routing')}
className="min-h-11 max-w-full rounded-md border bg-(--ui-bg) p-2"
disabled={busy}
onChange={e => setDraft({ ...draft, secim: e.target.value as IsHattiSecim })}
value={draft.secim}
>
{SECENEKLER.map(s => (
<option key={s.value} value={s.value}>
{tr ? s.tr : s.en}
</option>
))}
</select>
}
description={secili ? (tr ? secili.aciklama : secili.desc) : undefined}
title={t('İş dağıtımı', 'Work routing')}
/>
{draft.secim === 'ozel' && (
<>
<textarea
aria-label={t('Codex’e eklenecek kural', 'Rule given to Codex')}
className="min-h-40 w-full rounded-md border bg-(--ui-bg) p-2 text-sm"
disabled={busy}
onChange={e => setDraft({ ...draft, talimat: e.target.value })}
placeholder={t('Örnek: Kodlama işlerini alt ajanlara ver. Her gün tekrarlanacak işleri {komut} komutuyla AITURK’e gönder.', 'Example: Give coding to subagents. Send daily work to AITURK with {komut}.')}
value={draft.talimat}
/>
<p className="text-sm text-(--ui-text-secondary)">
{t('Metinde {zamanla} yazarsanız AITURK IDE botuna zamanlanmış iş veren komut, {komut} yazarsanız işi AITURK’e gönderen komut yerine konur.', 'Write {zamanla} to insert the command that schedules work on the AITURK IDE bot, or {komut} for the command that sends work to AITURK.')}
</p>
</>
)}
<div className="flex gap-2">
<Button disabled={busy || (draft.secim === 'ozel' && !draft.talimat.trim())} onClick={() => void save(draft)}>
{busy ? t('Kaydediliyor…', 'Saving…') : t('Kaydet', 'Save')}
</Button>
<Button disabled={busy} onClick={() => void save({ secim: 'onerilen', talimat: draft.talimat })} variant="secondary">
{t('Varsayılana dön', 'Restore default')}
</Button>
</div>
</>
)}
{message && <p role={failed ? 'alert' : 'status'}>{message}</p>}
</SettingsSection>
)
}
@@ -44,13 +44,13 @@ describe('staged transition settings UI', () => {
show() show()
await screen.findByText('Kademeli Geçiş') await screen.findByText('Kademeli Geçiş')
fireEvent.click(screen.getByText('+ Geçiş kuralı ekle')) fireEvent.click(screen.getByText('+ Geçiş kuralı ekle'))
expect(screen.getByLabelText('İtiraz edilen model 4')).toBeTruthy() expect(screen.getByLabelText('İtiraz edilen model 3')).toBeTruthy()
expect(screen.getByLabelText('Kullanılacak model 4')).toBeTruthy() expect(screen.getByLabelText('Kullanılacak model 3')).toBeTruthy()
fireEvent.click(screen.getByLabelText('Geçiş kuralı 4 sil')) fireEvent.click(screen.getByLabelText('Geçiş kuralı 3 sil'))
expect(screen.queryByLabelText('İtiraz edilen model 4')).toBeNull() expect(screen.queryByLabelText('İtiraz edilen model 3')).toBeNull()
fireEvent.click(screen.getByText('+ Her zaman geç kuralı ekle')) fireEvent.click(screen.getByText('+ Her zaman geç kuralı ekle'))
expect(screen.getByLabelText('Her zaman geçen model 3')).toBeTruthy() expect(screen.getByLabelText('Her zaman geçen model 4')).toBeTruthy()
fireEvent.click(screen.getByLabelText('Her zaman geç kuralı 3 sil')) fireEvent.click(screen.getByLabelText('Her zaman geç kuralı 4 sil'))
fireEvent.change(screen.getByLabelText('İsteği yerine getirmeme denetim metni'), { fireEvent.change(screen.getByLabelText('İsteği yerine getirmeme denetim metni'), {
target: { value: 'custom prompt' } target: { value: 'custom prompt' }
}) })
@@ -58,6 +58,37 @@ describe('staged transition settings UI', () => {
await screen.findByText('Kaydedildi. Yeni değerlendirmelerde uygulanır.') await screen.findByText('Kaydedildi. Yeni değerlendirmelerde uygulanır.')
expect(set.mock.calls[0][0].settings.questions.ret).toBe('custom prompt') expect(set.mock.calls[0][0].settings.questions.ret).toBe('custom prompt')
expect(set.mock.calls[0][0]).not.toHaveProperty('apiKey') expect(set.mock.calls[0][0]).not.toHaveProperty('apiKey')
expect(set.mock.calls[0][0]).not.toHaveProperty('policyApiKey')
})
it('edits the decision stages, the correction channel and the Dify rules source', async () => {
get.mockResolvedValue({
...state(),
policyKeyConfigured: false,
policyStatus: { ok: true, fetchedAt: 'dün', sections: ['autoRoute'], error: 'Dify kapalı' }
})
show()
await screen.findByText('Karar aşamaları')
fireEvent.click(screen.getByLabelText('Ret ön kontrolü'))
fireEvent.change(screen.getByLabelText('Ret ön kontrolü eşiği'), { target: { value: '0.75' } })
fireEvent.click(screen.getByLabelText('Otomatik model'))
fireEvent.change(screen.getByLabelText('Zor iş sorusu'), { target: { value: 'zor mu?' } })
fireEvent.click(screen.getByLabelText('Erken bitiş denetimi'))
fireEvent.change(screen.getByLabelText('Modele verilecek not'), { target: { value: 'işi bitir' } })
fireEvent.click(screen.getByLabelText('Workflow doğrulayıcı elemesi'))
fireEvent.click(screen.getByText('+ Düzeltme kanalı modeli ekle'))
expect(screen.getByLabelText('Düzeltme kanalı modeli 4')).toBeTruthy()
fireEvent.click(screen.getByLabelText('Düzeltme kanalı modeli 4 sil'))
fireEvent.change(screen.getByLabelText('Dify uygulama anahtarı'), { target: { value: 'app-key' } })
fireEvent.click(screen.getByText('Kaydet'))
await screen.findByText('Kaydedildi. Yeni değerlendirmelerde uygulanır.')
const saved = set.mock.calls[0][0]
expect(saved.settings.prefilter).toMatchObject({ enabled: true, threshold: 0.75 })
expect(saved.settings.autoRoute).toMatchObject({ enabled: true, questions: { zor: 'zor mu?' } })
expect(saved.settings.finishCheck).toMatchObject({ enabled: true, note: 'işi bitir' })
expect(saved.settings.verifierCheck.enabled).toBe(true)
expect(saved.settings.watchModels).toEqual(defaults.watchModels)
expect(saved.policyApiKey).toBe('app-key')
expect((screen.getByLabelText('Dify uygulama anahtarı') as HTMLInputElement).value).toBe('')
}) })
it('retains user edits if persistence fails', async () => { it('retains user edits if persistence fails', async () => {
set.mockRejectedValue(new Error('disk error')) set.mockRejectedValue(new Error('disk error'))
@@ -21,6 +21,7 @@ export function StagedTransitionSettingsView() {
const [state, setState] = useState<StagedTransitionState | null>(null) const [state, setState] = useState<StagedTransitionState | null>(null)
const [draft, setDraft] = useState<StagedTransitionSettings | null>(null) const [draft, setDraft] = useState<StagedTransitionSettings | null>(null)
const [key, setKey] = useState('') const [key, setKey] = useState('')
const [policyKey, setPolicyKey] = useState('')
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [error, setError] = useState(false) const [error, setError] = useState(false)
@@ -80,7 +81,10 @@ export function StagedTransitionSettingsView() {
const storedModels = [ const storedModels = [
draft.fallbackModel, draft.fallbackModel,
...draft.exemptModels, ...draft.exemptModels,
...draft.rejectionRules.flatMap(rule => [rule.from, rule.to]) ...draft.rejectionRules.flatMap(rule => [rule.from, rule.to]),
...draft.watchModels,
draft.prefilter.target,
...Object.values(draft.autoRoute.targets)
] ]
for (const id of storedModels) { for (const id of storedModels) {
@@ -127,14 +131,62 @@ export function StagedTransitionSettingsView() {
/> />
) )
// Decision stages (14 Oct 2026). Each stage has its own switch; all start disabled.
type StageName = 'prefilter' | 'autoRoute' | 'finishCheck' | 'verifierCheck'
const stage = <K extends StageName>(name: K, value: Partial<StagedTransitionSettings[K]>) =>
change(name, { ...draft[name], ...value } as StagedTransitionSettings[K])
const probabilityInput = (label: string, value: number, onChange: (value: number) => void) => (
<Input
aria-label={label}
max={1}
min={0}
onChange={e => onChange(Number(e.target.value))}
step={0.01}
type="number"
value={value}
/>
)
const secondsInput = (label: string, value: number, max: number, onChange: (value: number) => void) => (
<Input aria-label={label} max={max} min={1} onChange={e => onChange(Number(e.target.value))} type="number" value={value} />
)
const question = (label: string, value: string, onChange: (value: string) => void, rows = 3) => (
<label className="block py-2">
<span>{label}</span>
<Textarea aria-label={label} onChange={e => onChange(e.target.value)} rows={rows} value={value} />
</label>
)
const stageSwitch = (name: StageName, label: string, description: string) => (
<ListRow
action={
<Switch aria-label={label} checked={draft[name].enabled} onCheckedChange={value => stage(name, { enabled: value })} />
}
description={description}
title={label}
/>
)
const policy = state?.policyStatus
const save = async () => { const save = async () => {
setBusy(true) setBusy(true)
setMessage('') setMessage('')
setError(false) setError(false)
try { try {
adopt(await api.setStagedTransition({ settings: draft, ...(key.trim() ? { apiKey: key.trim() } : {}) })) adopt(
await api.setStagedTransition({
settings: draft,
...(key.trim() ? { apiKey: key.trim() } : {}),
...(policyKey.trim() ? { policyApiKey: policyKey.trim() } : {})
})
)
setKey('') setKey('')
setPolicyKey('')
setMessage(text('Kaydedildi. Yeni değerlendirmelerde uygulanır.', 'Saved. Applies to new evaluations.')) setMessage(text('Kaydedildi. Yeni değerlendirmelerde uygulanır.', 'Saved. Applies to new evaluations.'))
} catch (failure) { } catch (failure) {
setError(true) setError(true)
@@ -404,6 +456,248 @@ export function StagedTransitionSettingsView() {
{text('+ Her zaman geç kuralı ekle', '+ Add always-pass rule')} {text('+ Her zaman geç kuralı ekle', '+ Add always-pass rule')}
</Button> </Button>
</SettingsSection> </SettingsSection>
<SettingsSection icon={Link} title={text('Karar aşamaları', 'Decision stages')}>
<p>
{text(
'Karar modeli metin yazmaz; her aşamada evet/hayır olasılığı verir. Aşamalar yukarıdaki ana anahtar açıkken çalışır. Karar alınamazsa bugünkü yol izlenir.',
'The decision model writes no text; each stage gets a yes/no probability. Stages run while the main switch above is on. Without a decision the current path is kept.'
)}
</p>
{stageSwitch(
'prefilter',
text('Ret ön kontrolü', 'Refusal pre-check'),
text(
'Tur başında sorulur: normal bir model bu isteği reddeder ya da yumuşatır mı? Evetse adım beklemeden seçilen katmandan başlar.',
'Asked at the start of a turn: would a mainstream model refuse or water down this request? If so the step starts at the chosen layer.'
)
)}
<ListRow
action={modelSelect(text('Ret ön kontrolü hedefi', 'Refusal pre-check target'), draft.prefilter.target, value =>
stage('prefilter', { target: value })
)}
title={text('Reddedilecekse başlanacak model', 'Model to start with when a refusal is expected')}
/>
<ListRow
action={probabilityInput(text('Ret ön kontrolü eşiği', 'Refusal pre-check threshold'), draft.prefilter.threshold, value =>
stage('prefilter', { threshold: value })
)}
title={text('Eşik (0–1)', 'Threshold (0–1)')}
/>
{question(text('Ret ön kontrolü sorusu', 'Refusal pre-check question'), draft.prefilter.question, value =>
stage('prefilter', { question: value })
)}
{stageSwitch(
'autoRoute',
text('Otomatik model', 'Automatic model'),
text(
'Codex listesinde bir model olarak görünür. Seçildiğinde her yeni istekte karar modeli işin türüne göre combo seçer; aynı turun araç adımları aynı combo ile sürer. Açınca listede birkaç dakika içinde görünür.',
'Appears as a model in the Codex list. When selected, the decision model picks a combo for each new request; tool steps of the same turn keep it. It appears in the list within a few minutes after enabling.'
)
)}
<ListRow
action={
<Input
aria-label={text('Otomatik modelin adı', 'Automatic model name')}
onChange={e => stage('autoRoute', { model: e.target.value })}
value={draft.autoRoute.model}
/>
}
title={text('Listede görünecek ad', 'Name in the model list')}
/>
{(['basit', 'zor', 'arastirma'] as const).map(kind => {
const label =
kind === 'basit'
? text('Basit iş combo', 'Simple request combo')
: kind === 'zor'
? text('Zor iş combo', 'Hard request combo')
: text('Araştırma combo', 'Research combo')
return (
<ListRow
action={modelSelect(label, draft.autoRoute.targets[kind], value =>
stage('autoRoute', { targets: { ...draft.autoRoute.targets, [kind]: value } })
)}
key={kind}
title={label}
/>
)
})}
{(['zor', 'arastirma'] as const).map(kind => {
const label = kind === 'zor' ? text('Zor iş sorusu', 'Hard request question') : text('Araştırma sorusu', 'Research question')
return (
<div key={kind}>
<ListRow
action={probabilityInput(`${label} ${text('eşiği', 'threshold')}`, draft.autoRoute.thresholds[kind], value =>
stage('autoRoute', { thresholds: { ...draft.autoRoute.thresholds, [kind]: value } })
)}
title={`${label} ${text('eşiği', 'threshold')}`}
/>
{question(label, draft.autoRoute.questions[kind], value =>
stage('autoRoute', { questions: { ...draft.autoRoute.questions, [kind]: value } })
)}
</div>
)
})}
{stageSwitch(
'finishCheck',
text('Erken bitiş denetimi', 'Early finish check'),
text(
'Son cevapta sorulur: iş bitmeden “bitti” dendi mi ya da kanıt gösterilmedi mi? Evetse cevap aşağıdaki notla aynı modele bir kez geri verilir.',
'Asked on the final answer: did it say done before finishing or claim success without evidence? If so it goes back to the same model once with the note below.'
)
)}
<ListRow
action={probabilityInput(text('Erken bitiş eşiği', 'Early finish threshold'), draft.finishCheck.threshold, value =>
stage('finishCheck', { threshold: value })
)}
title={text('Eşik (0–1)', 'Threshold (0–1)')}
/>
{question(text('Erken bitiş sorusu', 'Early finish question'), draft.finishCheck.question, value =>
stage('finishCheck', { question: value })
)}
{question(
text('Modele verilecek not', 'Note given to the model'),
draft.finishCheck.note,
value => stage('finishCheck', { note: value }),
2
)}
{stageSwitch(
'verifierCheck',
text('Workflow doğrulayıcı elemesi', 'Workflow reviewer check'),
text(
'Dinamik workflow kabulünde sorulur: doğrulayıcı somut kanıt göstermeden mi onayladı? Evetse kabul engellenir, doğrulama yeniden yapılır.',
'Asked when a dynamic workflow is accepted: did the reviewer approve without concrete evidence? If so acceptance is blocked and verification is redone.'
)
)}
<ListRow
action={probabilityInput(text('Doğrulayıcı elemesi eşiği', 'Reviewer check threshold'), draft.verifierCheck.threshold, value =>
stage('verifierCheck', { threshold: value })
)}
title={text('Eşik (0–1)', 'Threshold (0–1)')}
/>
{question(text('Doğrulayıcı elemesi sorusu', 'Reviewer check question'), draft.verifierCheck.question, value =>
stage('verifierCheck', { question: value })
)}
<ListRow
action={secondsInput(text('Tur başı karar bekleme (saniye)', 'Turn-start decision wait (seconds)'), draft.prefilter.timeoutSeconds, 60, value => {
setDraft({
...draft,
prefilter: { ...draft.prefilter, timeoutSeconds: value },
autoRoute: { ...draft.autoRoute, timeoutSeconds: value }
})
})}
description={text(
'Bu sürede karar gelmezse istek bugünkü yoldan devam eder.',
'Without a decision within this time the request continues on the current path.'
)}
title={text('Tur başı karar bekleme (saniye)', 'Turn-start decision wait (seconds)')}
/>
</SettingsSection>
<SettingsSection icon={Link} title={text('Cevaptan sonra düzeltme kanalı', 'Correction channel after answers')}>
<p>
{text(
'Bekçi bu modellerin cevabından sonra düzeltme ya da ek bilgi gönderebilir. Ad tam eşleşmelidir.',
'Bekçi may send corrections or extra information after these models answer. Names must match exactly.'
)}
</p>
{draft.watchModels.map((model, index) => (
<div className="flex flex-wrap items-center gap-2 py-2" key={index}>
{modelSelect(text(`Düzeltme kanalı modeli ${index + 1}`, `Correction channel model ${index + 1}`), model, value =>
change(
'watchModels',
draft.watchModels.map((m, i) => (i === index ? value : m))
)
)}
<Button
aria-label={text(`Düzeltme kanalı modeli ${index + 1} sil`, `Remove correction channel model ${index + 1}`)}
onClick={() =>
change(
'watchModels',
draft.watchModels.filter((_, i) => i !== index)
)
}
variant="ghost"
>
{text('Sil', 'Remove')}
</Button>
</div>
))}
<Button onClick={() => change('watchModels', [...draft.watchModels, ''])} variant="outline">
{text('+ Düzeltme kanalı modeli ekle', '+ Add correction channel model')}
</Button>
</SettingsSection>
<SettingsSection icon={Link} title={text('Kural kaynağı', 'Rules source')}>
<p>
{text(
'Dify seçilirse karar aşamalarının soruları, eşikleri ve combo seçimleri Dify’daki iş akışından birkaç dakikada bir alınır. Codex’in konuştuğu model yine OmniRoute combo’sudur. Dify’a ulaşılamazsa son geçerli kurallar, o da yoksa bu sayfadaki ayarlar kullanılır.',
'With Dify, the stage questions, thresholds and combo choices are fetched from the Dify workflow every few minutes. Codex still talks to OmniRoute combos. If Dify is unreachable the last good rules, otherwise the settings on this page, are used.'
)}
</p>
<ListRow
action={
<Select
onValueChange={value => change('policy', { ...draft.policy, source: value as 'local' | 'dify' })}
value={draft.policy.source}
>
<SelectTrigger aria-label={text('Kural kaynağı', 'Rules source')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">{text('Bu sayfa', 'This page')}</SelectItem>
<SelectItem value="dify">Dify</SelectItem>
</SelectContent>
</Select>
}
title={text('Kural kaynağı', 'Rules source')}
/>
<ListRow
action={
<Input
aria-label={text('Dify API adresi', 'Dify API endpoint')}
onChange={e => change('policy', { ...draft.policy, endpoint: e.target.value })}
type="url"
value={draft.policy.endpoint}
/>
}
title={text('Dify API adresi', 'Dify API endpoint')}
/>
<ListRow
action={
<Input
aria-label={text('Dify uygulama anahtarı', 'Dify app key')}
autoComplete="new-password"
onChange={e => setPolicyKey(e.target.value)}
type="password"
value={policyKey}
/>
}
description={
state?.policyKeyConfigured
? text('Anahtar tanımlı. Değiştirmek için yeni anahtarı girin.', 'Key configured. Enter a new key to replace it.')
: text('Henüz anahtar tanımlı değil.', 'No key configured.')
}
title={text('Dify uygulama anahtarı', 'Dify app key')}
/>
<ListRow
action={secondsInput(text('Yenileme aralığı (dakika)', 'Refresh interval (minutes)'), draft.policy.refreshMinutes, 1440, value =>
change('policy', { ...draft.policy, refreshMinutes: value })
)}
title={text('Yenileme aralığı (dakika)', 'Refresh interval (minutes)')}
/>
{draft.policy.source === 'dify' && (
<p role="status">
{!policy
? text('Dify kuralları henüz alınmadı.', 'Dify rules not fetched yet.')
: policy.ok
? text(
`Son geçerli kurallar: ${policy.lastGoodAt ?? policy.fetchedAt ?? '-'} (${policy.sections.join(', ') || 'boş'})${policy.error ? ` · son deneme hatası: ${policy.error}` : ''}`,
`Last good rules: ${policy.lastGoodAt ?? policy.fetchedAt ?? '-'} (${policy.sections.join(', ') || 'empty'})${policy.error ? ` · last attempt failed: ${policy.error}` : ''}`
)
: text(`Kurallar alınamadı: ${policy.error ?? '-'}`, `Rules could not be fetched: ${policy.error ?? '-'}`)}
</p>
)}
</SettingsSection>
<SettingsSection icon={Link} title={text('Paralellik ve bekleme', 'Concurrency and waiting')}> <SettingsSection icon={Link} title={text('Paralellik ve bekleme', 'Concurrency and waiting')}>
{number( {number(
'maxConcurrent', 'maxConcurrent',
+6
View File
@@ -3,6 +3,8 @@ import type { TranslucencyState } from '@hermes/shared/translucency'
import type { CodexLinkPatch, CodexLinkStatus } from '../electron/codex-link-types' import type { CodexLinkPatch, CodexLinkStatus } from '../electron/codex-link-types'
import type { CompressionSettings, CompressionState } from '../electron/compression-settings-types' import type { CompressionSettings, CompressionState } from '../electron/compression-settings-types'
import type { CodexListeSettings, CodexListeState } from '../electron/codex-liste-settings'
import type { IsHattiSettings } from '../electron/is-hatti-settings'
import type { PoolLimits } from '../electron/pool-limits' import type { PoolLimits } from '../electron/pool-limits'
import type { StagedTransitionPatch, StagedTransitionState } from '../electron/staged-transition-types' import type { StagedTransitionPatch, StagedTransitionState } from '../electron/staged-transition-types'
@@ -344,6 +346,10 @@ declare global {
codexLink?: { codexLink?: {
getCompression: () => Promise<CompressionState> getCompression: () => Promise<CompressionState>
setCompression: (settings: CompressionSettings) => Promise<CompressionState> setCompression: (settings: CompressionSettings) => Promise<CompressionState>
getIsHatti?: () => Promise<IsHattiSettings>
setIsHatti?: (settings: IsHattiSettings) => Promise<IsHattiSettings>
getCodexListe?: () => Promise<CodexListeState>
setCodexListe?: (settings: CodexListeSettings) => Promise<CodexListeState>
getStagedTransition: () => Promise<StagedTransitionState> getStagedTransition: () => Promise<StagedTransitionState>
setStagedTransition: (patch: StagedTransitionPatch) => Promise<StagedTransitionState> setStagedTransition: (patch: StagedTransitionPatch) => Promise<StagedTransitionState>
get: () => Promise<CodexLinkStatus> get: () => Promise<CodexLinkStatus>
+44 -1
View File
@@ -6,6 +6,10 @@ import { useI18n } from './context'
// AITURK-distribution strings (see ./aiturk): Turkish for `tr`, English for // AITURK-distribution strings (see ./aiturk): Turkish for `tr`, English for
// every other locale. // every other locale.
const en = { const en = {
modeTitle: 'Connection mode',
directMode: 'Direct (recommended): subscription models independent of the bridge',
aiturkMode: 'AITURK: combos in the Codex list, all models through the bridge',
modeRestartNote: 'Restart Codex.',
nav: 'Codex Link', nav: 'Codex Link',
title: 'Codex Link', title: 'Codex Link',
description: description:
@@ -39,6 +43,10 @@ const en = {
} }
const tr: typeof en = { const tr: typeof en = {
modeTitle: 'Bağlantı modu',
directMode: 'Doğrudan (önerilen): abonelik modelleri köprüden bağımsız',
aiturkMode: 'AITURK: combolar Codex listesinde, tüm modeller köprüden',
modeRestartNote: "Codex'i yeniden başlatın",
nav: 'Codex Bağlama', nav: 'Codex Bağlama',
title: 'Codex Bağlama', title: 'Codex Bağlama',
description: description:
@@ -71,10 +79,45 @@ const tr: typeof en = {
restartNote: "Codex'te yeni modelleri görmek için Codex'i yeniden açın." restartNote: "Codex'te yeni modelleri görmek için Codex'i yeniden açın."
} }
const modeTranslations = {
zh: {
modeTitle: '连接模式',
directMode: '直接(推荐):订阅模型独立于桥接',
aiturkMode: 'AITURK:组合显示在 Codex 列表中,所有模型通过桥接',
modeRestartNote: '请重启 Codex。'
},
'zh-hant': {
modeTitle: '連線模式',
directMode: '直接(建議):訂閱模型獨立於橋接',
aiturkMode: 'AITURK:組合顯示在 Codex 清單中,所有模型透過橋接',
modeRestartNote: '請重新啟動 Codex。'
},
ja: {
modeTitle: '接続モード',
directMode: '直接(推奨):サブスクリプションモデルはブリッジから独立',
aiturkMode: 'AITURK:Codex の一覧にコンボを表示、すべてのモデルはブリッジ経由',
modeRestartNote: 'Codex を再起動してください。'
},
ar: {
modeTitle: 'وضع الاتصال',
directMode: 'مباشر (موصى به): نماذج الاشتراك مستقلة عن الجسر',
aiturkMode: 'AITURK: مجموعات النماذج في قائمة Codex، وجميع النماذج عبر الجسر',
modeRestartNote: 'أعد تشغيل Codex.'
},
ru: {
modeTitle: 'Режим подключения',
directMode: 'Напрямую (рекомендуется): модели подписки независимы от моста',
aiturkMode: 'AITURK: комбинации в списке Codex, все модели через мост',
modeRestartNote: 'Перезапустите Codex.'
}
}
export type CodexLinkCopy = typeof en export type CodexLinkCopy = typeof en
export function useCodexLinkCopy(): CodexLinkCopy { export function useCodexLinkCopy(): CodexLinkCopy {
const { locale } = useI18n() const { locale } = useI18n()
return locale === 'tr' ? tr : en if (locale === 'tr') return tr
if (locale === 'en') return en
return { ...en, ...modeTranslations[locale] }
} }
+253 -3
View File
@@ -69,7 +69,7 @@ import logging
import secrets import secrets
import threading import threading
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field, replace
from typing import Any, Callable, Dict, Optional from typing import Any, Callable, Dict, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -94,6 +94,7 @@ BROWSER_CONTROL_CAPABILITIES = frozenset(
"controller.noop", "controller.noop",
"browser_back", "browser_back",
"browser_click", "browser_click",
"browser_get_images",
"browser_navigate", "browser_navigate",
"browser_press", "browser_press",
"browser_screenshot", "browser_screenshot",
@@ -102,9 +103,22 @@ BROWSER_CONTROL_CAPABILITIES = frozenset(
"browser_tab_activate", "browser_tab_activate",
"browser_tabs", "browser_tabs",
"browser_type", "browser_type",
# The controller only captures the visible tab; the agent side saves
# the image and runs the usual native/auxiliary vision analysis.
"browser_vision",
} }
) )
#: Transport family for the desktop IDE's own browser (the AITURK Chrome
#: extension behind the desktop's native-messaging bridge). Its controller is
#: registered once per backend as a lane template and serves every desktop
#: session of that backend; see :meth:`BrowserControlBroker.attach_lane_template`.
LOCAL_DESKTOP_TRANSPORT_FAMILY = "desktop-local-ws"
#: Principal of the single local desktop user. The desktop backend is
#: loopback-only and its session token already grants every desktop session,
#: so there is exactly one local principal per backend process.
LOCAL_DESKTOP_PRINCIPAL = "principal:desktop:local"
#: Privileged capabilities (Phase 8 Task 30) that are never negotiable through #: Privileged capabilities (Phase 8 Task 30) that are never negotiable through
#: the base allowlist. ``browser_evaluate`` executes JavaScript in the page #: the base allowlist. ``browser_evaluate`` executes JavaScript in the page
#: context; ``browser_cdp`` is raw CDP. Both are fail-closed unless the broker #: context; ``browser_cdp`` is raw CDP. Both are fail-closed unless the broker
@@ -304,6 +318,23 @@ class _PendingCommand:
result: Any = None result: Any = None
@dataclass
class _LaneTemplate:
"""One controller offered to every session of one authenticated lane.
A template never matches a session by itself. The first lookup for a
session materializes an exact per-session scope through :meth:`attach`,
so selection, dispatch, cancellation and completion keep the broker's
exact-identity contract. ``send_for(session_id)`` returns the transport
writer that addresses that session's frames.
"""
template: ControllerScope
send_for: Callable[[str], Callable[[dict], None]]
owner: Any
derived: Dict[str, ControllerScope] = field(default_factory=dict)
class BrowserControlBroker: class BrowserControlBroker:
"""Thread-safe broker core binding controllers to callers. """Thread-safe broker core binding controllers to callers.
@@ -347,6 +378,10 @@ class BrowserControlBroker:
# listener attaches one store per profile so profile A touching the # listener attaches one store per profile so profile A touching the
# artifact route first can never pin profile B to A's physical root. # artifact route first can never pin profile B to A's physical root.
self._artifact_stores: Dict[Optional[str], Any] = {} self._artifact_stores: Dict[Optional[str], Any] = {}
# Lane templates keyed by (principal_id, transport_family). Only the
# local desktop lane uses them; every other transport registers one
# exact scope per session.
self._lane_templates: Dict[tuple, _LaneTemplate] = {}
def _developer_mode_now(self) -> bool: def _developer_mode_now(self) -> bool:
"""Current Developer Mode authority (live config unless pinned).""" """Current Developer Mode authority (live config unless pinned)."""
@@ -575,6 +610,163 @@ class BrowserControlBroker:
] ]
return len(matches) == 1 return len(matches) == 1
# ------------------------------------------------------------------
# Lane templates (one controller for every session of a lane)
# ------------------------------------------------------------------
def attach_lane_template(
self,
template: ControllerScope,
send_for: Callable[[str], Callable[[dict], None]],
*,
owner: Any,
) -> None:
"""Offer one controller to every session of ``template``'s lane.
The lane is ``(principal_id, transport_family)``; ``template`` carries
no session id. Re-registering the same controller (same profile,
controller id and browser profile) refreshes the transport and
negotiated capabilities of the sessions it already serves without
cancelling their work. A different controller hard-replaces the old
one: its per-session scopes are detached first, cancelling their
pending commands, exactly like a replacement in :meth:`attach`.
"""
template = replace(template, session_id=None)
key = (template.principal_id, template.transport_family)
lane = _LaneTemplate(template=template, send_for=send_for, owner=owner)
with self._lock:
previous = self._lane_templates.get(key)
self._lane_templates[key] = lane
if previous is None:
return
same_controller = (
previous.template.profile_id == template.profile_id
and previous.template.controller_id == template.controller_id
and previous.template.browser_profile_id == template.browser_profile_id
)
for session_id, scope in list(previous.derived.items()):
if not same_controller:
self._detach_identity(scope, notify_controller=False)
continue
refreshed = replace(template, session_id=session_id)
try:
self.attach(refreshed, send_for(session_id), owner=owner)
except Exception:
logger.exception("failed to refresh browser-controller lane scope")
self._detach_identity(scope, notify_controller=False)
continue
with self._lock:
if self._lane_templates.get(key) is lane:
lane.derived[session_id] = refreshed
def detach_lane_template(
self,
*,
principal_id: Optional[str],
transport_family: Optional[str],
owner: Any = _OWNER_UNSET,
notify_controller: bool = True,
) -> bool:
"""Remove a lane template and hard-detach every session it served."""
key = (principal_id, transport_family)
with self._lock:
lane = self._lane_templates.get(key)
if lane is None:
return False
if owner is not _OWNER_UNSET and lane.owner is not owner:
return False
del self._lane_templates[key]
derived = list(lane.derived.values())
for scope in derived:
self._detach_identity(scope, notify_controller=notify_controller)
return True
def lane_template(
self, *, principal_id: Optional[str], transport_family: Optional[str]
) -> Optional[ControllerScope]:
"""Return the template scope offered to a lane, if any."""
with self._lock:
lane = self._lane_templates.get((principal_id, transport_family))
return lane.template if lane is not None else None
def _materialize_lane_scope(
self, target: str, principal: str, family: str
) -> Optional[ControllerScope]:
"""Attach the exact per-session scope a lane template offers."""
key = (principal, family)
with self._lock:
lane = self._lane_templates.get(key)
if lane is None:
return None
existing = lane.derived.get(target)
if existing is not None and any(
_same_scope_identity(candidate, existing)
for candidate in self._controllers
):
return existing
scope = replace(lane.template, session_id=target)
send_for, owner = lane.send_for, lane.owner
self.attach(scope, send_for(target), owner=owner)
with self._lock:
if self._lane_templates.get(key) is lane:
lane.derived[target] = scope
return scope
# The template was replaced or removed while attaching: never leave a
# scope behind that its lane no longer vouches for.
self._detach_identity(scope, notify_controller=False)
return None
def _detach_identity(
self, scope: ControllerScope, *, notify_controller: bool = True
) -> None:
"""Detach whatever scope currently carries ``scope``'s identity.
A refresh may have re-keyed the scope with new capabilities, so an
exact-key lookup could miss it.
"""
with self._lock:
current = next(
(
candidate
for candidate in self._controllers
if _same_scope_identity(candidate, scope)
),
None,
)
if current is not None:
self.detach(current, notify_controller=notify_controller)
def complete_for_owner(
self,
command_id: str,
*,
owner: Any,
ok: bool,
result: Any = None,
) -> bool:
"""Complete a command only when ``owner`` drives its controller.
Lane controllers serve many sessions over one transport, so their
results are admitted by transport ownership instead of a session
lookup. Unknown, foreign or already-resolved ids report ``False``.
"""
with self._lock:
pending = self._pending.get(command_id)
if pending is None or pending.done:
return False
controller = next(
(
candidate
for candidate in self._controllers.values()
if _same_scope_identity(candidate.scope, pending.scope)
),
None,
)
if controller is None or controller.owner is not owner:
return False
scope = pending.scope
return self.complete(command_id, scope=scope, ok=ok, result=result)
def disconnect( def disconnect(
self, self,
scope: ControllerScope, scope: ControllerScope,
@@ -951,7 +1143,12 @@ class BrowserControlBroker:
and scope.principal_id == principal and scope.principal_id == principal
and scope.transport_family == family and scope.transport_family == family
] ]
return matches[0] if len(matches) == 1 else None has_template = (principal, family) in self._lane_templates
if matches:
return matches[0] if len(matches) == 1 else None
if has_template:
return self._materialize_lane_scope(target, principal, family)
return None
def lane_registered( def lane_registered(
self, self,
@@ -975,6 +1172,8 @@ class BrowserControlBroker:
if not target or not principal or not family: if not target or not principal or not family:
return False return False
with self._lock: with self._lock:
if (principal, family) in self._lane_templates:
return True
return any( return any(
scope.session_id == target scope.session_id == target
and scope.principal_id == principal and scope.principal_id == principal
@@ -982,8 +1181,30 @@ class BrowserControlBroker:
for scope in self._controllers for scope in self._controllers
) )
def _detach_lane_templates_owned_by(self, owner: Any) -> int:
with self._lock:
keys = [
key
for key, lane in self._lane_templates.items()
if lane.owner is owner
]
for principal_id, transport_family in keys:
self.detach_lane_template(
principal_id=principal_id,
transport_family=transport_family,
owner=owner,
)
return len(keys)
def disconnect_owner(self, owner: Any) -> int: def disconnect_owner(self, owner: Any) -> int:
"""Mark every controller owned by one lost transport offline.""" """Mark every controller owned by one lost transport offline.
Lane templates are the exception: a lane controller that goes away
(the user closed Chrome, or the IDE bridge reconnected) is removed
outright so its sessions go back to their normal browser instead of
failing closed until some later reconnect.
"""
self._detach_lane_templates_owned_by(owner)
with self._lock: with self._lock:
scopes = [ scopes = [
scope scope
@@ -997,6 +1218,7 @@ class BrowserControlBroker:
def detach_owner(self, owner: Any, *, notify_controller: bool = True) -> int: def detach_owner(self, owner: Any, *, notify_controller: bool = True) -> int:
"""Hard-detach every controller owned by one transport connection.""" """Hard-detach every controller owned by one transport connection."""
self._detach_lane_templates_owned_by(owner)
with self._lock: with self._lock:
scopes = [ scopes = [
scope scope
@@ -1014,6 +1236,7 @@ class BrowserControlBroker:
def reset(self) -> None: def reset(self) -> None:
"""Fail all live work closed and clear tickets (tests/shutdown).""" """Fail all live work closed and clear tickets (tests/shutdown)."""
with self._lock: with self._lock:
self._lane_templates.clear()
scopes = list(self._controllers) scopes = list(self._controllers)
for scope in scopes: for scope in scopes:
self.detach(scope) self.detach(scope)
@@ -1065,3 +1288,30 @@ def browser_control_enabled(config: Optional[dict] = None) -> bool:
if not isinstance(extension_control, dict): if not isinstance(extension_control, dict):
return False return False
return extension_control.get("enabled", False) is True return extension_control.get("enabled", False) is True
def browser_control_local_enabled(config: Optional[dict] = None) -> bool:
"""Whether the desktop IDE's own browser may become a lane controller.
Separate from :func:`browser_control_enabled` because the local lane is a
different trust boundary: the controller is the user's own browser on the
loopback desktop backend, so it is on unless the user switched it off
(``browser.extension_control.local_desktop: false``). Registering the
controller is still an explicit act of the browser extension.
"""
if config is None:
try:
from hermes_cli.config import load_config_readonly
config = load_config_readonly()
except Exception:
return True
if not isinstance(config, dict):
return True
browser = config.get("browser")
if not isinstance(browser, dict):
return True
extension_control = browser.get("extension_control")
if not isinstance(extension_control, dict):
return True
return extension_control.get("local_desktop", True) is not False
+4
View File
@@ -697,6 +697,10 @@ DEFAULT_CONFIG = {
"extension_control": { "extension_control": {
"enabled": False, "enabled": False,
"developer_mode": False, "developer_mode": False,
# The desktop IDE's own browser (AITURK Chrome behind the native
# bridge) may serve desktop sessions once the extension registers.
# Independent of ``enabled``, which gates remote/API controllers.
"local_desktop": True,
}, },
}, },
+4
View File
@@ -16727,6 +16727,9 @@ def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]:
if not token: if not token:
return "no_credential", "none" return "no_credential", "none"
if hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()): if hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()):
# Only reachable on the loopback / --insecure path: the desktop IDE and
# the bridges it spawns. Marks the local desktop browser-controller lane.
ws._hermes_local_desktop = True
return None, "token" return None, "token"
return "token_mismatch", "token" return "token_mismatch", "token"
@@ -17864,6 +17867,7 @@ async def gateway_ws(ws: WebSocket) -> None:
ws, ws,
auth_identity=getattr(ws, "_hermes_auth_identity", None), auth_identity=getattr(ws, "_hermes_auth_identity", None),
subprotocol=getattr(ws, "_hermes_ws_subprotocol", None), subprotocol=getattr(ws, "_hermes_ws_subprotocol", None),
local_desktop=getattr(ws, "_hermes_local_desktop", False) is True,
) )
+1 -1
View File
@@ -65,7 +65,7 @@
}, },
"apps/desktop": { "apps/desktop": {
"name": "aiturk-ide", "name": "aiturk-ide",
"version": "1.0.0-beta.36", "version": "1.0.0-beta.43",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@assistant-ui/core": "0.2.23", "@assistant-ui/core": "0.2.23",
+46
View File
@@ -2550,6 +2550,39 @@ function Install-Repository {
Write-Success "Repository ready" Write-Success "Repository ready"
} }
function Test-ReusableManagedVenv {
param([string]$VenvPath, [string]$BasePython, [string]$Version)
$pythonExe = Join-Path $VenvPath "Scripts\python.exe"
if (-not (Test-Path -LiteralPath $pythonExe -PathType Leaf)) { return $false }
$process = New-Object System.Diagnostics.Process
try {
$info = New-Object System.Diagnostics.ProcessStartInfo
$info.FileName = $pythonExe
$info.Arguments = '-I -c "import json,sys,ssl,ctypes,venv,encodings; print(json.dumps(dict(version=str(sys.version_info.major)+''.''+str(sys.version_info.minor),prefix=sys.prefix,base=sys._base_executable)))"'
$info.UseShellExecute = $false
$info.CreateNoWindow = $true
$info.RedirectStandardOutput = $true
$info.RedirectStandardError = $true
$process.StartInfo = $info
if (-not $process.Start()) { return $false }
$stdout = $process.StandardOutput.ReadToEndAsync()
$stderr = $process.StandardError.ReadToEndAsync()
if (-not $process.WaitForExit(15000)) {
try { $process.Kill() } catch { }
return $false
}
if ($process.ExitCode -ne 0) { return $false }
$health = $stdout.Result | ConvertFrom-Json
return ($health.version -eq $Version -and
[string]::Equals([IO.Path]::GetFullPath($health.prefix), [IO.Path]::GetFullPath($VenvPath), [StringComparison]::OrdinalIgnoreCase) -and
[string]::Equals([IO.Path]::GetFullPath($health.base), [IO.Path]::GetFullPath($BasePython), [StringComparison]::OrdinalIgnoreCase))
} catch {
return $false
} finally {
$process.Dispose()
}
}
function Install-Venv { function Install-Venv {
if ($NoVenv) { if ($NoVenv) {
Write-Info "Skipping virtual environment (-NoVenv)" Write-Info "Skipping virtual environment (-NoVenv)"
@@ -2567,6 +2600,19 @@ function Install-Venv {
throw "Hermes-managed Python is unavailable. Run install.ps1 -Stage python first." throw "Hermes-managed Python is unavailable. Run install.ps1 -Stage python first."
} }
# Routine source updates do not require replacing a healthy interpreter.
# Recreating it kills detached Codex bridges sharing this venv and leaves
# every subscription model disconnected until bootstrap finishes. Verify
# version, provenance and stdlib first; the dependency stage still performs
# the locked sync and import checks. Broken/mismatched runtimes retain the
# transactional repair path below.
$existingVenvPath = Join-Path $InstallDir "venv"
if (Test-ReusableManagedVenv -VenvPath $existingVenvPath -BasePython $resolvedPython.Path -Version $resolvedPython.Version) {
$env:UV_PYTHON = Join-Path $existingVenvPath "Scripts\python.exe"
Write-Success "Reusing healthy managed virtual environment; running Codex bridges preserved"
return
}
Write-Info "Creating virtual environment with Python $($resolvedPython.Version)..." Write-Info "Creating virtual environment with Python $($resolvedPython.Version)..."
Push-Location $InstallDir Push-Location $InstallDir
@@ -24,6 +24,7 @@ CONTROL_PROTOCOL = "hermes-browser-control-v1"
REAL_BROWSER_CAPABILITIES = { REAL_BROWSER_CAPABILITIES = {
"browser_back", "browser_back",
"browser_click", "browser_click",
"browser_get_images",
"browser_navigate", "browser_navigate",
"browser_press", "browser_press",
"browser_screenshot", "browser_screenshot",
@@ -32,6 +33,7 @@ REAL_BROWSER_CAPABILITIES = {
"browser_tab_activate", "browser_tab_activate",
"browser_tabs", "browser_tabs",
"browser_type", "browser_type",
"browser_vision",
} }
@@ -0,0 +1,345 @@
"""Local desktop browser lane: the AITURK Chrome extension behind the IDE.
One controller registered from the loopback desktop transport serves every
desktop session of the backend; each session still gets an exact scope.
"""
import base64
import json
import threading
from types import SimpleNamespace
import pytest
from gateway.browser_control_broker import (
LOCAL_DESKTOP_PRINCIPAL,
LOCAL_DESKTOP_TRANSPORT_FAMILY,
BrowserControlBroker,
ControllerCancelled,
ControllerScope,
get_browser_control_broker,
)
from hermes_cli import web_server
from tools.browser_extension_router import route_browser_tool
from tui_gateway import server
CAPS = frozenset({"browser_navigate", "browser_snapshot", "browser_vision"})
def _template(controller_id="chrome-1", capabilities=CAPS):
return ControllerScope(
principal_id=LOCAL_DESKTOP_PRINCIPAL,
profile_id="default",
session_id=None,
controller_id=controller_id,
browser_profile_id="chrome-profile",
transport_family=LOCAL_DESKTOP_TRANSPORT_FAMILY,
capabilities=capabilities,
)
class _Writer:
def __init__(self):
self.frames = []
self.sent = threading.Event()
def send_for(self, session_id):
def send(frame):
self.frames.append((session_id, frame))
self.sent.set()
return send
def _lookup(broker, session_id):
return broker.scope_for_session(
session_id=session_id,
principal_id=LOCAL_DESKTOP_PRINCIPAL,
transport_family=LOCAL_DESKTOP_TRANSPORT_FAMILY,
)
def _dispatch_async(broker, scope, action="browser_navigate"):
outcome = {}
def run():
try:
outcome["result"] = broker.dispatch(
scope, action=action, arguments={"url": "https://example.com"}, tool_call_id="call-1"
)
except Exception as exc: # noqa: BLE001 - asserted by the tests
outcome["error"] = exc
thread = threading.Thread(target=run)
thread.start()
return thread, outcome
def test_lane_template_serves_every_session_with_its_own_exact_scope():
broker = BrowserControlBroker(command_timeout=5)
writer = _Writer()
owner = object()
assert broker.lane_registered(
session_id="a", principal_id=LOCAL_DESKTOP_PRINCIPAL, transport_family=LOCAL_DESKTOP_TRANSPORT_FAMILY
) is False
broker.attach_lane_template(_template(), writer.send_for, owner=owner)
first, second = _lookup(broker, "session-a"), _lookup(broker, "session-b")
assert first.session_id == "session-a" and second.session_id == "session-b"
assert _lookup(broker, "session-a") == first
assert broker.select(first, "browser_navigate") is not None
assert broker.select(first, "browser_click") is None
assert broker.lane_registered(
session_id="anything", principal_id=LOCAL_DESKTOP_PRINCIPAL, transport_family=LOCAL_DESKTOP_TRANSPORT_FAMILY
)
thread, outcome = _dispatch_async(broker, second)
assert writer.sent.wait(2)
session_id, frame = writer.frames[-1]
assert session_id == "session-b"
command_id = frame["params"]["command_id"]
assert broker.complete_for_owner(command_id, owner=object(), ok=True, result="x") is False
assert broker.complete_for_owner(command_id, owner=owner, ok=True, result={"success": True}) is True
thread.join(2)
assert outcome == {"result": {"success": True}}
def test_other_lanes_never_see_the_local_template():
broker = BrowserControlBroker()
broker.attach_lane_template(_template(), _Writer().send_for, owner=object())
assert broker.scope_for_session(
session_id="session-a", principal_id="principal:dashboard:x", transport_family="cloud-ticket-ws"
) is None
assert broker.scope_for_session(
session_id="session-a", principal_id=LOCAL_DESKTOP_PRINCIPAL, transport_family="cloud-ticket-ws"
) is None
def test_losing_the_transport_removes_the_lane_and_cancels_its_work():
broker = BrowserControlBroker(command_timeout=5)
writer, owner = _Writer(), object()
broker.attach_lane_template(_template(), writer.send_for, owner=owner)
scope = _lookup(broker, "session-a")
thread, outcome = _dispatch_async(broker, scope)
assert writer.sent.wait(2)
broker.disconnect_owner(owner)
thread.join(2)
assert isinstance(outcome.get("error"), ControllerCancelled)
assert _lookup(broker, "session-a") is None
assert broker.lane_registered(
session_id="session-a", principal_id=LOCAL_DESKTOP_PRINCIPAL, transport_family=LOCAL_DESKTOP_TRANSPORT_FAMILY
) is False
def test_same_controller_refresh_keeps_work_but_a_new_controller_replaces_it():
broker = BrowserControlBroker(command_timeout=5)
writer, owner = _Writer(), object()
broker.attach_lane_template(_template(), writer.send_for, owner=owner)
scope = _lookup(broker, "session-a")
thread, outcome = _dispatch_async(broker, scope)
assert writer.sent.wait(2)
command_id = writer.frames[-1][1]["params"]["command_id"]
refreshed_writer = _Writer()
broker.attach_lane_template(
_template(capabilities=CAPS | {"browser_click"}), refreshed_writer.send_for, owner=owner
)
refreshed = _lookup(broker, "session-a")
assert broker.select(refreshed, "browser_click") is not None
assert broker.complete_for_owner(command_id, owner=owner, ok=True, result="done") is True
thread.join(2)
assert outcome == {"result": "done"}
thread, outcome = _dispatch_async(broker, refreshed)
assert refreshed_writer.sent.wait(2)
broker.attach_lane_template(_template(controller_id="chrome-2"), _Writer().send_for, owner=owner)
thread.join(2)
assert isinstance(outcome.get("error"), ControllerCancelled)
assert _lookup(broker, "session-a").controller_id == "chrome-2"
def test_router_uses_local_lane_without_the_remote_feature_flag(monkeypatch):
monkeypatch.setattr("gateway.browser_control_broker.browser_control_enabled", lambda config=None: False)
monkeypatch.setattr("gateway.browser_control_broker.browser_control_local_enabled", lambda config=None: True)
broker = BrowserControlBroker(command_timeout=5)
calls = []
def fallback():
calls.append("legacy")
return "legacy"
identity = dict(
session_id="session-a",
principal_id=LOCAL_DESKTOP_PRINCIPAL,
transport_family=LOCAL_DESKTOP_TRANSPORT_FAMILY,
)
# No controller registered yet: the normal browser keeps working.
assert route_browser_tool("browser_navigate", {}, fallback=fallback, broker=broker, enabled=True, **identity) == "legacy"
owner = object()
def send_for(session_id):
def send(frame):
broker.complete_for_owner(
frame["params"]["command_id"], owner=owner, ok=True, result={"success": True, "screenshot": "eA=="}
)
return send
broker.attach_lane_template(_template(), send_for, owner=owner)
seen = []
result = route_browser_tool(
"browser_vision", {"question": "q"}, fallback=fallback, broker=broker, enabled=True,
transform=lambda value: seen.append(value) or {"envelope": True}, **identity,
)
assert result == {"envelope": True}
assert seen == [{"success": True, "screenshot": "eA=="}]
assert calls == ["legacy"]
def test_lane_switch_off_keeps_the_normal_browser(monkeypatch):
from tools import browser_extension_router as router
monkeypatch.setattr("gateway.browser_control_broker.browser_control_local_enabled", lambda config=None: False)
assert router.lane_enabled(LOCAL_DESKTOP_TRANSPORT_FAMILY) is False
monkeypatch.setattr("gateway.browser_control_broker.browser_control_local_enabled", lambda config=None: True)
assert router.lane_enabled(LOCAL_DESKTOP_TRANSPORT_FAMILY) is True
def test_local_switch_defaults_on_and_respects_explicit_off():
from gateway.browser_control_broker import browser_control_local_enabled
assert browser_control_local_enabled({}) is True
assert browser_control_local_enabled({"browser": {"extension_control": {"enabled": False}}}) is True
assert browser_control_local_enabled({"browser": {"extension_control": {"local_desktop": False}}}) is False
def test_only_the_loopback_token_marks_a_local_desktop_transport():
token_ws = SimpleNamespace(
query_params={"token": web_server._SESSION_TOKEN}, headers={},
client=SimpleNamespace(host="127.0.0.1"), url=SimpleNamespace(path="/api/ws"),
)
previous = getattr(web_server.app.state, "auth_required", False)
web_server.app.state.auth_required = False
try:
assert web_server._ws_auth_ok(token_ws) is True
assert token_ws._hermes_local_desktop is True
wrong = SimpleNamespace(
query_params={"token": "not-the-token"}, headers={},
client=SimpleNamespace(host="127.0.0.1"), url=SimpleNamespace(path="/api/ws"),
)
assert web_server._ws_auth_ok(wrong) is False
assert getattr(wrong, "_hermes_local_desktop", False) is False
finally:
web_server.app.state.auth_required = previous
def test_desktop_session_context_binds_the_local_lane_identity():
from gateway.session_context import clear_session_vars, get_session_env
server._sessions["local-context-fixture"] = {
"transport": SimpleNamespace(auth_identity=None, local_desktop=True),
"session_key": "stored-local-context",
"profile": None,
"agent": SimpleNamespace(session_id="durable-local-session"),
}
tokens = []
try:
tokens = server._set_session_context("stored-local-context")
assert get_session_env("HERMES_SESSION_ID") == "durable-local-session"
assert get_session_env("HERMES_BROWSER_CONTROL_PRINCIPAL") == LOCAL_DESKTOP_PRINCIPAL
assert get_session_env("HERMES_BROWSER_CONTROL_TRANSPORT_FAMILY") == LOCAL_DESKTOP_TRANSPORT_FAMILY
finally:
clear_session_vars(tokens)
server._sessions.pop("local-context-fixture", None)
def _rpc(method, params, transport):
return server.dispatch({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, transport)
class _Transport:
def __init__(self, local=True):
self.auth_identity = None
self.local_desktop = local
self.frames = []
self.sent = threading.Event()
def write(self, frame):
self.frames.append(frame)
self.sent.set()
return True
REGISTER = {
"protocol_version": 1,
"controller_id": "chrome-extension",
"browser_profile_id": "chrome-default",
"capabilities": ["browser_navigate", "browser_snapshot", "browser_cdp", "unknown.method"],
}
def test_local_registration_is_refused_off_the_desktop_transport(monkeypatch):
monkeypatch.setattr("gateway.browser_control_broker.browser_control_local_enabled", lambda config=None: True)
get_browser_control_broker().reset()
assert _rpc("browser.controller.local.register", REGISTER, _Transport(local=False))["error"]["code"] == 4403
monkeypatch.setattr("gateway.browser_control_broker.browser_control_local_enabled", lambda config=None: False)
assert _rpc("browser.controller.local.register", REGISTER, _Transport())["error"]["code"] == 4403
assert _rpc("browser.controller.local.register", {**REGISTER, "capabilities": ["browser_cdp"]}, _Transport())["error"]["code"] == 4403
def test_local_gateway_round_trip_and_detach(monkeypatch):
monkeypatch.setattr("gateway.browser_control_broker.browser_control_local_enabled", lambda config=None: True)
broker = get_browser_control_broker()
broker.reset()
transport = _Transport()
try:
registered = _rpc("browser.controller.local.register", REGISTER, transport)["result"]
assert registered["capabilities"] == ["browser_navigate", "browser_snapshot"]
scope = _lookup(broker, "durable-session")
thread, outcome = _dispatch_async(broker, scope)
assert transport.sent.wait(2)
event = transport.frames[-1]
assert event["method"] == "event"
assert event["params"]["type"] == "browser.controller.command"
assert event["params"]["session_id"] == "durable-session"
command_id = event["params"]["payload"]["command_id"]
foreign = _rpc("browser.controller.local.result", {"command_id": command_id, "ok": True}, _Transport())
assert foreign["result"]["accepted"] is False
done = _rpc("browser.controller.local.result", {"command_id": command_id, "ok": True, "result": {"success": True}}, transport)
assert done["result"]["accepted"] is True
thread.join(2)
assert outcome == {"result": {"success": True}}
assert _rpc("browser.controller.local.detach", {}, transport)["result"]["detached"] is True
assert _lookup(broker, "durable-session") is None
finally:
broker.reset()
def test_vision_from_controller_saves_the_jpeg_and_reuses_the_analysis(monkeypatch, tmp_path):
from tools import browser_tool
monkeypatch.setattr("hermes_constants.get_hermes_dir", lambda *parts: tmp_path)
captured = {}
def analyze(path, question, annotate, result, *, mime_type="image/png", fallback_warning=None):
captured.update(path=path, question=question, mime=mime_type, bytes=path.read_bytes())
return {"native": True}
monkeypatch.setattr(browser_tool, "_analyze_browser_screenshot", analyze)
jpeg = b"\xff\xd8\xff\xe0fixture"
out = browser_tool._browser_vision_from_controller(
{"success": True, "screenshot": base64.b64encode(jpeg).decode(), "mime": "image/jpeg"}, "Ne görünüyor?"
)
assert out == {"native": True}
assert captured["bytes"] == jpeg and captured["mime"] == "image/jpeg"
assert captured["path"].suffix == ".jpg" and captured["question"] == "Ne görünüyor?"
bad = json.loads(browser_tool._browser_vision_from_controller({"success": True, "screenshot": "%%%"}, "q"))
assert bad["success"] is False
failed = json.loads(browser_tool._browser_vision_from_controller({"success": False, "error": "Sekme kapandı"}, "q"))
assert failed == {"success": False, "error": "Sekme kapandı"}
@@ -0,0 +1,77 @@
"""Run the actual installer functions against a real disposable Python venv."""
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
SOURCE = Path(__file__).resolve().parents[1] / 'scripts/install.ps1'
PS = shutil.which('powershell') or shutil.which('pwsh')
pytestmark = pytest.mark.skipif(os.name != 'nt' or not PS, reason='Windows installer runtime test')
def function(source, name):
start = source.index('function ' + name + ' {')
opening = source.index('{', start)
depth = 0
for index in range(opening, len(source)):
if source[index] == '{': depth += 1
elif source[index] == '}': depth -= 1
if depth == 0: return source[start:index + 1]
raise AssertionError(name)
@pytest.fixture(scope='module')
def runtime(tmp_path_factory):
root = tmp_path_factory.mktemp('installer-reuse')
subprocess.run([sys.executable, '-m', 'venv', '--without-pip', str(root / 'venv')], check=True)
return root
def run(runtime, body):
text = SOURCE.read_text(encoding='utf-8-sig')
script = runtime / 'probe.ps1'
prefix = "$ErrorActionPreference = 'Stop'\n"
prefix += function(text, 'Test-ReusableManagedVenv') + '\n'
prefix += function(text, 'Install-Venv') + '\n'
prefix += "$InstallDir = '" + str(runtime).replace("'", "''") + "'\n"
prefix += "$BasePython = '" + sys._base_executable.replace("'", "''") + "'\n"
prefix += "$Version = '" + str(sys.version_info.major) + '.' + str(sys.version_info.minor) + "'\n"
script.write_text(prefix + body, encoding='utf-8-sig')
result = subprocess.run([PS, '-NoProfile', '-File', str(script)], capture_output=True, text=True, timeout=40)
assert result.returncode == 0, result.stdout + result.stderr
return result.stdout
def test_healthy_update_keeps_live_python_and_never_reaches_kill_sweep(runtime):
python = runtime / 'venv/Scripts/python.exe'
live = subprocess.Popen([str(python), '-c', 'import time;time.sleep(90)'])
try:
output = run(runtime, r'''
function Resolve-AvailablePythonVersion { return @{Path=$BasePython;Version=$Version} }
function Write-Success { param($Message); Write-Output $Message }
function Write-Info { throw 'Unexpected venv recreation/kill path' }
function taskkill { throw 'Live process must not be killed' }
Install-Venv
''')
assert 'Reusing healthy' in output
assert live.poll() is None
assert python.is_file()
finally:
live.terminate();live.wait(timeout=10)
def test_wrong_version_or_foreign_interpreter_does_not_reuse(runtime):
output = run(runtime, r'''
$venvPath = Join-Path $InstallDir 'venv'
$results = @(
(Test-ReusableManagedVenv $venvPath $BasePython '0.0'),
(Test-ReusableManagedVenv $venvPath (Join-Path $InstallDir 'other-python.exe') $Version),
(Test-ReusableManagedVenv (Join-Path $InstallDir 'missing') $BasePython $Version)
)
ConvertTo-Json -Compress -InputObject $results
''')
assert json.loads(output) == [False, False, False]
@@ -19,7 +19,7 @@ def _route_spy(monkeypatch):
exercising real routing or a real browser backend.""" exercising real routing or a real browser backend."""
calls = [] calls = []
def spy(action, args, *, fallback, task_id=None, session_id=None, tool_call_id=None): def spy(action, args, *, fallback, task_id=None, session_id=None, tool_call_id=None, transform=None):
calls.append( calls.append(
{ {
"action": action, "action": action,
@@ -27,6 +27,7 @@ def _route_spy(monkeypatch):
"task_id": task_id, "task_id": task_id,
"session_id": session_id, "session_id": session_id,
"tool_call_id": tool_call_id, "tool_call_id": tool_call_id,
"transform": transform is not None,
} }
) )
return fallback() return fallback()
+38 -16
View File
@@ -45,6 +45,24 @@ from typing import Any, Callable, Dict, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def lane_enabled(transport_family: Optional[str]) -> bool:
"""Feature gate for one controller lane.
The desktop IDE's own browser lane has its own switch
(``browser.extension_control.local_desktop``, on by default); every other
lane keeps the explicit ``browser.extension_control.enabled`` opt-in.
"""
from gateway.browser_control_broker import (
LOCAL_DESKTOP_TRANSPORT_FAMILY,
browser_control_enabled,
browser_control_local_enabled,
)
if transport_family == LOCAL_DESKTOP_TRANSPORT_FAMILY:
return browser_control_local_enabled()
return browser_control_enabled()
def extension_controller_available(action: str) -> bool: def extension_controller_available(action: str) -> bool:
"""Whether this request owns one exact controller capable of ``action``. """Whether this request owns one exact controller capable of ``action``.
@@ -56,14 +74,9 @@ def extension_controller_available(action: str) -> bool:
all fail closed. all fail closed.
""" """
try: try:
from gateway.browser_control_broker import ( from gateway.browser_control_broker import get_browser_control_broker
browser_control_enabled,
get_browser_control_broker,
)
from gateway.session_context import get_session_env from gateway.session_context import get_session_env
if not browser_control_enabled():
return False
session_id = get_session_env("HERMES_SESSION_ID", "") or None session_id = get_session_env("HERMES_SESSION_ID", "") or None
principal_id = get_session_env("HERMES_BROWSER_CONTROL_PRINCIPAL", "") or None principal_id = get_session_env("HERMES_BROWSER_CONTROL_PRINCIPAL", "") or None
transport_family = get_session_env( transport_family = get_session_env(
@@ -71,6 +84,8 @@ def extension_controller_available(action: str) -> bool:
) or None ) or None
if not session_id or not principal_id or not transport_family: if not session_id or not principal_id or not transport_family:
return False return False
if not lane_enabled(transport_family):
return False
broker = get_browser_control_broker() broker = get_browser_control_broker()
scope = broker.scope_for_session( scope = broker.scope_for_session(
session_id=session_id, session_id=session_id,
@@ -99,6 +114,7 @@ def route_browser_tool(
principal_id: Optional[str] = None, principal_id: Optional[str] = None,
transport_family: Optional[str] = None, transport_family: Optional[str] = None,
tool_call_id: Optional[str] = "", tool_call_id: Optional[str] = "",
transform: Optional[Callable[[Any], Any]] = None,
) -> Any: ) -> Any:
"""Route one browser action through the extension-control broker. """Route one browser action through the extension-control broker.
@@ -127,6 +143,11 @@ def route_browser_tool(
Hermes callers. Hermes callers.
tool_call_id: tool_call_id:
Caller tool-call id forwarded verbatim to ``dispatch``. Caller tool-call id forwarded verbatim to ``dispatch``.
transform:
Optional post-processor for a *controller* result only (never for the
fallback). Its return value is the tool result as-is, so it may build
the multimodal envelope (``browser_vision`` turns the controller's
screenshot into the same result the legacy backend returns).
Returns Returns
------- -------
@@ -184,6 +205,8 @@ def route_browser_tool(
result = broker.dispatch( result = broker.dispatch(
scope, action=action, arguments=args, tool_call_id=tool_call_id scope, action=action, arguments=args, tool_call_id=tool_call_id
) )
if transform is not None:
return transform(result)
if isinstance(result, str): if isinstance(result, str):
return result return result
return json.dumps(result, ensure_ascii=False) return json.dumps(result, ensure_ascii=False)
@@ -215,6 +238,7 @@ def routed_browser_handler(
principal_id: Optional[str] = None, principal_id: Optional[str] = None,
transport_family: Optional[str] = None, transport_family: Optional[str] = None,
tool_call_id: Optional[str] = None, tool_call_id: Optional[str] = None,
transform: Optional[Callable[[Any], Any]] = None,
) -> Any: ) -> Any:
"""Lazy registry-handler route wrapper for ``browser_*`` tools. """Lazy registry-handler route wrapper for ``browser_*`` tools.
@@ -225,10 +249,7 @@ def routed_browser_handler(
legacy handler runs unchanged. legacy handler runs unchanged.
""" """
try: try:
from gateway.browser_control_broker import ( from gateway.browser_control_broker import get_browser_control_broker
browser_control_enabled,
get_browser_control_broker,
)
except Exception as exc: # pragma: no cover - defensive, gateway always present except Exception as exc: # pragma: no cover - defensive, gateway always present
logger.debug( logger.debug(
"browser extension router unavailable (%s); using legacy backend", "browser extension router unavailable (%s); using legacy backend",
@@ -236,12 +257,6 @@ def routed_browser_handler(
) )
return fallback() return fallback()
if not browser_control_enabled():
return fallback()
if tool_call_id is None:
tool_call_id = current_tool_call_id()
try: try:
from gateway.session_context import get_session_env from gateway.session_context import get_session_env
@@ -255,6 +270,12 @@ def routed_browser_handler(
except Exception: except Exception:
pass pass
if not lane_enabled(transport_family):
return fallback()
if tool_call_id is None:
tool_call_id = current_tool_call_id()
return route_browser_tool( return route_browser_tool(
action, action,
args, args,
@@ -266,4 +287,5 @@ def routed_browser_handler(
principal_id=principal_id, principal_id=principal_id,
transport_family=transport_family, transport_family=transport_family,
tool_call_id=tool_call_id, tool_call_id=tool_call_id,
transform=transform,
) )
+251 -146
View File
@@ -5676,152 +5676,14 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
), ),
}, ensure_ascii=False) }, ensure_ascii=False)
# NOTE: the full-resolution base64 encode is deliberately deferred. return _analyze_browser_screenshot(
# The native fast path below sizes its own history-reuse embed via screenshot_path,
# _resize_image_for_vision (stat-based quick estimate — no full-res question,
# encode when oversized), and only the aux-LLM fallback path needs annotate,
# the one-shot full-res data URL. result,
fallback_warning=_lp_fallback_warning,
# Fast path: when native image routing is in effect for the active main
# model, attach the screenshot directly instead of describing it through
# an auxiliary vision LLM. The model inspects the pixels on its next
# turn — no aux call, no information loss. Consistent with vision_analyze.
from tools.vision_tools import (
_EMBED_MAX_DIMENSION,
_EMBED_TARGET_BYTES,
_build_native_vision_tool_result,
_resize_image_for_vision,
_should_use_native_vision_fast_path,
) )
if _should_use_native_vision_fast_path():
# History-reuse cap (#92699): this embed is baked into the tool
# result and re-sent on every later turn, exactly like
# vision_analyze's native path — apply the same proactive resize
# so full-res screenshots can't enter immutable history uncapped.
# The helper's internal stat/dimension quick-estimate skips the
# resize (and encodes directly) when the screenshot is already
# under both caps, so no full-res base64 is built just to be
# thrown away. Fail-open: without Pillow it falls back to the
# raw bytes and the compressor's keep-newest pass still retires
# stale embeds.
data_url = _resize_image_for_vision(
screenshot_path,
mime_type="image/png",
max_base64_bytes=_EMBED_TARGET_BYTES,
max_dimension=_EMBED_MAX_DIMENSION,
force_jpeg=True,
)
native_result = _build_native_vision_tool_result(
image_url=str(screenshot_path),
question=question,
image_data_url=data_url,
image_size_bytes=screenshot_path.stat().st_size,
)
meta = native_result.setdefault("meta", {})
meta["screenshot_path"] = str(screenshot_path)
if _lp_fallback_warning:
meta["fallback_warning"] = _lp_fallback_warning
if annotate and result.get("data", {}).get("annotations"):
meta["annotations"] = result["data"]["annotations"]
native_result["text_summary"] = (
f"{native_result.get('text_summary', '')} "
f"Screenshot path: {screenshot_path}"
).strip()
return native_result
vision_prompt = (
f"You are analyzing a screenshot of a web browser.\n\n"
f"User's question: {question}\n\n"
f"Provide a detailed and helpful answer based on what you see in the screenshot. "
f"If there are interactive elements, describe them. If there are verification challenges "
f"or CAPTCHAs, describe what type they are and what action might be needed. "
f"Focus on answering the user's specific question."
)
# Aux-LLM path: one-shot analysis, not baked into history — encode at
# full resolution here (the pre-existing 5 MB oversize guard below
# still applies).
_screenshot_bytes = screenshot_path.read_bytes()
_screenshot_b64 = base64.b64encode(_screenshot_bytes).decode("ascii")
data_url = f"data:image/png;base64,{_screenshot_b64}"
# Use the centralized LLM router
vision_model = _get_vision_model()
logger.debug("browser_vision: analysing screenshot (%d bytes)",
len(_screenshot_bytes))
# Read vision timeout/temperature from config (auxiliary.vision.*).
# Local vision models (llama.cpp, ollama) can take well over 30s for
# screenshot analysis, so the default timeout must be generous.
vision_timeout = 120.0
vision_temperature = 0.1
try:
from hermes_cli.config import load_config
_cfg = load_config()
_vision_cfg = cfg_get(_cfg, "auxiliary", "vision", default={})
_vt = _vision_cfg.get("timeout")
if _vt is not None:
vision_timeout = float(_vt)
_vtemp = _vision_cfg.get("temperature")
if _vtemp is not None:
vision_temperature = float(_vtemp)
except Exception:
pass
call_kwargs = {
"task": "vision",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": vision_prompt},
{"type": "image_url", "image_url": {"url": data_url}},
],
}
],
"temperature": vision_temperature,
"timeout": vision_timeout,
}
if vision_model:
call_kwargs["model"] = vision_model
# Try full-size screenshot; on size-related rejection, downscale and retry.
try:
response = _lazy_call_llm(**call_kwargs)
except Exception as _api_err:
from tools.vision_tools import (
_is_image_size_error, _resize_image_for_vision, _RESIZE_TARGET_BYTES,
)
if (_is_image_size_error(_api_err)
and len(data_url) > _RESIZE_TARGET_BYTES):
logger.info(
"Vision API rejected screenshot (%.1f MB); "
"auto-resizing to ~%.0f MB and retrying...",
len(data_url) / (1024 * 1024),
_RESIZE_TARGET_BYTES / (1024 * 1024),
)
data_url = _resize_image_for_vision(
screenshot_path, mime_type="image/png")
call_kwargs["messages"][0]["content"][1]["image_url"]["url"] = data_url
response = _lazy_call_llm(**call_kwargs)
else:
raise
analysis = (response.choices[0].message.content or "").strip()
# Redact secrets the vision LLM may have read from the screenshot.
from agent.redact import redact_sensitive_text
analysis = redact_sensitive_text(analysis)
response_data = {
"success": True,
"analysis": analysis or "Vision analysis returned no content.",
"screenshot_path": str(screenshot_path),
}
_copy_fallback_warning(response_data, result)
# Include annotation data if annotated screenshot was taken
if annotate and result.get("data", {}).get("annotations"):
response_data["annotations"] = result["data"]["annotations"]
return json.dumps(response_data, ensure_ascii=False)
except Exception as e: except Exception as e:
# Keep the screenshot if it was captured successfully — the failure is # Keep the screenshot if it was captured successfully — the failure is
# in the LLM vision analysis, not the capture. Deleting a valid # in the LLM vision analysis, not the capture. Deleting a valid
@@ -5836,6 +5698,243 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
return json.dumps(error_info, ensure_ascii=False) return json.dumps(error_info, ensure_ascii=False)
#: Largest decoded screenshot accepted from a browser-extension controller.
_CONTROLLER_SCREENSHOT_MAX_BYTES = 8 * 1024 * 1024
def _browser_vision_from_controller(
controller_result: Any, question: str, annotate: bool = False
) -> Union[str, Dict[str, Any]]:
"""Turn an extension controller's screenshot into a browser_vision result.
The controller (the user's own browser) only captures the visible tab:
``{"success": true, "screenshot": <base64>, "mime": "image/jpeg", ...}``.
Saving the image and the native/auxiliary vision analysis stay here, so
the model receives exactly what the legacy backend would return.
"""
import base64
import uuid as uuid_mod
from hermes_constants import get_hermes_dir
data = controller_result
if isinstance(data, str):
try:
data = json.loads(data)
except (TypeError, ValueError):
return data
if not isinstance(data, dict):
return json.dumps(
{"success": False, "error": "Browser returned no screenshot."},
ensure_ascii=False,
)
if data.get("success") is not True or not isinstance(data.get("screenshot"), str):
return json.dumps(
{
"success": False,
"error": str(data.get("error") or "Browser returned no screenshot."),
},
ensure_ascii=False,
)
mime = "image/png" if data.get("mime") == "image/png" else "image/jpeg"
try:
raw = base64.b64decode(data["screenshot"], validate=True)
except (ValueError, TypeError):
return json.dumps(
{"success": False, "error": "Browser returned an unreadable screenshot."},
ensure_ascii=False,
)
if not raw or len(raw) > _CONTROLLER_SCREENSHOT_MAX_BYTES:
return json.dumps(
{"success": False, "error": "Browser screenshot is empty or too large."},
ensure_ascii=False,
)
screenshots_dir = get_hermes_dir("cache/screenshots", "browser_screenshots")
screenshots_dir.mkdir(parents=True, exist_ok=True)
_cleanup_old_screenshots(screenshots_dir, max_age_hours=24)
suffix = ".png" if mime == "image/png" else ".jpg"
screenshot_path = screenshots_dir / f"browser_screenshot_{uuid_mod.uuid4().hex}{suffix}"
screenshot_path.write_bytes(raw)
capture = {"success": True, "data": {"path": str(screenshot_path)}}
annotations = data.get("annotations")
if annotate and isinstance(annotations, list):
capture["data"]["annotations"] = annotations
try:
return _analyze_browser_screenshot(
screenshot_path, question, annotate, capture, mime_type=mime
)
except Exception as e:
logger.warning("browser_vision (extension) failed: %s", e, exc_info=True)
return json.dumps(
{
"success": False,
"error": f"Error during vision analysis: {str(e)}",
"screenshot_path": str(screenshot_path),
"note": "Screenshot was captured but vision analysis failed. You can still share it via MEDIA:<path>.",
},
ensure_ascii=False,
)
def _analyze_browser_screenshot(
screenshot_path: Path,
question: str,
annotate: bool,
result: Dict[str, Any],
*,
mime_type: str = "image/png",
fallback_warning: Optional[str] = None,
) -> Union[str, Dict[str, Any]]:
"""Native-attach or auxiliary-LLM analysis of one saved browser screenshot."""
import base64
_lp_fallback_warning = fallback_warning
# NOTE: the full-resolution base64 encode is deliberately deferred.
# The native fast path below sizes its own history-reuse embed via
# _resize_image_for_vision (stat-based quick estimate — no full-res
# encode when oversized), and only the aux-LLM fallback path needs
# the one-shot full-res data URL.
# Fast path: when native image routing is in effect for the active main
# model, attach the screenshot directly instead of describing it through
# an auxiliary vision LLM. The model inspects the pixels on its next
# turn — no aux call, no information loss. Consistent with vision_analyze.
from tools.vision_tools import (
_EMBED_MAX_DIMENSION,
_EMBED_TARGET_BYTES,
_build_native_vision_tool_result,
_resize_image_for_vision,
_should_use_native_vision_fast_path,
)
if _should_use_native_vision_fast_path():
# History-reuse cap (#92699): this embed is baked into the tool
# result and re-sent on every later turn, exactly like
# vision_analyze's native path — apply the same proactive resize
# so full-res screenshots can't enter immutable history uncapped.
# The helper's internal stat/dimension quick-estimate skips the
# resize (and encodes directly) when the screenshot is already
# under both caps, so no full-res base64 is built just to be
# thrown away. Fail-open: without Pillow it falls back to the
# raw bytes and the compressor's keep-newest pass still retires
# stale embeds.
data_url = _resize_image_for_vision(
screenshot_path,
mime_type=mime_type,
max_base64_bytes=_EMBED_TARGET_BYTES,
max_dimension=_EMBED_MAX_DIMENSION,
force_jpeg=True,
)
native_result = _build_native_vision_tool_result(
image_url=str(screenshot_path),
question=question,
image_data_url=data_url,
image_size_bytes=screenshot_path.stat().st_size,
)
meta = native_result.setdefault("meta", {})
meta["screenshot_path"] = str(screenshot_path)
if _lp_fallback_warning:
meta["fallback_warning"] = _lp_fallback_warning
if annotate and result.get("data", {}).get("annotations"):
meta["annotations"] = result["data"]["annotations"]
native_result["text_summary"] = (
f"{native_result.get('text_summary', '')} "
f"Screenshot path: {screenshot_path}"
).strip()
return native_result
vision_prompt = (
f"You are analyzing a screenshot of a web browser.\n\n"
f"User's question: {question}\n\n"
f"Provide a detailed and helpful answer based on what you see in the screenshot. "
f"If there are interactive elements, describe them. If there are verification challenges "
f"or CAPTCHAs, describe what type they are and what action might be needed. "
f"Focus on answering the user's specific question."
)
# Aux-LLM path: one-shot analysis, not baked into history — encode at
# full resolution here (the pre-existing 5 MB oversize guard below
# still applies).
_screenshot_bytes = screenshot_path.read_bytes()
_screenshot_b64 = base64.b64encode(_screenshot_bytes).decode("ascii")
data_url = f"data:{mime_type};base64,{_screenshot_b64}"
# Use the centralized LLM router
vision_model = _get_vision_model()
logger.debug("browser_vision: analysing screenshot (%d bytes)",
len(_screenshot_bytes))
# Read vision timeout/temperature from config (auxiliary.vision.*).
# Local vision models (llama.cpp, ollama) can take well over 30s for
# screenshot analysis, so the default timeout must be generous.
vision_timeout = 120.0
vision_temperature = 0.1
try:
from hermes_cli.config import load_config
_cfg = load_config()
_vision_cfg = cfg_get(_cfg, "auxiliary", "vision", default={})
_vt = _vision_cfg.get("timeout")
if _vt is not None:
vision_timeout = float(_vt)
_vtemp = _vision_cfg.get("temperature")
if _vtemp is not None:
vision_temperature = float(_vtemp)
except Exception:
pass
call_kwargs = {
"task": "vision",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": vision_prompt},
{"type": "image_url", "image_url": {"url": data_url}},
],
}
],
"temperature": vision_temperature,
"timeout": vision_timeout,
}
if vision_model:
call_kwargs["model"] = vision_model
# Try full-size screenshot; on size-related rejection, downscale and retry.
try:
response = _lazy_call_llm(**call_kwargs)
except Exception as _api_err:
from tools.vision_tools import (
_is_image_size_error, _resize_image_for_vision, _RESIZE_TARGET_BYTES,
)
if (_is_image_size_error(_api_err)
and len(data_url) > _RESIZE_TARGET_BYTES):
logger.info(
"Vision API rejected screenshot (%.1f MB); "
"auto-resizing to ~%.0f MB and retrying...",
len(data_url) / (1024 * 1024),
_RESIZE_TARGET_BYTES / (1024 * 1024),
)
data_url = _resize_image_for_vision(
screenshot_path, mime_type=mime_type)
call_kwargs["messages"][0]["content"][1]["image_url"]["url"] = data_url
response = _lazy_call_llm(**call_kwargs)
else:
raise
analysis = (response.choices[0].message.content or "").strip()
# Redact secrets the vision LLM may have read from the screenshot.
from agent.redact import redact_sensitive_text
analysis = redact_sensitive_text(analysis)
response_data = {
"success": True,
"analysis": analysis or "Vision analysis returned no content.",
"screenshot_path": str(screenshot_path),
}
_copy_fallback_warning(response_data, result)
# Include annotation data if annotated screenshot was taken
if annotate and result.get("data", {}).get("annotations"):
response_data["annotations"] = result["data"]["annotations"]
return json.dumps(response_data, ensure_ascii=False)
def _cleanup_old_screenshots(screenshots_dir, max_age_hours=24): def _cleanup_old_screenshots(screenshots_dir, max_age_hours=24):
"""Remove browser screenshots older than max_age_hours to prevent disk bloat. """Remove browser screenshots older than max_age_hours to prevent disk bloat.
@@ -6381,7 +6480,10 @@ def check_browser_vision_requirements() -> bool:
fails at call time with a cryptic provider-side error like fails at call time with a cryptic provider-side error like
``unknown variant `image_url`, expected `text``` (issue #31179). ``unknown variant `image_url`, expected `text``` (issue #31179).
""" """
if not check_browser_requirements(): if not (
check_browser_requirements()
or extension_controller_available("browser_vision")
):
return False return False
try: try:
from tools.vision_tools import check_vision_requirements from tools.vision_tools import check_vision_requirements
@@ -6602,7 +6704,7 @@ registry.register(
fallback=lambda: browser_get_images(task_id=kw.get("task_id")), fallback=lambda: browser_get_images(task_id=kw.get("task_id")),
**_browser_router_kw(kw), **_browser_router_kw(kw),
), ),
check_fn=check_browser_requirements, check_fn=lambda: check_browser_routed_requirements("browser_get_images"),
emoji="🖼️", emoji="🖼️",
) )
registry.register( registry.register(
@@ -6613,6 +6715,9 @@ registry.register(
"browser_vision", "browser_vision",
args, args,
fallback=lambda: browser_vision(question=args.get("question", ""), annotate=args.get("annotate", False), task_id=kw.get("task_id")), fallback=lambda: browser_vision(question=args.get("question", ""), annotate=args.get("annotate", False), task_id=kw.get("task_id")),
transform=lambda result: _browser_vision_from_controller(
result, args.get("question", ""), bool(args.get("annotate", False))
),
**_browser_router_kw(kw), **_browser_router_kw(kw),
), ),
check_fn=check_browser_vision_requirements, check_fn=check_browser_vision_requirements,
+134
View File
@@ -38,6 +38,8 @@ import logging
from gateway.browser_control_broker import ( from gateway.browser_control_broker import (
BROWSER_CONTROL_PROTOCOL_VERSION, BROWSER_CONTROL_PROTOCOL_VERSION,
LOCAL_DESKTOP_PRINCIPAL,
LOCAL_DESKTOP_TRANSPORT_FAMILY,
browser_control_protocol_supported, browser_control_protocol_supported,
filter_browser_control_capabilities, filter_browser_control_capabilities,
) )
@@ -58,9 +60,17 @@ method = _registry.method
#: transport can never address a dashboard controller (and vice versa). #: transport can never address a dashboard controller (and vice versa).
_CLOUD_TRANSPORT_FAMILY = "cloud-ticket-ws" _CLOUD_TRANSPORT_FAMILY = "cloud-ticket-ws"
#: Local desktop lane: the AITURK Chrome extension reaches this backend only
#: through the desktop IDE's native bridge, which authenticates with the
#: loopback session token (``WSTransport.local_desktop``).
_LOCAL_TRANSPORT_FAMILY = LOCAL_DESKTOP_TRANSPORT_FAMILY
_LOCAL_PRINCIPAL = LOCAL_DESKTOP_PRINCIPAL
#: JSON-RPC error code for identity / session / flag denials (forbidden). #: JSON-RPC error code for identity / session / flag denials (forbidden).
_ERR_FORBIDDEN = 4403 _ERR_FORBIDDEN = 4403
_ID_LIMIT = 200
def _is_authenticated_identity(identity: object) -> bool: def _is_authenticated_identity(identity: object) -> bool:
"""True for a server-minted, non-internal ``{user_id, provider}`` identity.""" """True for a server-minted, non-internal ``{user_id, provider}`` identity."""
@@ -377,6 +387,130 @@ def _(
return _ok(rid, {"detached": True}) return _ok(rid, {"detached": True})
def _is_local_desktop(transport: object) -> bool:
return getattr(transport, "local_desktop", False) is True
@method("browser.controller.local.register")
def _(
rid,
params: dict,
_family=_LOCAL_TRANSPORT_FAMILY,
_principal=_LOCAL_PRINCIPAL,
_protocol_supported=browser_control_protocol_supported,
_protocol_version=BROWSER_CONTROL_PROTOCOL_VERSION,
_filter_capabilities=filter_browser_control_capabilities,
_forbidden=_ERR_FORBIDDEN,
_local=_is_local_desktop,
_event_writer=_broker_event_writer,
_id_limit=_ID_LIMIT,
) -> dict:
"""Offer the desktop user's own browser to every desktop session.
Only the loopback desktop transport (the IDE's native Chrome bridge) may
register, and only while ``browser.extension_control.local_desktop`` is
on. The controller becomes a lane template: each desktop session that
uses a browser tool gets its own exact scope on first use, and its frames
carry that session id. Registering again from the same transport
refreshes it; closing the transport removes it, so sessions go back to
their normal browser backend.
"""
from gateway import browser_control_broker
transport = current_transport()
if not _local(transport):
return _err(
rid, _forbidden, "local browser controller requires the desktop transport"
)
if not browser_control_broker.browser_control_local_enabled():
return _err(rid, _forbidden, "browser.extension_control.local_desktop is off")
if not _protocol_supported(params.get("protocol_version")):
return _err(
rid,
_forbidden,
f"unsupported browser-control protocol version; expected {_protocol_version}",
)
controller_id = str(params.get("controller_id") or "").strip()
browser_profile_id = str(params.get("browser_profile_id") or "").strip()
profile_id = str(params.get("profile") or "local").strip() or "local"
if (
not controller_id
or not browser_profile_id
or max(len(controller_id), len(browser_profile_id), len(profile_id)) > _id_limit
):
return _err(rid, _forbidden, "controller_id and browser_profile_id are required")
capabilities = _filter_capabilities(params.get("capabilities"))
if not capabilities:
return _err(rid, _forbidden, "no permitted controller capabilities requested")
template = browser_control_broker.ControllerScope(
principal_id=_principal,
profile_id=profile_id,
session_id=None,
controller_id=controller_id,
browser_profile_id=browser_profile_id,
transport_family=_family,
capabilities=capabilities,
)
broker = browser_control_broker.get_browser_control_broker()
broker.attach_lane_template(
template,
lambda session_id: _event_writer(transport, session_id),
owner=transport,
)
return _ok(
rid,
{
"registered": True,
"transport_family": _family,
"capabilities": sorted(capabilities),
},
)
@method("browser.controller.local.result")
def _(rid, params: dict, _forbidden=_ERR_FORBIDDEN, _local=_is_local_desktop) -> dict:
"""Deliver one local controller result; only its own transport may."""
from gateway import browser_control_broker
transport = current_transport()
if not _local(transport):
return _err(rid, _forbidden, "local browser controller requires the desktop transport")
command_id = str(params.get("command_id") or "")
if not command_id:
return _err(rid, _forbidden, "command_id required")
ok = params.get("ok") is True
accepted = browser_control_broker.get_browser_control_broker().complete_for_owner(
command_id,
owner=transport,
ok=ok,
result=params.get("result") if ok else params.get("error"),
)
return _ok(rid, {"accepted": accepted})
@method("browser.controller.local.detach")
def _(
rid,
params: dict,
_family=_LOCAL_TRANSPORT_FAMILY,
_principal=_LOCAL_PRINCIPAL,
_forbidden=_ERR_FORBIDDEN,
_local=_is_local_desktop,
) -> dict:
"""Stop offering the user's browser; pending commands are cancelled."""
from gateway import browser_control_broker
transport = current_transport()
if not _local(transport):
return _err(rid, _forbidden, "local browser controller requires the desktop transport")
detached = browser_control_broker.get_browser_control_broker().detach_lane_template(
principal_id=_principal,
transport_family=_family,
owner=transport,
)
return _ok(rid, {"detached": detached})
def register(server) -> None: def register(server) -> None:
"""Bind this module's handlers onto ``server``'s globals and registry.""" """Bind this module's handlers onto ``server``'s globals and registry."""
_registry.install(server) _registry.install(server)
+11
View File
@@ -4848,6 +4848,17 @@ def _set_session_context(
browser_control_transport_family = ( browser_control_transport_family = (
_methods_browser_control._CLOUD_TRANSPORT_FAMILY _methods_browser_control._CLOUD_TRANSPORT_FAMILY
) )
elif getattr(transport, "local_desktop", False) is True:
# The desktop IDE's own browser (AITURK Chrome) serves
# desktop sessions once its extension registers; until
# then the lane is unregistered and the normal browser
# backend is used unchanged.
browser_control_principal = (
_methods_browser_control._LOCAL_PRINCIPAL
)
browser_control_transport_family = (
_methods_browser_control._LOCAL_TRANSPORT_FAMILY
)
break break
return set_session_vars( return set_session_vars(
session_key=session_key, session_key=session_key,
+7
View File
@@ -129,10 +129,15 @@ class WSTransport:
*, *,
peer: str = "unknown", peer: str = "unknown",
auth_identity: dict | None = None, auth_identity: dict | None = None,
local_desktop: bool = False,
) -> None: ) -> None:
self._ws = ws self._ws = ws
self._loop = loop self._loop = loop
self._peer = peer self._peer = peer
#: True only for the loopback desktop's session-token credential (the
#: desktop IDE and its own bridges). Never set from RPC params; it is
#: the authority for the local desktop browser-controller lane.
self.local_desktop = local_desktop is True
#: Server-verified identity carried from the WS-upgrade credential #: Server-verified identity carried from the WS-upgrade credential
#: (dashboard ticket / internal credential) — stamped by #: (dashboard ticket / internal credential) — stamped by
#: ``hermes_cli.web_server._ws_auth_reason`` onto the WS object and #: ``hermes_cli.web_server._ws_auth_reason`` onto the WS object and
@@ -369,6 +374,7 @@ async def handle_ws(
*, *,
auth_identity: dict | None = None, auth_identity: dict | None = None,
subprotocol: str | None = None, subprotocol: str | None = None,
local_desktop: bool = False,
) -> None: ) -> None:
"""Run one WebSocket session. Wire-compatible with ``tui_gateway.entry``. """Run one WebSocket session. Wire-compatible with ``tui_gateway.entry``.
@@ -406,6 +412,7 @@ async def handle_ws(
asyncio.get_running_loop(), asyncio.get_running_loop(),
peer=peer, peer=peer,
auth_identity=auth_identity, auth_identity=auth_identity,
local_desktop=local_desktop,
) )
# resolve_skin() reads config + initializes the skin engine — # resolve_skin() reads config + initializes the skin engine —
+17 -2
View File
@@ -284,6 +284,7 @@ protocol version, transport names, and the exact capability allowlist:
controller.noop controller.noop
browser_back browser_back
browser_click browser_click
browser_get_images
browser_navigate browser_navigate
browser_press browser_press
browser_screenshot browser_screenshot
@@ -292,11 +293,25 @@ browser_snapshot
browser_tab_activate browser_tab_activate
browser_tabs browser_tabs
browser_type browser_type
browser_vision
``` ```
Requested capabilities outside that list are filtered out. Raw CDP, arbitrary Requested capabilities outside that list are filtered out. Raw CDP, arbitrary
script evaluation, console access, uploads, image extraction, and vision are not script evaluation, console access and uploads are not part of the controller
part of the controller protocol. protocol. For `browser_vision` the controller only returns a capture of the
visible tab; Hermes saves it and runs the usual vision analysis itself.
### Desktop browser lane
The desktop app's own browser extension (AITURK Chrome) registers through the
app's native bridge with `browser.controller.local.register` on the loopback
desktop WebSocket. One registration serves every desktop session of that
backend; each session still gets its own exact controller scope on first use,
and command events carry that session id. The lane has its own switch,
`browser.extension_control.local_desktop` (on by default), independent of
`enabled`. When the extension disconnects or calls
`browser.controller.local.detach`, pending commands are cancelled and desktop
sessions return to their normal browser backend.
When a request has no bound controller identity, or when the feature is disabled, When a request has no bound controller identity, or when the feature is disabled,
Hermes preserves the existing browser backend. Once the gateway binds a Hermes preserves the existing browser backend. Once the gateway binds a