query('limit', 50))); $offset = max(0, (int) $request->query('offset', 0)); $search = sanitize_text_field((string) $request->query('search', '')); $archived = filter_var($request->query('archived', false), FILTER_VALIDATE_BOOLEAN); $recentFirst = filter_var($request->query('recent', false), FILTER_VALIDATE_BOOLEAN); $rows = $this->sessions->list($this->userId(), $search, $limit + 1, $offset, false, $archived, $recentFirst); $visible = array_slice($rows, 0, $limit); $includeLastModel = filter_var($request->query('include_last_model', false), FILTER_VALIDATE_BOOLEAN); $lastModels = $includeLastModel ? $this->sessions->latestModels(array_map(static fn (array $row): int => (int) ($row['id'] ?? 0), $visible)) : []; $items = array_map(static function (array $row) use ($lastModels): array { $row['last_model'] = $lastModels[(int) ($row['id'] ?? 0)] ?? ''; return SessionResource::make($row); }, $visible); return $this->noStore($this->ok([ 'items' => $items, 'meta' => ['total' => $this->sessions->count($this->userId(), $search, false, $archived), 'more' => count($rows) > $limit, 'offset' => $offset], ])); } /** * `POST /sessions` with `{model?, agent?, permission_mode?}`; optionally `{text, message_id}` to start the first run in one request. */ public function store(Request $request, ModelGateway $gateway, AgentRegistry $agents, PolicyEngine $policy, Settings $settings): JsonResponse { $text = $request->has('text') ? Redactor::cleanText((string) $request->input('text', '')) : null; if ($text !== null && ($text === '' || mb_strlen($text) > RunController::MAX_MESSAGE)) { return $this->validationError(['text' => [$text === '' ? __('Type a message first.', 'maze-graph') : sprintf( /* translators: %s: maximum number of characters. */ __('A message can have at most %s characters.', 'maze-graph'), number_format_i18n(RunController::MAX_MESSAGE) )]]); } $permissionMode = PermissionMode::ASK; if ($request->has('permission_mode')) { $permissionMode = sanitize_text_field((string) $request->input('permission_mode')); if (!in_array($permissionMode, PermissionMode::ALL, true)) { return $this->validationError(['permission_mode' => [__('Choose Ask, Auto or Full.', 'maze-graph')]]); } } $model = $this->model($request, $gateway); if ($model instanceof JsonResponse) { return $model; } // Store the site default on the session so a later Settings change cannot change the provider mid-session. if ($model === '') { $model = $settings->string('default_model'); if ($model !== '' && !$gateway->availability()->hasModel($model)) { return $this->validationError(['model' => [__('The site default model is no longer available. Choose an available model in Settings.', 'maze-graph')]]); } } // A new chat may start with any primary agent people are offered (built-in or from a definition). $agent = sanitize_text_field((string) $request->input('agent', AgentRegistry::DEFAULT)); if ($agent !== AgentRegistry::DEFAULT && (!$agents->has($agent) || $agents->get($agent)->mode !== AgentDefinition::PRIMARY || $agents->get($agent)->hidden)) { return $this->validationError(['agent' => [__('That agent is not available.', 'maze-graph')]]); } $decision = $policy->decide(new PolicyRequest(new Principal($this->userId()), 'agent.use', $agent)); if (!$decision->allowed()) { return $this->error($decision->message, 403); } if ($text !== null) { $decision = $policy->decide(new PolicyRequest(new Principal($this->userId()), 'run.start')); if (!$decision->allowed()) { return $this->error($decision->message, match ($decision->code) { 'disabled' => 503, 'consent_required' => 428, 'user_rate_limited' => 429, default => 403, }); } if (!$this->availabilitySummary($gateway)->ready()) { return $this->error(__('No AI provider is ready. Connect one in Settings → Connectors.', 'maze-graph'), 503); } } $session = $this->sessions->create($this->userId(), $model, $agent, '', $permissionMode); $this->events->record(new Event('session.created', ['session' => $session->id, 'user' => $this->userId(), 'agent' => $agent])); if ($text === null) { return $this->created(SessionResource::make($session) + ['entries' => []]); } $clientId = substr((string) preg_replace('/[^A-Za-z0-9-]/', '', (string) $request->input('message_id', '')), 0, 64); $started = $this->runs->start($session->id, $text, $this->userId(), $clientId); if ($started === null) { return $this->error(__('This session is still answering. Wait for it, or stop it first.', 'maze-graph'), 409); } $this->sessions->autoTitle($session, Transcript::title($text)); $fresh = $this->sessions->find($this->userId(), $session->uuid) ?? $session; return $this->noStore($this->created([ 'session' => SessionResource::make($fresh), 'entries' => Transcript::forClient($started['entries']), ])); } /** * `GET /sessions/{id}`: the session and the newest bounded transcript page. */ public function show(Request $request): JsonResponse { $session = $this->sessions->find($this->userId(), (string) $request->route('id')); if ($session === null) { return $this->missing(); } $page = $this->store->latestPage($session->id); return $this->noStore($this->ok(SessionResource::make($session) + [ 'entries' => Transcript::forClient($page['entries']), 'history' => ['has_older' => $page['has_more'], 'before_seq' => $page['before_seq']], ])); } /** * `GET /sessions/{id}/context`: a preview from the same context assembler used for model calls. */ public function context(Request $request, ContextAssembler $assembler, ToolRegistry $tools, AgentRegistry $agents, Settings $settings, ContextFactory $contextFactory, SessionContextPreviewCache $cache): JsonResponse { $session = $this->sessions->find($this->userId(), (string) $request->route('id')); if ($session === null) { return $this->missing(); } $mode = PermissionMode::normalize((string) ($session->permission_mode ?? '')); $run = $this->store->run($session->id); $agent = $agents->get((string) ($run['agent'] ?? $session->agent)); $settingsSnapshot = $settings->all(); $agentTools = $agents->toolPatterns($agent, (bool) ($settingsSnapshot['assistant_all_enabled_tools'] ?? false)); $maxSteps = max(1, (int) ($settingsSnapshot['max_steps'] ?? 8)); if ($agent->maxSteps > 0) { $maxSteps = min($maxSteps, $agent->maxSteps); } $step = in_array((string) ($run['status'] ?? ''), ['running', 'awaiting'], true) ? (int) ($run['step'] ?? 0) : 0; $user = wp_get_current_user(); $caps = $user->allcaps; ksort($caps); $fingerprintData = [ 'version' => MAZE_GRAPH_VERSION, 'session' => [ 'user_id' => $this->userId(), 'updated_at' => $session->updated_at ?? null, 'last_activity_at' => $session->last_activity_at ?? null, 'entry_count' => (int) ($session->entry_count ?? 0), 'model' => (string) $session->model, 'agent' => (string) ($run['agent'] ?? $session->agent), 'permission_mode' => $mode, ], 'run' => $run, 'agent' => [ 'id' => $agent->id, 'instructions' => $agent->instructions, 'tools' => $agentTools, 'max_steps' => $agent->maxSteps, 'mode' => $agent->mode, 'model' => $agent->model, ], 'settings' => $settingsSnapshot, 'context_facts' => $contextFactory->facts(), 'tool_registry' => $tools instanceof AbilityToolRegistry ? $tools->contextFingerprint() : get_class($tools), 'capabilities' => $caps, ]; $fingerprintJson = wp_json_encode($fingerprintData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $fingerprint = hash('sha256', is_string($fingerprintJson) ? $fingerprintJson : serialize($fingerprintData)); $cached = $cache->get($session->id, $fingerprint); if ($cached !== null) { return $this->noStore($this->ok($cached)); } $storedEntries = $this->store->contextEntries($session->id, ($settingsSnapshot['context_strategy'] ?? 'window') === 'compact'); $entries = Transcript::visible($storedEntries); $tainted = $this->store->hasTaintedToolResults($session->id); $toolContext = new ToolContext($this->userId(), $session->id, (string) ($run['id'] ?? ''), $step, $maxSteps, '', $tainted, '', $agentTools, ToolContext::MODEL, true, $mode); $offered = $tools->available($toolContext); $maxOutputTokens = max(1, (int) ($settingsSnapshot['max_output_tokens'] ?? 0) ?: 4096); $requestForModel = $assembler instanceof PermissionAwareContextAssembler ? $assembler->buildWithPermissionMode($entries, $offered, (string) $session->model, $maxOutputTokens, $agent, $mode) : PermissionModePrompt::apply($assembler->build($entries, $offered, (string) $session->model, $maxOutputTokens, $agent), $mode); $labels = []; $available = array_map(static function ($tool) use (&$labels): array { $labels[$tool->name] = $tool->label; return ['name' => $tool->name, 'label' => $tool->label, 'risk' => $tool->risk()]; }, $requestForModel->tools); $usedTools = []; foreach ($entries as $entry) { if (($entry['type'] ?? '') !== 'message' || ($entry['role'] ?? '') !== 'tool') { continue; } foreach ((array) ($entry['parts'] ?? []) as $part) { if (($part['type'] ?? '') !== 'tool_result') { continue; } $name = (string) ($part['name'] ?? ''); if ($name === '') { continue; } $usedTools[$name] ??= ['name' => $name, 'label' => $labels[$name] ?? $name, 'calls' => 0, 'last_status' => '', 'last_at' => null]; $usedTools[$name]['calls']++; $usedTools[$name]['last_status'] = (string) ($part['status'] ?? 'ok'); $usedTools[$name]['last_at'] = is_string($entry['ts'] ?? null) ? $entry['ts'] : null; } } foreach ($usedTools as $name => $row) { $usedTools[$name]['label'] = $labels[$name] ?? $row['label']; } $stats = $requestForModel->stats; $strategy = (string) ($settingsSnapshot['context_strategy'] ?? 'window'); if (!in_array($strategy, ContextBuilder::STRATEGIES, true)) { $strategy = 'window'; } $data = [ 'strategy' => $strategy, 'model' => (string) $session->model, 'mode' => $mode, 'system' => $requestForModel->system, 'history' => [ 'kept' => (int) ($stats['kept'] ?? 0), 'dropped' => (int) ($stats['dropped'] ?? 0), 'estimated_tokens' => (int) ($stats['est_tokens'] ?? 0), ], 'tools' => $available, 'used_tools' => array_values($usedTools), ]; // append() increments entry_count before inserting its line. Skip caching if this request observed that // brief in-between state, so the next open cannot reuse a partial transcript preview. if ($this->store->transcriptMatchesCount($session->id, (int) ($session->entry_count ?? 0))) { $cache->put($session->id, $fingerprint, $data); } return $this->noStore($this->ok($data)); } /** * `GET /sessions/{id}/entries?before=` loads one bounded older page; `?after=` catches up with new lines. */ public function entries(Request $request): JsonResponse { $session = $this->sessions->find($this->userId(), (string) $request->route('id')); if ($session === null) { return $this->missing(); } if ($request->has('before')) { $page = $this->store->pageBefore($session->id, max(1, (int) $request->input('before', 1))); return $this->noStore($this->ok([ 'session' => SessionResource::make($session), 'entries' => Transcript::forClient($page['entries']), 'history' => ['has_older' => $page['has_more'], 'before_seq' => $page['before_seq']], ])); } $after = max(0, (int) $request->input('after', 0)); return $this->noStore($this->ok(['session' => SessionResource::make($session), 'entries' => Transcript::forClient($this->store->entriesAfter($session->id, $after))])); } /** * `PUT /sessions/{id}` with `{title?, model?, agent?, permission_mode?, pinned?, archived?}` */ public function update(Request $request, ModelGateway $gateway, AgentRegistry $agents, PolicyEngine $policy): JsonResponse { $session = $this->sessions->find($this->userId(), (string) $request->route('id')); if ($session === null) { return $this->missing(); } $agentId = null; if ($request->has('agent')) { $agentId = sanitize_text_field((string) $request->input('agent')); $agent = $agents->get($agentId); if ($agentId === '' || !$agents->has($agentId) || $agent->mode !== AgentDefinition::PRIMARY || $agent->hidden) { return $this->validationError(['agent' => [__('That assistant is not available.', 'maze-graph')]]); } if (($session->parent_id ?? null) !== null || str_starts_with((string) $session->agent, 'studio-')) { return $this->error(__('This session uses a fixed assistant and cannot be reassigned.', 'maze-graph'), 409); } if ($agentId !== (string) $session->agent && in_array((string) $session->run_status, ['running', 'awaiting'], true)) { return $this->error(__('Wait for this reply or approval to finish before changing the assistant.', 'maze-graph'), 409); } $decision = $policy->decide(new PolicyRequest(new Principal($this->userId()), 'agent.use', $agentId)); if (!$decision->allowed()) { return $this->error($decision->message, 403); } } $permissionMode = null; $pinned = null; $archived = null; if ($request->has('permission_mode')) { $permissionMode = sanitize_text_field((string) $request->input('permission_mode')); if (!in_array($permissionMode, PermissionMode::ALL, true)) { return $this->validationError(['permission_mode' => [__('Choose Ask, Auto or Full.', 'maze-graph')]]); } if (in_array((string) $session->run_status, ['running', 'awaiting'], true)) { return $this->error(__('Wait for this run to finish before changing its permission mode.', 'maze-graph'), 409); } } foreach (['pinned', 'archived'] as $flag) { if (!$request->has($flag)) { continue; } $value = filter_var($request->input($flag), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); if (!is_bool($value)) { return $this->validationError([$flag => [__('Choose true or false.', 'maze-graph')]]); } if ($flag === 'pinned') { $pinned = $value; } else { $archived = $value; } } $changedFields = []; if ($request->has('title')) { $title = sanitize_text_field((string) $request->input('title')); if ($title === '') { return $this->validationError(['title' => [__('Give the session a name.', 'maze-graph')]]); } $this->sessions->rename($session, $title); $changedFields[] = 'title'; } if ($request->has('model')) { $model = $this->model($request, $gateway); if ($model instanceof JsonResponse) { return $model; } $previousModel = (string) $session->model; $this->sessions->setModel($session, $model); if ($model !== $previousModel) { $changedFields[] = 'model'; } } if (is_string($agentId) && $agentId !== (string) $session->agent) { if (!$this->sessions->setAgent($session, $agentId)) { return $this->error(__('Wait for this reply or approval to finish before changing the assistant.', 'maze-graph'), 409); } $changedFields[] = 'agent'; $session = $this->sessions->find($this->userId(), (string) $session->uuid) ?? $session; } if (is_string($permissionMode) && $permissionMode !== PermissionMode::normalize((string) $session->permission_mode)) { $previousMode = PermissionMode::normalize((string) $session->permission_mode); $this->sessions->setPermissionMode($session, $permissionMode); $this->store->append($session->id, Transcript::event('permission_mode_changed', [ 'from' => $previousMode, 'to' => $permissionMode, ])); $changedFields[] = 'permission_mode'; $session = $this->sessions->find($this->userId(), (string) $session->uuid) ?? $session; } if (is_bool($pinned) && $pinned !== (($session->pinned_at ?? null) !== null)) { $this->sessions->setPinned($session, $pinned); $changedFields[] = 'pinned'; } if (is_bool($archived) && $archived !== (($session->archived_at ?? null) !== null)) { $this->sessions->setArchived($session, $archived); $changedFields[] = 'archived'; } if ($changedFields !== []) { $this->events->record(new Event('session.updated', ['session' => $session->id, 'user' => $this->userId(), 'fields' => $changedFields])); } return $this->ok(SessionResource::make($session)); } /** * `DELETE /sessions/{id}`: hidden at once, restorable for ten minutes. */ public function destroy(Request $request): JsonResponse { $session = $this->sessions->find($this->userId(), (string) $request->route('id')); $count = $this->sessions->trash($this->userId(), [(string) $request->route('id')]); if ($count > 0 && $session !== null) { $this->events->record(new Event('session.trashed', ['session' => $session->id, 'user' => $this->userId()])); } return $count === 0 ? $this->missing() : $this->ok(['deleted' => $count, 'undo_seconds' => SessionRepository::UNDO_SECONDS]); } /** * `POST /sessions/delete` with `{ids: [..]}` */ public function bulkDelete(Request $request): JsonResponse { $ids = $this->ids($request); if ($ids === []) { return $this->validationError(['ids' => [__('Choose at least one session.', 'maze-graph')]]); } $sessionIds = $this->sessionIds($ids); $deleted = $this->sessions->trash($this->userId(), $ids); foreach (array_slice($sessionIds, 0, $deleted) as $sessionId) { $this->events->record(new Event('session.trashed', ['session' => $sessionId, 'user' => $this->userId()])); } return $this->ok(['deleted' => $deleted, 'undo_seconds' => SessionRepository::UNDO_SECONDS]); } /** * `POST /sessions/archive` with `{ids: [...], archived: bool}`. */ public function bulkArchive(Request $request): JsonResponse { $ids = $this->ids($request); if ($ids === []) { return $this->validationError(['ids' => [__('Choose at least one session.', 'maze-graph')]]); } $archived = filter_var($request->input('archived'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); if (!is_bool($archived)) { return $this->validationError(['archived' => [__('Choose true or false.', 'maze-graph')]]); } $sessionIds = $this->sessionIds($ids); $updated = $this->sessions->archive($this->userId(), $ids, $archived); foreach (array_slice($sessionIds, 0, $updated) as $sessionId) { $this->events->record(new Event('session.archived', ['session' => $sessionId, 'user' => $this->userId(), 'status' => $archived ? 'archived' : 'unarchived'])); } return $this->ok(['updated' => $updated, 'archived' => $archived]); } /** * `POST /sessions/restore` with `{ids: [..]}`: undo a delete. */ public function restore(Request $request): JsonResponse { $uuids = $this->ids($request); $sessionIds = $this->sessions->restorableIds($this->userId(), $uuids); $restored = $this->sessions->restore($this->userId(), $uuids); foreach (array_slice($sessionIds, 0, $restored) as $sessionId) { $this->events->record(new Event('session.restored', ['session' => $sessionId, 'user' => $this->userId()])); } return $this->ok(['restored' => $restored]); } /** * @param list $uuids * @return list */ protected function sessionIds(array $uuids): array { $ids = []; foreach ($uuids as $uuid) { $session = $this->sessions->find($this->userId(), $uuid); if ($session !== null) { $ids[] = $session->id; } } return $ids; } /** * `GET /sessions/{id}/export`: the transcript as JSONL, with a file name for the download. */ public function export(Request $request): JsonResponse { $session = $this->sessions->find($this->userId(), (string) $request->route('id')); if ($session === null) { return $this->missing(); } $title = Redactor::mask($session->title); $name = sanitize_file_name(($title !== '' ? $title : 'session') . '-' . substr($session->uuid, 0, 8)) . '.jsonl'; return $this->noStore($this->ok([ 'filename' => $name, 'jsonl' => Transcript::jsonl([ Transcript::event('session', ['id' => $session->uuid, 'title' => Redactor::mask($session->title), 'model' => $session->model, 'created_at' => SessionResource::time($session->created_at)]), ...Transcript::forClient($this->store->entries($session->id)), ]), ])); } /** * The requested model: '' (site default) or one of the usable models. */ protected function model(Request $request, ModelGateway $gateway): string|JsonResponse { $model = sanitize_text_field((string) $request->input('model', '')); if ($model !== '' && !$gateway->availability()->hasModel($model)) { return $this->validationError(['model' => [__('That model is not available on this site.', 'maze-graph')]]); } return $model; } /** * Avoid model enumeration for a provider readiness check; retain compatibility with older adapters. */ protected function availabilitySummary(ModelGateway $gateway): Availability { return $gateway instanceof ModelAvailabilitySummary ? $gateway->summary() : $gateway->availability(); } /** * @return list */ protected function ids(Request $request): array { $ids = $request->input('ids', []); return is_array($ids) ? array_values(array_filter(array_map('strval', $ids), 'wp_is_uuid')) : []; } protected function missing(): JsonResponse { return $this->notFound(__('That session does not exist or was deleted.', 'maze-graph')); } protected function noStore(JsonResponse $response): JsonResponse { return $response->header('Cache-Control', 'no-store, private'); } }