quoxcli 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +35 -0
- package/README.md +142 -2
- package/bin/quox.js +292 -0
- package/bin/quoxon.js +8 -0
- package/lib/api/endpoints/admin.js +125 -0
- package/lib/api/endpoints/aee.js +38 -0
- package/lib/api/endpoints/agent-attach.js +126 -0
- package/lib/api/endpoints/agents.js +56 -0
- package/lib/api/endpoints/assistants.js +50 -0
- package/lib/api/endpoints/audit.js +14 -0
- package/lib/api/endpoints/auth.js +50 -0
- package/lib/api/endpoints/awareness.js +34 -0
- package/lib/api/endpoints/backup-schedules.js +20 -0
- package/lib/api/endpoints/backups.js +86 -0
- package/lib/api/endpoints/brain.js +140 -0
- package/lib/api/endpoints/budgets.js +62 -0
- package/lib/api/endpoints/calendarq.js +71 -0
- package/lib/api/endpoints/catalog.js +14 -0
- package/lib/api/endpoints/chat.js +54 -0
- package/lib/api/endpoints/consent.js +26 -0
- package/lib/api/endpoints/contactsq.js +66 -0
- package/lib/api/endpoints/conversations.js +32 -0
- package/lib/api/endpoints/custom-roles.js +92 -0
- package/lib/api/endpoints/divisions.js +56 -0
- package/lib/api/endpoints/domains.js +38 -0
- package/lib/api/endpoints/engagement.js +231 -0
- package/lib/api/endpoints/engine.js +11 -0
- package/lib/api/endpoints/entity.js +50 -0
- package/lib/api/endpoints/executions.js +32 -0
- package/lib/api/endpoints/file.js +20 -0
- package/lib/api/endpoints/files.js +26 -0
- package/lib/api/endpoints/github.js +21 -0
- package/lib/api/endpoints/governance.js +43 -0
- package/lib/api/endpoints/hat.js +36 -0
- package/lib/api/endpoints/inbox.js +32 -0
- package/lib/api/endpoints/inboxq.js +120 -0
- package/lib/api/endpoints/index.js +228 -0
- package/lib/api/endpoints/invites.js +19 -0
- package/lib/api/endpoints/llm-policy.js +43 -0
- package/lib/api/endpoints/lucid.js +89 -0
- package/lib/api/endpoints/members.js +32 -0
- package/lib/api/endpoints/memory.js +75 -0
- package/lib/api/endpoints/mfa.js +32 -0
- package/lib/api/endpoints/mon.js +178 -0
- package/lib/api/endpoints/notesq.js +43 -0
- package/lib/api/endpoints/objectives.js +62 -0
- package/lib/api/endpoints/orgs.js +38 -0
- package/lib/api/endpoints/plugins.js +134 -0
- package/lib/api/endpoints/powers.js +75 -0
- package/lib/api/endpoints/prompts.js +69 -0
- package/lib/api/endpoints/qlarity.js +122 -0
- package/lib/api/endpoints/quoxbox.js +55 -0
- package/lib/api/endpoints/quoxchat.js +54 -0
- package/lib/api/endpoints/quoxflow.js +32 -0
- package/lib/api/endpoints/quoxplan.js +114 -0
- package/lib/api/endpoints/quoxseo.js +108 -0
- package/lib/api/endpoints/reach.js +47 -0
- package/lib/api/endpoints/runs.js +38 -0
- package/lib/api/endpoints/safety.js +46 -0
- package/lib/api/endpoints/scim.js +46 -0
- package/lib/api/endpoints/scopes.js +14 -0
- package/lib/api/endpoints/security.js +69 -0
- package/lib/api/endpoints/setup.js +90 -0
- package/lib/api/endpoints/skills.js +180 -0
- package/lib/api/endpoints/sso.js +67 -0
- package/lib/api/endpoints/tags.js +32 -0
- package/lib/api/endpoints/teams.js +56 -0
- package/lib/api/endpoints/telephony.js +46 -0
- package/lib/api/endpoints/tg.js +56 -0
- package/lib/api/endpoints/tools.js +50 -0
- package/lib/api/endpoints/traces.js +157 -0
- package/lib/api/endpoints/triggers.js +70 -0
- package/lib/api/endpoints/user-library.js +41 -0
- package/lib/api/endpoints/users.js +20 -0
- package/lib/api/endpoints/v1.js +499 -0
- package/lib/api/endpoints/vault.js +192 -0
- package/lib/api/endpoints/voice.js +22 -0
- package/lib/api/endpoints/volt.js +84 -0
- package/lib/api/endpoints/ward.js +40 -0
- package/lib/api/endpoints/webhooks.js +46 -0
- package/lib/api/endpoints/workflows.js +162 -0
- package/lib/api/endpoints/x.js +24 -0
- package/lib/api/endpoints.js +15 -0
- package/lib/commands/admin.js +359 -0
- package/lib/commands/aee.js +99 -0
- package/lib/commands/agent.js +201 -0
- package/lib/commands/agentAttach.js +1440 -0
- package/lib/commands/approvals.js +47 -0
- package/lib/commands/assistant.js +114 -0
- package/lib/commands/auth.js +458 -0
- package/lib/commands/awareness.js +88 -0
- package/lib/commands/backup.js +373 -0
- package/lib/commands/brain.js +994 -0
- package/lib/commands/budget.js +241 -0
- package/lib/commands/build.js +156 -0
- package/lib/commands/calendar.js +216 -0
- package/lib/commands/capabilities.js +34 -0
- package/lib/commands/chat.js +240 -0
- package/lib/commands/claude.js +354 -0
- package/lib/commands/config-cmd.js +106 -0
- package/lib/commands/consent.js +84 -0
- package/lib/commands/contacts.js +967 -0
- package/lib/commands/contactsSelftest.js +132 -0
- package/lib/commands/conversation.js +74 -0
- package/lib/commands/department.js +75 -0
- package/lib/commands/dev.js +620 -0
- package/lib/commands/devResident.js +314 -0
- package/lib/commands/devSupervisor.js +1050 -0
- package/lib/commands/discord.js +96 -0
- package/lib/commands/doctor.js +181 -0
- package/lib/commands/domain.js +114 -0
- package/lib/commands/done.js +277 -0
- package/lib/commands/engagement.js +586 -0
- package/lib/commands/entity.js +119 -0
- package/lib/commands/facebook.js +212 -0
- package/lib/commands/file.js +128 -0
- package/lib/commands/fleet.js +882 -0
- package/lib/commands/governance.js +638 -0
- package/lib/commands/gripe.js +324 -0
- package/lib/commands/hat.js +205 -0
- package/lib/commands/helpers.js +79 -0
- package/lib/commands/inbox.js +122 -0
- package/lib/commands/instagram.js +264 -0
- package/lib/commands/integration.js +100 -0
- package/lib/commands/inventory.js +115 -0
- package/lib/commands/invites.js +45 -0
- package/lib/commands/licence.js +280 -0
- package/lib/commands/linkedin.js +178 -0
- package/lib/commands/llm-policy.js +117 -0
- package/lib/commands/llm.js +45 -0
- package/lib/commands/lucid.js +591 -0
- package/lib/commands/mail.js +531 -0
- package/lib/commands/memory.js +284 -0
- package/lib/commands/mon.js +514 -0
- package/lib/commands/monitoring.js +86 -0
- package/lib/commands/motion.js +22 -0
- package/lib/commands/notes.js +1246 -0
- package/lib/commands/notesBatch.js +160 -0
- package/lib/commands/notesBridges.js +56 -0
- package/lib/commands/notesLinks.js +60 -0
- package/lib/commands/notification.js +57 -0
- package/lib/commands/nucleus.js +92 -0
- package/lib/commands/objective.js +229 -0
- package/lib/commands/onboard.js +328 -0
- package/lib/commands/open.js +56 -0
- package/lib/commands/org.js +266 -0
- package/lib/commands/plan.js +906 -0
- package/lib/commands/plugin-doctor.js +1091 -0
- package/lib/commands/plugin.js +539 -0
- package/lib/commands/policy.js +78 -0
- package/lib/commands/powers.js +296 -0
- package/lib/commands/prompt.js +350 -0
- package/lib/commands/qlarity.js +690 -0
- package/lib/commands/quoxbox.js +270 -0
- package/lib/commands/quoxchat.js +266 -0
- package/lib/commands/quoxseo.js +340 -0
- package/lib/commands/reach.js +158 -0
- package/lib/commands/role.js +286 -0
- package/lib/commands/run.js +132 -0
- package/lib/commands/safety.js +107 -0
- package/lib/commands/scim.js +88 -0
- package/lib/commands/scope.js +58 -0
- package/lib/commands/security.js +157 -0
- package/lib/commands/service.js +89 -0
- package/lib/commands/settings.js +99 -0
- package/lib/commands/setup.js +543 -0
- package/lib/commands/skillManifest.js +113 -0
- package/lib/commands/skillRightsFormat.js +38 -0
- package/lib/commands/skills.js +644 -0
- package/lib/commands/slot.js +1198 -0
- package/lib/commands/sso.js +229 -0
- package/lib/commands/status.js +137 -0
- package/lib/commands/tag.js +91 -0
- package/lib/commands/team.js +140 -0
- package/lib/commands/tg.js +240 -0
- package/lib/commands/tiktok.js +218 -0
- package/lib/commands/tool.js +216 -0
- package/lib/commands/trace.js +525 -0
- package/lib/commands/trigger.js +128 -0
- package/lib/commands/vault.js +1424 -0
- package/lib/commands/voice.js +182 -0
- package/lib/commands/volt.js +528 -0
- package/lib/commands/watch.js +115 -0
- package/lib/commands/webhook.js +137 -0
- package/lib/commands/workflow.js +503 -0
- package/lib/commands/x.js +439 -0
- package/lib/core/actingAs.js +136 -0
- package/lib/core/aee.js +53 -0
- package/lib/core/agentAlias.js +163 -0
- package/lib/core/approvalContext.js +61 -0
- package/lib/core/auth.js +350 -0
- package/lib/core/banner.js +143 -0
- package/lib/core/brainPrime.js +90 -0
- package/lib/core/callerSource.js +129 -0
- package/lib/core/claudeLs.js +94 -0
- package/lib/core/client.js +584 -0
- package/lib/core/completions.js +163 -0
- package/lib/core/config.js +551 -0
- package/lib/core/confirm.js +37 -0
- package/lib/core/deeplinks.js +76 -0
- package/lib/core/doneEngineFetch.js +219 -0
- package/lib/core/errors.js +132 -0
- package/lib/core/global-flags.js +90 -0
- package/lib/core/governedSession.js +402 -0
- package/lib/core/lazyCommands.js +119 -0
- package/lib/core/liveSessionsReport.js +112 -0
- package/lib/core/org-override.js +52 -0
- package/lib/core/output.js +239 -0
- package/lib/core/prodEnv.js +7 -0
- package/lib/core/quoxglyph.js +158 -0
- package/lib/core/residentAlarm.js +282 -0
- package/lib/core/residentAuth.js +243 -0
- package/lib/core/residentPump.js +229 -0
- package/lib/core/residentSeed.js +104 -0
- package/lib/core/sessionHeartbeat.js +192 -0
- package/lib/core/sessionsOverview.js +160 -0
- package/lib/core/slotKeeper.js +282 -0
- package/lib/core/supervisorEngine.js +864 -0
- package/lib/core/systemdInstaller.js +256 -0
- package/lib/core/version.js +87 -0
- package/lib/core/websiteSession.js +148 -0
- package/lib/lucid/LucidIntro.jsx +105 -0
- package/lib/lucid/blind.js +71 -0
- package/lib/lucid/deck.js +32 -0
- package/lib/lucid/fmt.js +139 -0
- package/lib/lucid/sitrep.js +173 -0
- package/lib/plugins/loader.js +93 -0
- package/lib/quoxon/activity.js +104 -0
- package/lib/quoxon/api.js +473 -0
- package/lib/quoxon/app.js +1262 -0
- package/lib/quoxon/capabilities.js +75 -0
- package/lib/quoxon/commands.js +154 -0
- package/lib/quoxon/connect.js +467 -0
- package/lib/quoxon/connectCard.js +28 -0
- package/lib/quoxon/context.js +68 -0
- package/lib/quoxon/copy.js +33 -0
- package/lib/quoxon/demo.fixtures.js +24 -0
- package/lib/quoxon/demo.js +35 -0
- package/lib/quoxon/egg.js +36 -0
- package/lib/quoxon/events.js +458 -0
- package/lib/quoxon/gallery.js +223 -0
- package/lib/quoxon/indicator.js +193 -0
- package/lib/quoxon/intent.js +41 -0
- package/lib/quoxon/keys.js +121 -0
- package/lib/quoxon/main.js +244 -0
- package/lib/quoxon/markdown.js +317 -0
- package/lib/quoxon/model.js +205 -0
- package/lib/quoxon/motion/clock.js +69 -0
- package/lib/quoxon/motion/color.js +35 -0
- package/lib/quoxon/motion/frame.js +14 -0
- package/lib/quoxon/motion/reveal.js +54 -0
- package/lib/quoxon/motion/ring.js +79 -0
- package/lib/quoxon/objects/agent.js +61 -0
- package/lib/quoxon/objects/crystal.js +100 -0
- package/lib/quoxon/objects/fetch.js +170 -0
- package/lib/quoxon/objects/index.js +85 -0
- package/lib/quoxon/objects/ladder.js +71 -0
- package/lib/quoxon/objects/memory.js +44 -0
- package/lib/quoxon/objects/model.js +118 -0
- package/lib/quoxon/objects/proof.js +110 -0
- package/lib/quoxon/objects/skill.js +105 -0
- package/lib/quoxon/objects/view.js +76 -0
- package/lib/quoxon/objects/workflow.js +119 -0
- package/lib/quoxon/observers.js +42 -0
- package/lib/quoxon/onboarding/copy.js +235 -0
- package/lib/quoxon/onboarding/credential.js +95 -0
- package/lib/quoxon/onboarding/detect.js +22 -0
- package/lib/quoxon/onboarding/finish.js +119 -0
- package/lib/quoxon/onboarding/index.js +67 -0
- package/lib/quoxon/onboarding/installRunner.js +225 -0
- package/lib/quoxon/onboarding/intent.js +53 -0
- package/lib/quoxon/onboarding/journey.js +73 -0
- package/lib/quoxon/onboarding/preflight.js +142 -0
- package/lib/quoxon/onboarding/runner.js +58 -0
- package/lib/quoxon/onboarding/view.js +625 -0
- package/lib/quoxon/print.js +94 -0
- package/lib/quoxon/qart.js +379 -0
- package/lib/quoxon/qglyph.js +143 -0
- package/lib/quoxon/qhello.js +58 -0
- package/lib/quoxon/qmotion.js +111 -0
- package/lib/quoxon/render.js +648 -0
- package/lib/quoxon/roster.js +221 -0
- package/lib/quoxon/route.js +38 -0
- package/lib/quoxon/rules.js +130 -0
- package/lib/quoxon/session.js +68 -0
- package/lib/quoxon/skills.js +30 -0
- package/lib/quoxon/slash/compact.js +90 -0
- package/lib/quoxon/slash/ledger.js +83 -0
- package/lib/quoxon/slash/listView.js +28 -0
- package/lib/quoxon/slash/palette.js +104 -0
- package/lib/quoxon/slash/paletteController.js +59 -0
- package/lib/quoxon/slash/panels.js +101 -0
- package/lib/quoxon/slash/picker.js +38 -0
- package/lib/quoxon/slash/restart.js +144 -0
- package/lib/quoxon/slash/resume.js +59 -0
- package/lib/quoxon/slash/settings.js +50 -0
- package/lib/quoxon/sound/index.js +186 -0
- package/lib/quoxon/sound/mapping.js +119 -0
- package/lib/quoxon/sound/output.js +52 -0
- package/lib/quoxon/sound/synth.js +122 -0
- package/lib/quoxon/term.js +106 -0
- package/lib/quoxon/textBuffer.js +242 -0
- package/lib/quoxon/theme.js +253 -0
- package/lib/quoxon/transcript.js +150 -0
- package/lib/quoxon/web/qface.js +81 -0
- package/lib/quoxon/welcome.js +52 -0
- package/lib/tui/app.jsx +289 -0
- package/lib/tui/components/Breadcrumb.jsx +42 -0
- package/lib/tui/components/ChatInput.jsx +68 -0
- package/lib/tui/components/Confirm.jsx +32 -0
- package/lib/tui/components/DataTable.jsx +270 -0
- package/lib/tui/components/DetailPanel.jsx +241 -0
- package/lib/tui/components/HelpOverlay.jsx +67 -0
- package/lib/tui/components/Layout.jsx +129 -0
- package/lib/tui/components/Palette.jsx +141 -0
- package/lib/tui/components/SearchInput.jsx +125 -0
- package/lib/tui/components/Sparkline.jsx +55 -0
- package/lib/tui/components/StatusBar.jsx +133 -0
- package/lib/tui/components/Toast.jsx +52 -0
- package/lib/tui/hooks/useApi.js +44 -0
- package/lib/tui/hooks/useHealth.js +90 -0
- package/lib/tui/hooks/usePolledApi.js +86 -0
- package/lib/tui/hooks/useTerminalSize.js +46 -0
- package/lib/tui/index.js +56 -0
- package/lib/tui/intro/Intro.jsx +332 -0
- package/lib/tui/jsx-loader.js +32 -0
- package/lib/tui/keys.js +87 -0
- package/lib/tui/registry.js +49 -0
- package/lib/tui/theme.js +103 -0
- package/lib/tui/views/AdminView.jsx +398 -0
- package/lib/tui/views/AgentView.jsx +204 -0
- package/lib/tui/views/BackupView.jsx +225 -0
- package/lib/tui/views/BrainView.jsx +223 -0
- package/lib/tui/views/ChatView.jsx +468 -0
- package/lib/tui/views/FilesView.jsx +218 -0
- package/lib/tui/views/FleetView.jsx +234 -0
- package/lib/tui/views/GovernanceView.jsx +453 -0
- package/lib/tui/views/HealthView.jsx +216 -0
- package/lib/tui/views/InboxView.jsx +228 -0
- package/lib/tui/views/LucidDeck.jsx +727 -0
- package/lib/tui/views/MainMenu.jsx +123 -0
- package/lib/tui/views/MemoryView.jsx +377 -0
- package/lib/tui/views/ObjectivesView.jsx +230 -0
- package/lib/tui/views/OrgView.jsx +229 -0
- package/lib/tui/views/ToolView.jsx +221 -0
- package/lib/tui/views/VaultView.jsx +422 -0
- package/lib/tui/views/WorkflowView.jsx +329 -0
- package/package.json +72 -4
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { DEMO_STEPS } from './demo.fixtures.js';
|
|
2
|
+
|
|
3
|
+
const sleep = (ms, signal) => new Promise((res) => {
|
|
4
|
+
const done = () => { clearTimeout(t); signal?.removeEventListener('abort', done); res(); };
|
|
5
|
+
const t = setTimeout(done, ms);
|
|
6
|
+
signal?.addEventListener('abort', done, { once: true });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
/** A fake api with the same surface as createApi(), driven by DEMO_STEPS. */
|
|
10
|
+
export function createDemoApi({ speed = 1 } = {}) {
|
|
11
|
+
let gate = null;
|
|
12
|
+
let decision = 'approve';
|
|
13
|
+
return {
|
|
14
|
+
orgId: () => 'demo-org',
|
|
15
|
+
async *stream({ turnId }, signal) {
|
|
16
|
+
decision = 'approve';
|
|
17
|
+
for (const step of DEMO_STEPS(turnId)) {
|
|
18
|
+
if (signal?.aborted) return;
|
|
19
|
+
if (step.when && step.when !== decision) continue;
|
|
20
|
+
if (step.gate) { await new Promise((res) => { gate = res; signal?.addEventListener('abort', res, { once: true }); }); continue; }
|
|
21
|
+
await sleep(step.wait / speed, signal);
|
|
22
|
+
if (signal?.aborted) return;
|
|
23
|
+
yield step.ev;
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
async steer() { return { ok: false, reason: 'unsupported' }; },
|
|
27
|
+
async interrupt() { return { ok: false }; },
|
|
28
|
+
async health() { return { ok: true, url: 'demo' }; },
|
|
29
|
+
async approvals() { return []; },
|
|
30
|
+
async skills() { return [{ id: 'sk1', name: 'daily-fleet-check', description: 'Check fleet health and summarise.', edition: 'stable' }]; },
|
|
31
|
+
async skill(id) { return { id, name: 'daily-fleet-check', description: 'Check fleet health and summarise.', edition: 'stable' }; },
|
|
32
|
+
async conversationExists() { return { exists: true }; },
|
|
33
|
+
async respond(_id, action) { decision = action === 'reject' ? 'reject' : 'approve'; const g = gate; gate = null; g?.(); return { ok: true }; }
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Dragon's Eye launch Easter egg (CLI-QUOX). On launch the settled Q opens its eye and blinks, rarely:
|
|
3
|
+
* 1 in EGG_ODDS launches, never twice in a row, never in --print, under reduced motion or off a TTY.
|
|
4
|
+
* /eye plays it on demand, and the cinematic motion level always may.
|
|
5
|
+
*
|
|
6
|
+
* pickLaunchEgg is pure; launchEgg adds the one bit of persistence (did the last launch show the eye?) in
|
|
7
|
+
* ~/.config/quox/motion.json. QUOX_EGG=eye forces it (demos, captures); QUOX_EGG=off disables it.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import { join, dirname } from 'node:path';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
|
|
13
|
+
export const EGG_ODDS = 20;
|
|
14
|
+
|
|
15
|
+
/** Pure decision. */
|
|
16
|
+
export function pickLaunchEgg({ rand = Math.random, lastWasEye = false, print = false, reduced = false, tty = true, force = null } = {}) {
|
|
17
|
+
if (print || reduced || !tty) return false;
|
|
18
|
+
if (force === 'off') return false;
|
|
19
|
+
if (force === 'eye') return true;
|
|
20
|
+
if (lastWasEye) return false;
|
|
21
|
+
return rand() < 1 / EGG_ODDS;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const eggStatePath = (env = process.env) => join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'quox', 'motion.json');
|
|
25
|
+
|
|
26
|
+
/** Decide once for this launch and remember the outcome. Never throws: an unreadable file means "no eye last time". */
|
|
27
|
+
export function launchEgg({ env = process.env, tty = Boolean(process.stdout?.isTTY), print = false, rand = Math.random, path = eggStatePath(env) } = {}) {
|
|
28
|
+
let last = false;
|
|
29
|
+
try { last = Boolean(JSON.parse(readFileSync(path, 'utf8')).lastEye); } catch { /* first launch */ }
|
|
30
|
+
const reduced = env.QUOX_REDUCED_MOTION === '1' || Boolean(env.QUOX_TUI_LITE);
|
|
31
|
+
const eye = pickLaunchEgg({ rand, lastWasEye: last, print, reduced, tty, force: env.QUOX_EGG || null });
|
|
32
|
+
if (!print && tty) {
|
|
33
|
+
try { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, JSON.stringify({ lastEye: eye }) + '\n'); } catch { /* read-only home: fine */ }
|
|
34
|
+
}
|
|
35
|
+
return eye;
|
|
36
|
+
}
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QUOXON conversation state reducer. Consumes BOTH the legacy /chat/stream
|
|
3
|
+
* events (connected, thinking, chunk, done, error) and the v1 contract
|
|
4
|
+
* (docs/QUOXON_EVENTS_v1.md): turn.*, text.delta, tool.*, approval.*, trace,
|
|
5
|
+
* steer.accepted. Pure: no I/O, no clocks (time comes in on actions).
|
|
6
|
+
*
|
|
7
|
+
* Shape
|
|
8
|
+
* history : settled items, rendered once via <Static>
|
|
9
|
+
* blocks : the live turn's ordered blocks; a settled PREFIX is flushed to
|
|
10
|
+
* history (text followed by something, tools that finished)
|
|
11
|
+
* turn : null | { id, status: running|interrupting, startedAt, stage, activity }
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { splitSettled } from './markdown.js';
|
|
15
|
+
import { nextActivity, actionActivity } from './activity.js';
|
|
16
|
+
import { normUsage, turnTokens, fmtTokens, fmtCost } from './indicator.js';
|
|
17
|
+
|
|
18
|
+
export const TOOL_STATUSES = ['ok', 'failed', 'denied', 'effect_unknown'];
|
|
19
|
+
export const APPROVAL_ARM_MS = 300;
|
|
20
|
+
export const KEY_REPEAT_MS = 400;
|
|
21
|
+
export const HELD_KEY_MS = 1500;
|
|
22
|
+
|
|
23
|
+
export const initialState = (over = {}) => ({
|
|
24
|
+
history: [],
|
|
25
|
+
blocks: [],
|
|
26
|
+
turn: null,
|
|
27
|
+
ctx: { agent: null, model: null, provider: null, endpoint: null, hat: null, conversationId: null },
|
|
28
|
+
pending: [], // client-side queue (Tab)
|
|
29
|
+
steering: [], // steer messages not yet accepted
|
|
30
|
+
approvals: [], // approval cards waiting for a human, first is shown; survive the turn that raised them
|
|
31
|
+
running: [], // approved from this terminal, result arrives asynchronously (polled)
|
|
32
|
+
inbox: [], // last /approvals listing
|
|
33
|
+
verbose: 'on',
|
|
34
|
+
doneCalls: {},
|
|
35
|
+
background: 0,
|
|
36
|
+
notify: { approvals: null, error: null },
|
|
37
|
+
seenInbox: {}, // inbox item ids already shown as a card (event or poll), so a poll never re-raises one
|
|
38
|
+
autoDrain: false,
|
|
39
|
+
seq: 0,
|
|
40
|
+
...over
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The server mirrors every legacy `chunk` as a `text.delta` (chunk first). Per turn, text comes from
|
|
45
|
+
* text.delta once the stream is known to be v1 (turn.started or any text.delta); legacy-only
|
|
46
|
+
* streams keep using `chunk`. Returns { append, st } where st is { v1, lastChunk }.
|
|
47
|
+
*/
|
|
48
|
+
export function textGate(st, ev) {
|
|
49
|
+
const g = st || { v1: false, lastChunk: null };
|
|
50
|
+
if (ev.type === 'turn.started') return { append: false, st: { ...g, v1: true } };
|
|
51
|
+
if (ev.type === 'chunk') {
|
|
52
|
+
if (g.v1) return { append: false, st: g };
|
|
53
|
+
return { append: Boolean(ev.content), st: { ...g, lastChunk: ev.content ?? null } };
|
|
54
|
+
}
|
|
55
|
+
if (ev.type === 'text.delta') {
|
|
56
|
+
const mirrored = !g.v1 && g.lastChunk != null && g.lastChunk === ev.content;
|
|
57
|
+
return { append: Boolean(ev.content) && !mirrored, st: { v1: true, lastChunk: null } };
|
|
58
|
+
}
|
|
59
|
+
return { append: false, st: g };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const tms = (v) => { const t = typeof v === 'number' ? v : Date.parse(v); return Number.isFinite(t) ? t : null; };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A pending inbox approval found by polling -> approval card, or null when it does not qualify.
|
|
66
|
+
* Qualifies when created after `since` (the turn start, else app start), or raised on behalf of this
|
|
67
|
+
* user, or in this conversation. The inbox list is already scoped to the signed-in user and org.
|
|
68
|
+
*/
|
|
69
|
+
export function cardFromInboxItem(it, { since = 0, userId = null, conversationId = null, now = 0 } = {}) {
|
|
70
|
+
if (!it || !it.id) return null;
|
|
71
|
+
const c = it.payload?.approval_context || {};
|
|
72
|
+
const m = c.method || {};
|
|
73
|
+
const created = tms(it.created_at ?? it.createdAt);
|
|
74
|
+
const mine = Boolean(userId && c.on_behalf_of?.user_id && String(c.on_behalf_of.user_id) === String(userId));
|
|
75
|
+
const here = Boolean(conversationId && c.on_behalf_of?.conversation_id === conversationId);
|
|
76
|
+
if (!(created != null && created >= since) && !mine && !here) return null;
|
|
77
|
+
return {
|
|
78
|
+
callId: `inbox:${it.id}`, inboxItemId: it.id, approvalId: it.payload?.approval_id || it.approval_id || it.approvalId || null,
|
|
79
|
+
tool: m.tool || String(it.title || it.id), target: m.target || null, scope: null, risk: c.risk || null,
|
|
80
|
+
url: it.dashboard_url || null, shownAt: now, undescribable: !m.tool || !m.target
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let uid = 0;
|
|
85
|
+
const nid = () => ++uid;
|
|
86
|
+
|
|
87
|
+
const settled = (b) => b.kind === 'tool' ? b.status !== 'running' : b.kind !== 'text';
|
|
88
|
+
|
|
89
|
+
/** Move the settled prefix of live blocks into history. */
|
|
90
|
+
function flush(s, force = false) {
|
|
91
|
+
const blocks = [...s.blocks];
|
|
92
|
+
const out = [];
|
|
93
|
+
while (blocks.length) {
|
|
94
|
+
const b = blocks[0];
|
|
95
|
+
const done = force || settled(b) || (b.kind === 'text' && blocks.length > 1);
|
|
96
|
+
if (!done) break;
|
|
97
|
+
blocks.shift();
|
|
98
|
+
if (b.kind === 'text' && !b.text.trim()) continue;
|
|
99
|
+
out.push(b);
|
|
100
|
+
}
|
|
101
|
+
return out.length || blocks.length !== s.blocks.length ? { ...s, history: [...s.history, ...out], blocks } : s;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function note(s, text, tone = 'dim') {
|
|
105
|
+
return { ...s, history: [...s.history, { id: nid(), kind: 'note', text, tone }] };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function addBlock(s, b) { return flush({ ...s, blocks: [...s.blocks, { id: nid(), ...b }] }); }
|
|
109
|
+
|
|
110
|
+
// Raw markdown of each answer, kept for /copy (history holds only the split, rendered blocks).
|
|
111
|
+
function recordRaw(s, content) {
|
|
112
|
+
const id = s.turn?.id ?? '_';
|
|
113
|
+
const a = s.answers || [];
|
|
114
|
+
const last = a[a.length - 1];
|
|
115
|
+
const next = last && last.id === id ? a.slice(0, -1).concat({ id, text: last.text + content }) : a.concat({ id, text: String(content) }).slice(-20);
|
|
116
|
+
return { ...s, answers: next };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function appendText(s00, content) {
|
|
120
|
+
const s0 = recordRaw(s00, content);
|
|
121
|
+
const s = s0.turn && !s0.turn.sawText ? { ...s0, turn: { ...s0.turn, sawText: true } } : s0;
|
|
122
|
+
const last = s.blocks[s.blocks.length - 1];
|
|
123
|
+
if (last && last.kind === 'text') {
|
|
124
|
+
let text = last.text + content;
|
|
125
|
+
let history = s.history;
|
|
126
|
+
// Completed markdown blocks leave the live region (-> <Static>); only the unfinished tail re-renders.
|
|
127
|
+
if (s.blocks.length === 1 && text.length > 1) {
|
|
128
|
+
const { settled, rest } = splitSettled(text);
|
|
129
|
+
if (settled.trim()) { history = [...history, { id: nid(), kind: 'text', text: settled }]; text = rest; }
|
|
130
|
+
}
|
|
131
|
+
return { ...s, history, blocks: s.blocks.slice(0, -1).concat({ ...last, text }) };
|
|
132
|
+
}
|
|
133
|
+
return addBlock(s, { kind: 'text', text: content });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function upsertTool(s, ev, patch) {
|
|
137
|
+
const i = s.blocks.findIndex(b => b.kind === 'tool' && b.callId === ev.call_id);
|
|
138
|
+
if (i >= 0) {
|
|
139
|
+
const cur = s.blocks[i];
|
|
140
|
+
// started after finished (out of order): never downgrade a settled row
|
|
141
|
+
const p = cur.status !== 'running' && patch.status === 'running' ? { summary: cur.summary || patch.summary, tool: cur.tool || patch.tool } : patch;
|
|
142
|
+
const blocks = s.blocks.slice();
|
|
143
|
+
blocks[i] = { ...cur, ...p };
|
|
144
|
+
const done = p.status && p.status !== 'running' ? { ...s.doneCalls, [ev.call_id]: true } : s.doneCalls;
|
|
145
|
+
return flush({ ...s, blocks, doneCalls: done });
|
|
146
|
+
}
|
|
147
|
+
// A row that already settled and left the live region: a late tool.started (out of order) or a duplicate finish is dropped.
|
|
148
|
+
if (s.doneCalls[ev.call_id]) return s;
|
|
149
|
+
let n = patch.status !== 'running' ? { ...s, doneCalls: { ...s.doneCalls, [ev.call_id]: true } } : s;
|
|
150
|
+
if (n.turn) n = { ...n, turn: { ...n.turn, tools: n.turn.tools + 1 } };
|
|
151
|
+
return addBlock(n, { kind: 'tool', callId: ev.call_id, tool: ev.tool, ...patch });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function finishRunning(s, status, detail) {
|
|
155
|
+
const blocks = s.blocks.map(b => b.kind === 'tool' && b.status === 'running'
|
|
156
|
+
? { ...b, status, resultSummary: detail } : b);
|
|
157
|
+
return { ...s, blocks };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
|
|
161
|
+
|
|
162
|
+
/** One grey closing line from facts only (durations, counts, receipt ids). */
|
|
163
|
+
export function summarizeTurn(s, status, now) {
|
|
164
|
+
const t = s.turn;
|
|
165
|
+
const secs = t && now != null && t.startedAt != null ? Math.max(0, Math.round((now - t.startedAt) / 1000)) : null;
|
|
166
|
+
const finished = t?.finishedTools || 0;
|
|
167
|
+
const unknown = s.blocks.filter(b => b.kind === 'tool' && (b.status === 'running' || b.status === 'effect_unknown')).length;
|
|
168
|
+
if (status === 'unconfirmed') return 'Ended without confirmation from the server';
|
|
169
|
+
const head = status === 'interrupted' ? 'Interrupted' : status === 'failed' ? 'Failed' : 'Thought';
|
|
170
|
+
const parts = [secs == null ? head : `${head} ${status === 'completed' ? 'for' : 'after'} ${secs}s`];
|
|
171
|
+
if (t?.model) parts.push(t.model.length > 32 ? t.model.slice(0, 31) + '…' : t.model); // which model answered, as reported
|
|
172
|
+
// Real server usage when it arrived; otherwise the estimate from streamed text; nothing when neither exists.
|
|
173
|
+
const tk = turnTokens(t);
|
|
174
|
+
if (tk > 0) parts.push(`${fmtTokens(tk)} tokens`);
|
|
175
|
+
const cost = fmtCost(t?.usage?.cost ?? normUsage(null, t?.doneCost).cost);
|
|
176
|
+
if (cost) parts.push(cost);
|
|
177
|
+
if (status === 'interrupted' || status === 'failed') {
|
|
178
|
+
if (t?.tools) parts.push(`${plural(finished, 'tool')} finished`);
|
|
179
|
+
if (unknown) parts.push(`${unknown} effect unknown`);
|
|
180
|
+
} else if (t?.tools) parts.push(plural(t.tools, 'tool'));
|
|
181
|
+
if (t?.approvals) parts.push(plural(t.approvals, 'approval'));
|
|
182
|
+
const r = t?.receipts || [];
|
|
183
|
+
if (r.length === 1) parts.push(`receipt ward:${r[0]}`);
|
|
184
|
+
else if (r.length > 1) parts.push(`${r.length} receipts, last ward:${r[r.length - 1]}`);
|
|
185
|
+
return parts.join(' · ');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function endTurn(s, status, now) {
|
|
189
|
+
let n = s;
|
|
190
|
+
if (status === 'interrupted' || status === 'failed' || status === 'unconfirmed') {
|
|
191
|
+
n = finishRunning(n, 'effect_unknown', 'dispatched, no result received; verify before retrying');
|
|
192
|
+
} else {
|
|
193
|
+
n = finishRunning(n, 'effect_unknown', 'no result reported');
|
|
194
|
+
}
|
|
195
|
+
n = flush(n, true);
|
|
196
|
+
const summary = summarizeTurn(s, status, now);
|
|
197
|
+
if (status === 'interrupted') n = note(n, 'Interrupted. Nothing completed was undone.', 'warn');
|
|
198
|
+
// unaccepted steers become queued items, in front
|
|
199
|
+
let pending = n.pending;
|
|
200
|
+
let steering = n.steering;
|
|
201
|
+
if (steering.length) {
|
|
202
|
+
pending = [...steering.map(x => ({ id: x.id, text: x.text })), ...pending];
|
|
203
|
+
steering = [];
|
|
204
|
+
n = note(n, 'Steering was not applied before the turn ended; queued instead.', 'warn');
|
|
205
|
+
}
|
|
206
|
+
n = { ...n, history: [...n.history, { id: nid(), kind: 'summary', text: summary }] };
|
|
207
|
+
return {
|
|
208
|
+
...n, turn: null, pending, steering,
|
|
209
|
+
// Only a confirmed-completed turn drains the queue; failed, interrupted or unconfirmed turns leave
|
|
210
|
+
// the pending items visible for the human to send.
|
|
211
|
+
autoDrain: status === 'completed' && pending.length > 0
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function reduceInner(s, a) {
|
|
216
|
+
switch (a.type) {
|
|
217
|
+
case 'user': // a submitted prompt
|
|
218
|
+
return { ...s, history: [...s.history, { id: nid(), kind: 'user', text: a.text, queued: !!a.queued }] };
|
|
219
|
+
case 'turn.begin':
|
|
220
|
+
return { ...s, turn: { id: a.turnId, status: 'running', startedAt: a.now, stage: null, activity: null, tools: 0, finishedTools: 0, approvals: 0, receipts: [], chars: 0, usage: null }, blocks: [], autoDrain: false };
|
|
221
|
+
case 'note':
|
|
222
|
+
return note(s, a.text, a.tone);
|
|
223
|
+
case 'notify':
|
|
224
|
+
return { ...s, notify: { ...s.notify, ...a.patch } };
|
|
225
|
+
case 'welcome':
|
|
226
|
+
return { ...s, history: [...s.history, { id: nid(), kind: 'welcome', version: a.version }] };
|
|
227
|
+
case 'help':
|
|
228
|
+
return { ...s, history: [...s.history, { id: nid(), kind: 'help', model: a.model }] };
|
|
229
|
+
case 'object': // inline object view (objects/): pure data, rebuilt at render width
|
|
230
|
+
return { ...s, history: [...s.history, { id: nid(), kind: 'object', obj: a.obj }] };
|
|
231
|
+
case 'error':
|
|
232
|
+
return flush(addBlock(s, { kind: 'error', text: a.text }), false);
|
|
233
|
+
case 'verbose':
|
|
234
|
+
return { ...s, verbose: a.mode };
|
|
235
|
+
case 'history.add': // restored turns (/resume): ordinary history items, plus their answers for /copy
|
|
236
|
+
return { ...s, history: [...s.history, ...(a.items || []).map(i => ({ id: nid(), ...i }))], answers: [...(s.answers || []), ...(a.answers || [])].slice(-20) };
|
|
237
|
+
case 'history.reset': // /clear: a fresh screen; nothing on the server is touched
|
|
238
|
+
return s.turn ? s : { ...s, history: [], blocks: [], answers: [] };
|
|
239
|
+
case 'ctx':
|
|
240
|
+
return { ...s, ctx: { ...s.ctx, ...a.ctx } };
|
|
241
|
+
case 'resume': { // QUOXON J2: open an existing conversation; its saved turns become the visible history
|
|
242
|
+
const items = [];
|
|
243
|
+
for (const t of a.turns || []) {
|
|
244
|
+
if (t.role === 'user') items.push({ id: nid(), kind: 'user', text: String(t.content ?? ''), queued: false });
|
|
245
|
+
else if (t.role === 'assistant') items.push({ id: nid(), kind: 'text', text: String(t.content ?? '') });
|
|
246
|
+
}
|
|
247
|
+
const n = items.filter((x) => x.kind === 'user').length;
|
|
248
|
+
const s2 = { ...s, ctx: { ...s.ctx, conversationId: a.conversationId }, history: [...s.history, ...items] };
|
|
249
|
+
return note(s2, `Resumed conversation ${a.conversationId} (${n} earlier turn${n === 1 ? '' : 's'}). New messages continue it.`);
|
|
250
|
+
}
|
|
251
|
+
case 'inbox':
|
|
252
|
+
return { ...s, inbox: a.items };
|
|
253
|
+
case 'queue.add':
|
|
254
|
+
return { ...s, pending: [...s.pending, { id: nid(), text: a.text }] };
|
|
255
|
+
case 'queue.remove':
|
|
256
|
+
return { ...s, pending: s.pending.filter(p => p.id !== a.id) };
|
|
257
|
+
case 'queue.shift':
|
|
258
|
+
return { ...s, pending: s.pending.slice(1), autoDrain: false };
|
|
259
|
+
case 'queue.pop': // Up pulls the last item back to the composer
|
|
260
|
+
return { ...s, pending: s.pending.slice(0, -1) };
|
|
261
|
+
case 'steer.add':
|
|
262
|
+
return { ...s, steering: [...s.steering, { id: a.id, text: a.text }] };
|
|
263
|
+
case 'steer.converted': {
|
|
264
|
+
const item = s.steering.find(x => x.id === a.id);
|
|
265
|
+
if (!item) return s;
|
|
266
|
+
const n = note({ ...s, steering: s.steering.filter(x => x.id !== a.id), pending: [...s.pending, { id: item.id, text: item.text }] },
|
|
267
|
+
a.reason || 'Steering is not available for this turn; queued instead.', 'warn');
|
|
268
|
+
return { ...n, autoDrain: !n.turn };
|
|
269
|
+
}
|
|
270
|
+
case 'steer.failed': { // ambiguous or denied: never auto-queued; the text goes back to the draft (the app does that)
|
|
271
|
+
if (!s.steering.some(x => x.id === a.id)) return s;
|
|
272
|
+
return note({ ...s, steering: s.steering.filter(x => x.id !== a.id) }, a.reason, 'warn');
|
|
273
|
+
}
|
|
274
|
+
case 'interrupt.requested':
|
|
275
|
+
return s.turn ? { ...s, turn: { ...s.turn, status: 'interrupting' } } : s;
|
|
276
|
+
case 'stopped.local': { // SSE aborted by us because the server has no interrupt route
|
|
277
|
+
let n = note(s, 'Stopped locally; work already sent may still finish. Verify before retrying.', 'warn');
|
|
278
|
+
return endTurn(n, 'interrupted', a.now);
|
|
279
|
+
}
|
|
280
|
+
case 'approval.found': { // pending approval seen by polling (covers provider paths that emit no approval.requested)
|
|
281
|
+
const card = cardFromInboxItem(a.item, { since: s.turn?.startedAt ?? a.since ?? 0, userId: a.userId, conversationId: s.ctx.conversationId, now: a.now || 0 });
|
|
282
|
+
if (!card || s.seenInbox[card.inboxItemId] || s.approvals.some(x => x.inboxItemId === card.inboxItemId || (card.approvalId && x.approvalId === card.approvalId))) return s;
|
|
283
|
+
return { ...s, approvals: [...s.approvals, card], seenInbox: { ...s.seenInbox, [card.inboxItemId]: true }, turn: s.turn ? { ...s.turn, approvals: s.turn.approvals + 1 } : s.turn };
|
|
284
|
+
}
|
|
285
|
+
case 'approval.dismiss':
|
|
286
|
+
return { ...s, approvals: s.approvals.filter(x => x.callId !== a.callId) };
|
|
287
|
+
case 'approval.sent': { // the human answered from this terminal
|
|
288
|
+
const rest = { ...s, approvals: s.approvals.filter(x => x.callId !== a.callId) };
|
|
289
|
+
if (a.action !== 'approve') return note(rest, `denied: ${a.tool || 'approval'}${a.target ? ` on ${a.target}` : ''}`, 'warn');
|
|
290
|
+
const run = { callId: a.callId, approvalId: a.approvalId || null, tool: a.tool, target: a.target };
|
|
291
|
+
return note({ ...rest, running: [...rest.running.filter(x => x.callId !== a.callId), run] },
|
|
292
|
+
`approved; running (result arrives asynchronously)${a.tool ? `: ${a.tool}${a.target ? ` on ${a.target}` : ''}` : ''}`, 'ok');
|
|
293
|
+
}
|
|
294
|
+
case 'approval.result': { // outcome of polling GET /api/v1/approvals/:id/result
|
|
295
|
+
const run = s.running.find(x => x.callId === a.callId);
|
|
296
|
+
const n = { ...s, running: s.running.filter(x => x.callId !== a.callId) };
|
|
297
|
+
const what = run ? `${run.tool || 'approval'}${run.target ? ` on ${run.target}` : ''}` : 'approval';
|
|
298
|
+
if (a.outcome === 'done') return note(n, `result: ${what}: ${a.text || 'completed'}`, 'ok');
|
|
299
|
+
if (a.outcome === 'failed') return note(n, `failed: ${what}: ${a.text || 'error'}`, 'warn');
|
|
300
|
+
if (a.outcome === 'denied') return note(n, `denied: ${what}`, 'warn');
|
|
301
|
+
return note(n, `result not known yet: ${what}; check /approvals`, 'warn');
|
|
302
|
+
}
|
|
303
|
+
case 'inbox.sync': { // open approval ids from a successful inbox refresh
|
|
304
|
+
const ids = new Set(a.ids);
|
|
305
|
+
const approvals = s.approvals.filter(x => !x.inboxItemId || ids.has(x.inboxItemId) || (a.now || 0) - x.shownAt < 5000);
|
|
306
|
+
return approvals.length === s.approvals.length ? s : { ...s, approvals };
|
|
307
|
+
}
|
|
308
|
+
case 'event':
|
|
309
|
+
return reduceEvent(s, a.ev, a.now || 0);
|
|
310
|
+
default:
|
|
311
|
+
return s;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The first card is armed from the moment it is actually SHOWN, not from when it was received. */
|
|
316
|
+
export function reduce(s, a) {
|
|
317
|
+
let n = reduceInner(s, a);
|
|
318
|
+
// The honest activity word: only real events move it (see activity.js).
|
|
319
|
+
if (n.turn) {
|
|
320
|
+
const act = a.type === 'event' ? nextActivity(s.turn?.act, a.ev, a.now || 0, { hasText: Boolean(n.turn.chars) })
|
|
321
|
+
: actionActivity(n.turn.act, a, a.now || 0);
|
|
322
|
+
if (act !== n.turn.act) n = { ...n, turn: { ...n.turn, act } };
|
|
323
|
+
}
|
|
324
|
+
const first = n.approvals[0];
|
|
325
|
+
if (first && first.displayedAt == null) {
|
|
326
|
+
return { ...n, approvals: [{ ...first, displayedAt: a.now || 0 }, ...n.approvals.slice(1)] };
|
|
327
|
+
}
|
|
328
|
+
return n;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function reduceEvent(s, ev, now) {
|
|
332
|
+
switch (ev.type) {
|
|
333
|
+
case 'connected': return s;
|
|
334
|
+
// TODO(contract v2): background.status is not in events v1; wire here once the server defines it.
|
|
335
|
+
case 'turn.started': {
|
|
336
|
+
const ctx = {
|
|
337
|
+
agent: ev.agent ?? s.ctx.agent, model: ev.model ?? s.ctx.model, provider: ev.provider ?? s.ctx.provider,
|
|
338
|
+
// B3: the host a self-hosted provider ran at; cleared when the server names none.
|
|
339
|
+
endpoint: ev.provider ? (ev.endpoint ?? null) : (s.ctx.endpoint ?? null),
|
|
340
|
+
hat: ev.hat ?? s.ctx.hat, conversationId: ev.conversation_id ?? s.ctx.conversationId
|
|
341
|
+
};
|
|
342
|
+
// The model THIS turn actually used, as the server reported it (never the profile's default).
|
|
343
|
+
const used = ev.model || ev.provider || null;
|
|
344
|
+
return { ...s, ctx, turn: s.turn ? { ...s.turn, gate: textGate(s.turn.gate, ev).st, ...(used ? { model: String(used) } : {}) } : s.turn };
|
|
345
|
+
}
|
|
346
|
+
case 'thinking': {
|
|
347
|
+
const stage = ev.stage || '';
|
|
348
|
+
const msg = String(ev.message || '');
|
|
349
|
+
if (!s.turn) return s;
|
|
350
|
+
const turn = { ...s.turn, stage: msg || stage, activity: stage.includes('tool') && !s.blocks.some(b => b.kind === 'tool' && b.status === 'running') ? msg : s.turn.activity };
|
|
351
|
+
let n = { ...s, turn };
|
|
352
|
+
// B3: a provider fact the user should see (e.g. "this model does not support tools"), always, dim.
|
|
353
|
+
if (stage === 'provider_note' && msg) return addBlock(n, { kind: 'note', text: msg, tone: 'dim' });
|
|
354
|
+
// QUOXON: the model's own reasoning text: one dim, collapsed line (size only); verbose full
|
|
355
|
+
// expands it. It is never answer text.
|
|
356
|
+
if (stage === 'model_reasoning') {
|
|
357
|
+
const why = String(ev.reasoning || '').trim();
|
|
358
|
+
const text = s.verbose === 'full' && why ? `thinking: ${why}` : (msg || 'thinking');
|
|
359
|
+
return addBlock(n, { kind: 'note', text, tone: 'dim', collapsed: !(s.verbose === 'full' && why) });
|
|
360
|
+
}
|
|
361
|
+
// Legacy servers report tool use only as a thinking stage: show it honestly, with no result claimed.
|
|
362
|
+
if (stage.includes('tool') && msg) n = addBlock(n, { kind: 'note', text: `working: ${msg}`, tone: 'dim', legacyTool: true });
|
|
363
|
+
if (s.verbose === 'full' && msg && !stage.includes('tool')) n = addBlock(n, { kind: 'note', text: `thinking: ${msg}`, tone: 'dim' });
|
|
364
|
+
return n;
|
|
365
|
+
}
|
|
366
|
+
case 'chunk': case 'text.delta': {
|
|
367
|
+
const g = textGate(s.turn?.gate, ev);
|
|
368
|
+
const n = s.turn ? { ...s, turn: { ...s.turn, gate: g.st } } : s;
|
|
369
|
+
if (!g.append) return n;
|
|
370
|
+
const counted = n.turn ? { ...n, turn: { ...n.turn, chars: (n.turn.chars || 0) + String(ev.content).length } } : n;
|
|
371
|
+
return appendText(counted, ev.content);
|
|
372
|
+
}
|
|
373
|
+
// QUOXON C5 (events v1.1): the client's own reconnect facts around a dropped durable turn.
|
|
374
|
+
case 'reconnecting':
|
|
375
|
+
return ev.attempt === 1 && s.turn ? addBlock(s, { kind: 'note', text: 'Connection to the server dropped; reconnecting. Quox keeps working on the turn meanwhile.', tone: 'warn' }) : s;
|
|
376
|
+
case 'resumed':
|
|
377
|
+
return s.turn ? addBlock(s, { kind: 'note', text: ev.after ? `Reconnected; picking up after event ${ev.after}.` : 'Reattached to the running turn.', tone: 'dim' }) : s;
|
|
378
|
+
case 'replay.gap':
|
|
379
|
+
return addBlock(s, { kind: 'note', text: `Events ${ev.from} to ${ev.to} of this turn were not kept on the server (size cap); the rest follows.`, tone: 'warn' });
|
|
380
|
+
case 'tool.started':
|
|
381
|
+
return upsertTool(s, ev, { status: 'running', summary: ev.summary || ev.tool, startedAt: now });
|
|
382
|
+
case 'tool.finished': {
|
|
383
|
+
const status = TOOL_STATUSES.includes(ev.status) ? ev.status : 'unknown'; // never green for a status we do not know
|
|
384
|
+
const first = !s.doneCalls[ev.call_id] && ['ok', 'failed', 'denied'].includes(status);
|
|
385
|
+
let withTool = upsertTool(s, ev, {
|
|
386
|
+
status, resultSummary: ev.result_summary, durationMs: ev.duration_ms,
|
|
387
|
+
envelopeId: ev.envelope_id, receipt: ev.receipt || null
|
|
388
|
+
});
|
|
389
|
+
if (first && withTool.turn) withTool = { ...withTool, turn: { ...withTool.turn, finishedTools: withTool.turn.finishedTools + 1 } };
|
|
390
|
+
if (ev.receipt && ev.receipt.verified === true && withTool.turn && ev.receipt.ward_seq != null) {
|
|
391
|
+
return { ...withTool, turn: { ...withTool.turn, receipts: [...withTool.turn.receipts, ev.receipt.ward_seq] } };
|
|
392
|
+
}
|
|
393
|
+
return withTool;
|
|
394
|
+
}
|
|
395
|
+
case 'approval.requested': {
|
|
396
|
+
const card = {
|
|
397
|
+
callId: ev.call_id, inboxItemId: ev.inbox_item_id, approvalId: ev.approval_id, tool: ev.tool,
|
|
398
|
+
target: ev.target, scope: ev.scope, risk: ev.risk, url: ev.dashboard_url, shownAt: now,
|
|
399
|
+
undescribable: ev.describable === false || !ev.target
|
|
400
|
+
};
|
|
401
|
+
if (s.approvals.some(x => x.callId === card.callId)) return s;
|
|
402
|
+
const dup = s.approvals.findIndex(x => (card.inboxItemId && x.inboxItemId === card.inboxItemId) || (card.approvalId && x.approvalId === card.approvalId));
|
|
403
|
+
if (dup >= 0) { // already raised by the poll: adopt the event's call id so approval.resolved finds it
|
|
404
|
+
const approvals = s.approvals.slice(); approvals[dup] = { ...approvals[dup], callId: card.callId };
|
|
405
|
+
return { ...s, approvals };
|
|
406
|
+
}
|
|
407
|
+
if (card.inboxItemId && s.seenInbox[card.inboxItemId]) return s; // already shown and answered
|
|
408
|
+
return { ...s, seenInbox: card.inboxItemId ? { ...s.seenInbox, [card.inboxItemId]: true } : s.seenInbox, approvals: [...s.approvals, card], turn: s.turn ? { ...s.turn, approvals: s.turn.approvals + 1 } : s.turn };
|
|
409
|
+
}
|
|
410
|
+
case 'approval.resolved': {
|
|
411
|
+
const card = s.approvals.find(x => x.callId === ev.call_id);
|
|
412
|
+
const n = { ...s, approvals: s.approvals.filter(x => x.callId !== ev.call_id) };
|
|
413
|
+
return card || ev.decision ? note(n, `approval ${ev.decision || 'resolved'}${card ? `: ${card.tool} on ${card.target}` : ''}`, ev.decision === 'approved' ? 'ok' : 'warn') : n;
|
|
414
|
+
}
|
|
415
|
+
case 'steer.accepted': {
|
|
416
|
+
const i = s.steering.findIndex(x => x.text === ev.text);
|
|
417
|
+
const steering = i >= 0 ? s.steering.filter((_, k) => k !== i) : s.steering.slice(1);
|
|
418
|
+
return addBlock({ ...s, steering }, { kind: 'note', text: `steered: ${ev.text}`, tone: 'accent' });
|
|
419
|
+
}
|
|
420
|
+
case 'trace':
|
|
421
|
+
if (s.verbose === 'off') return s;
|
|
422
|
+
return addBlock(s, { kind: 'trace', protocol: ev.protocol, label: ev.label, ref: ev.ref, detail: ev.detail });
|
|
423
|
+
case 'error': {
|
|
424
|
+
const n = addBlock(s, { kind: 'error', text: String(ev.error || 'stream error') });
|
|
425
|
+
return n;
|
|
426
|
+
}
|
|
427
|
+
case 'turn.finished': case 'done': {
|
|
428
|
+
if (!s.turn) return s; // dedupe: done after turn.finished
|
|
429
|
+
let n = s;
|
|
430
|
+
const convo = ev.conversation_id || ev.session_id;
|
|
431
|
+
if (convo) n = { ...n, ctx: { ...n.ctx, conversationId: convo } };
|
|
432
|
+
if (ev.type === 'done' && !n.turn.sawText && ev.full_response) {
|
|
433
|
+
n = appendText(n, ev.full_response);
|
|
434
|
+
}
|
|
435
|
+
// `done` only carries text/session; turn.finished (sent just after it) ends the turn. A legacy
|
|
436
|
+
// server never sends turn.finished: the app synthesises one when the stream closes.
|
|
437
|
+
// usage: `done` may carry cost_usd; turn.finished carries `usage` (input/output tokens, maybe cost_usd).
|
|
438
|
+
if (ev.type === 'done') return { ...n, turn: { ...n.turn, doneCost: ev.cost_usd } };
|
|
439
|
+
n = { ...n, turn: { ...n.turn, usage: normUsage(ev.usage, n.turn.doneCost) } };
|
|
440
|
+
return endTurn(n, ev.status || 'completed', now);
|
|
441
|
+
}
|
|
442
|
+
default:
|
|
443
|
+
return s;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Approval key guard: dialog arms after 300 ms, ignores held keys (any key
|
|
449
|
+
* seen within KEY_REPEAT_MS), and the caller must never map Enter to approve.
|
|
450
|
+
*/
|
|
451
|
+
export function approvalKeyAllowed({ now, shownAt, lastKeyAt, heldKey = null, ch = null }) {
|
|
452
|
+
if (now - shownAt < APPROVAL_ARM_MS) return false;
|
|
453
|
+
// a fresh press needs a release gap: any key within KEY_REPEAT_MS is a held/repeat sequence
|
|
454
|
+
if (lastKeyAt != null && now - lastKeyAt < KEY_REPEAT_MS) return false;
|
|
455
|
+
// the key that was already down when the card appeared stays dead until another key or a long gap
|
|
456
|
+
if (heldKey != null && ch === heldKey && (lastKeyAt == null || now - lastKeyAt < HELD_KEY_MS)) return false;
|
|
457
|
+
return true;
|
|
458
|
+
}
|