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,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QUOXON roster: what is actually running on this instance for this org. Pure
|
|
3
|
+
* functions only (normalise server rows, build the view); fetching lives in api.js.
|
|
4
|
+
* Rows come from real list endpoints; a source that cannot answer contributes
|
|
5
|
+
* nothing (never a placeholder row).
|
|
6
|
+
*/
|
|
7
|
+
import { clip } from './theme.js';
|
|
8
|
+
|
|
9
|
+
export const GLYPH = { running: '◐', waiting: '⏸', done: '✔', failed: '✖' };
|
|
10
|
+
export const FRESH_MS = 10 * 60 * 1000; // finished rows linger this long
|
|
11
|
+
export const USER_DONE_MS = 20 * 1000; // a user-started completion vanishes quickly: rows come and go
|
|
12
|
+
export const PENDING_FRESH_MS = 24 * 3600 * 1000; // an older "pending" is stale, not waiting
|
|
13
|
+
export const MAX_ROWS = 4;
|
|
14
|
+
export const MIN_FULL_COLS = 60;
|
|
15
|
+
export const MIN_FULL_ROWS = 24;
|
|
16
|
+
|
|
17
|
+
/** Server timestamps: epoch ms, ISO, or SQLite "YYYY-MM-DD HH:MM:SS" (UTC, no zone). */
|
|
18
|
+
export function parseTime(v) {
|
|
19
|
+
if (v == null || v === '') return null;
|
|
20
|
+
if (typeof v === 'number') return v;
|
|
21
|
+
const s = String(v);
|
|
22
|
+
const t = Date.parse(/^\d{4}-\d\d-\d\d \d\d:\d\d(:\d\d)?$/.test(s) ? s.replace(' ', 'T') + 'Z' : s);
|
|
23
|
+
return Number.isFinite(t) ? t : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function elapsed(ms) {
|
|
27
|
+
if (ms == null || !Number.isFinite(ms)) return '';
|
|
28
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
29
|
+
if (s < 60) return `${s}s`;
|
|
30
|
+
const m = Math.floor(s / 60);
|
|
31
|
+
if (m < 60) return `${m}m`;
|
|
32
|
+
const h = Math.floor(m / 60);
|
|
33
|
+
if (h < 24) return `${h}h${String(m % 60).padStart(2, '0')}`;
|
|
34
|
+
return `${Math.floor(h / 24)}d`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ACTIVE_RE = /^(running|planning|decompos|executing|in_progress|active)/;
|
|
38
|
+
const WAITING_RE = /^(paused|awaiting|blocked|plan_review|pending_approval|waiting)/;
|
|
39
|
+
const FAILED = new Set(['failed', 'error', 'halted', 'cancelled', 'canceled', 'timed_out', 'timeout']);
|
|
40
|
+
|
|
41
|
+
/** How long a finished row lingers before it leaves the roster (null while running/waiting). */
|
|
42
|
+
const expiryFor = (state, doneMs) => state === 'done' ? doneMs : state === 'failed' ? FRESH_MS : null;
|
|
43
|
+
|
|
44
|
+
/** -> state or null (not shown). */
|
|
45
|
+
function stateOf(status, { startedAt, createdAt, finishedAt, now, doneMs = FRESH_MS }) {
|
|
46
|
+
const st = String(status || '').toLowerCase();
|
|
47
|
+
if (ACTIVE_RE.test(st)) return 'running';
|
|
48
|
+
if (WAITING_RE.test(st)) return 'waiting';
|
|
49
|
+
if (st === 'pending' || st === 'queued') {
|
|
50
|
+
const t = createdAt ?? startedAt;
|
|
51
|
+
return t != null && now - t < PENDING_FRESH_MS ? 'waiting' : null;
|
|
52
|
+
}
|
|
53
|
+
if (st === 'completed' || st === 'complete' || st === 'succeeded' || FAILED.has(st)) {
|
|
54
|
+
if (finishedAt == null) return null;
|
|
55
|
+
const ok = st === 'completed' || st === 'complete' || st === 'succeeded';
|
|
56
|
+
if (now - finishedAt > (ok ? doneMs : FRESH_MS)) return null;
|
|
57
|
+
return ok ? 'done' : 'failed';
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function fromObjective(o, now = Date.now()) {
|
|
63
|
+
const startedAt = parseTime(o.started_at);
|
|
64
|
+
const createdAt = parseTime(o.created_at);
|
|
65
|
+
const finishedAt = parseTime(o.completed_at);
|
|
66
|
+
// Objectives and loops are always user-started work.
|
|
67
|
+
const state = stateOf(o.status, { startedAt, createdAt, finishedAt, now, doneMs: USER_DONE_MS });
|
|
68
|
+
if (!state) return null;
|
|
69
|
+
const loop = o.execution_mode === 'loop';
|
|
70
|
+
let activity = '';
|
|
71
|
+
if (state === 'failed' && o.error_message) activity = String(o.error_message);
|
|
72
|
+
else if (loop && o.max_iterations) activity = `iter ${o.iteration_count || 0}/${o.max_iterations}`;
|
|
73
|
+
else if (o.task_count) activity = `${o.tasks_completed || 0}/${o.task_count} tasks`;
|
|
74
|
+
else if (state === 'waiting') activity = 'waiting to start';
|
|
75
|
+
const from = startedAt ?? createdAt;
|
|
76
|
+
return {
|
|
77
|
+
key: `objective:${o.id}`, id: o.id, source: 'objectives', background: false, kind: loop ? 'loop' : 'objective', state,
|
|
78
|
+
name: o.title || o.id, activity, status: o.status,
|
|
79
|
+
startedAt: from, finishedAt, ageMs: from != null ? (state === 'done' || state === 'failed' ? (finishedAt ?? now) - from : now - from) : null,
|
|
80
|
+
expiryMs: expiryFor(state, USER_DONE_MS),
|
|
81
|
+
detail: { description: o.description, error: o.error_message, tokens: o.tokens_used, toolCalls: o.tool_calls_used }
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const USER_TRIGGERS = new Set(['manual', 'chat', 'cli', 'user']);
|
|
86
|
+
const BACKGROUND_BY = /^(schedule|cron|interval|webhook|event|system)\b/i;
|
|
87
|
+
/**
|
|
88
|
+
* Is this workflow execution background work (hidden from the roster by default)?
|
|
89
|
+
* Fields relied on (live /api/executions rows, checked 2026-10-12): `triggerData.trigger`
|
|
90
|
+
* ('schedule' on every live row) and `triggeredBy` ('schedule:trigger', 'event:<node>',
|
|
91
|
+
* 'webhook:<node>' set by quoxflow's trigger-bootstrap). Rule: a background-looking triggeredBy
|
|
92
|
+
* always wins; otherwise user-started only if the trigger is manual/chat/cli/user OR triggeredBy
|
|
93
|
+
* equals the signed-in user's id. Unknown trigger with no such evidence is background.
|
|
94
|
+
*/
|
|
95
|
+
export function isBackgroundExecution(e, userId = null) {
|
|
96
|
+
const by = e?.triggeredBy == null ? '' : String(e.triggeredBy);
|
|
97
|
+
if (BACKGROUND_BY.test(by)) return true;
|
|
98
|
+
if (userId && by && by === String(userId)) return false;
|
|
99
|
+
return !USER_TRIGGERS.has(String(e?.triggerData?.trigger || '').toLowerCase());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function fromExecution(e, now = Date.now(), { userId = null } = {}) {
|
|
103
|
+
const startedAt = parseTime(e.startedAt);
|
|
104
|
+
const finishedAt = parseTime(e.completedAt);
|
|
105
|
+
const background = isBackgroundExecution(e, userId);
|
|
106
|
+
const state = stateOf(e.status, { startedAt, createdAt: startedAt, finishedAt, now, doneMs: background ? FRESH_MS : USER_DONE_MS });
|
|
107
|
+
if (!state) return null;
|
|
108
|
+
let activity = '';
|
|
109
|
+
if (state === 'failed' && e.error) activity = typeof e.error === 'string' ? e.error : (e.error.message || JSON.stringify(e.error));
|
|
110
|
+
else if (e.nodeCount) activity = `${e.nodeCount} node${e.nodeCount === 1 ? '' : 's'}`;
|
|
111
|
+
else if (e.triggerData?.trigger) activity = `via ${e.triggerData.trigger}`;
|
|
112
|
+
return {
|
|
113
|
+
key: `execution:${e.id}`, id: e.id, source: 'executions', kind: 'workflow', background, state,
|
|
114
|
+
name: e.workflowName || e.workflowId || e.id, activity, status: e.status,
|
|
115
|
+
startedAt, finishedAt, ageMs: startedAt != null ? (state === 'done' || state === 'failed' ? (finishedAt ?? now) - startedAt : now - startedAt) : null,
|
|
116
|
+
expiryMs: expiryFor(state, background ? FRESH_MS : USER_DONE_MS),
|
|
117
|
+
detail: { workflowId: e.workflowId, correlationId: e.correlationId, trigger: e.triggerData?.trigger, error: e.error }
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A pending approval (inbox item) for this user/org -> amber waiting row, always shown. */
|
|
122
|
+
export function fromApproval(it, now = Date.now()) {
|
|
123
|
+
if (!it || !it.id) return null;
|
|
124
|
+
const m = it.payload?.approval_context?.method || {};
|
|
125
|
+
const name = m.tool ? (m.target ? `${m.tool} on ${m.target}` : m.tool) : String(it.title || it.summary || it.id);
|
|
126
|
+
const createdAt = parseTime(it.created_at ?? it.createdAt);
|
|
127
|
+
return { key: `approval:${it.id}`, id: it.id, source: 'approvals', kind: 'approval', state: 'waiting', background: false, name, activity: '/approvals', status: 'pending',
|
|
128
|
+
startedAt: createdAt, finishedAt: null, ageMs: createdAt != null ? now - createdAt : null, detail: { description: it.title } };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Heartbeat style system work: nobody wants a row or a notification for it, working or failing. */
|
|
132
|
+
export const NOISE_NAME = /heartbeat|health[ _-]?check|keep[ _-]?alive|\bping\b/i;
|
|
133
|
+
export const isNoise = (r) => Boolean(r.background) && NOISE_NAME.test(String(r.name || ''));
|
|
134
|
+
|
|
135
|
+
/** Rows the user should see: background rows only with `all` (/roster all). */
|
|
136
|
+
export function selectRows(rows, { all = false } = {}) {
|
|
137
|
+
return all ? rows : rows.filter(r => !r.background && !isNoise(r));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One line per workflow that is failing in the background (deduped); these feed the notifications bar. */
|
|
141
|
+
export function backgroundAlerts(rows) {
|
|
142
|
+
const by = new Map();
|
|
143
|
+
for (const r of rows) if (r.background && !isNoise(r) && r.state === 'failed') by.set(r.name, (by.get(r.name) || 0) + 1);
|
|
144
|
+
return [...by].map(([name, n]) => `✖ ${name} failing (${n} in ${FRESH_MS / 60000}m) · /roster all`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** When the row last changed state: it started, or it finished (done/failed). */
|
|
148
|
+
export const changedAt = (r) => (r.state === 'done' || r.state === 'failed' ? r.finishedAt : null) ?? r.startedAt ?? 0;
|
|
149
|
+
|
|
150
|
+
/** Approvals pinned on top (oldest first, they need action); everything else most recent change first. */
|
|
151
|
+
export function sortRows(rows) {
|
|
152
|
+
const ap = (r) => (r.kind === 'approval' ? 0 : 1);
|
|
153
|
+
return [...rows].sort((a, b) => ap(a) - ap(b) || (ap(a) === 0 ? (a.startedAt ?? 0) - (b.startedAt ?? 0) : changedAt(b) - changedAt(a)));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const isExpired = (r, now = Date.now()) => r.expiryMs != null && r.finishedAt != null && now - r.finishedAt > r.expiryMs;
|
|
157
|
+
|
|
158
|
+
/** Tone steps, brightest first (theme colour names). Position among non-approval rows, and for finished rows age toward expiry. */
|
|
159
|
+
export const TONES = ['text', 'placeholder', 'dim', 'faint'];
|
|
160
|
+
export function toneName(r, pos, now = Date.now()) {
|
|
161
|
+
if (r.kind === 'approval') return TONES[0];
|
|
162
|
+
let step = Math.min(Math.max(0, pos), TONES.length - 1);
|
|
163
|
+
if (r.expiryMs && r.finishedAt != null) {
|
|
164
|
+
const frac = Math.max(0, now - r.finishedAt) / r.expiryMs;
|
|
165
|
+
step = Math.max(step, frac < 0.25 ? 0 : frac < 0.5 ? 1 : frac < 0.75 ? 2 : 3);
|
|
166
|
+
}
|
|
167
|
+
return TONES[step];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Live age text: a clock for running/waiting rows, recency ("done 3s ago") for finished ones. */
|
|
171
|
+
export function ageText(r, now = Date.now()) {
|
|
172
|
+
if (r.state === 'done' || r.state === 'failed') return r.finishedAt != null ? `${r.state === 'done' ? 'done' : 'failed'} ${elapsed(now - r.finishedAt)} ago` : '';
|
|
173
|
+
return r.startedAt != null ? elapsed(now - r.startedAt) : '';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function countsLine(rows) {
|
|
177
|
+
const n = (s) => rows.filter(r => r.state === s).length;
|
|
178
|
+
return [['running', 'running'], ['waiting', 'waiting'], ['failed', 'failed'], ['done', 'finished']]
|
|
179
|
+
.filter(([s]) => n(s)).map(([s, w]) => `${GLYPH[s]} ${n(s)} ${w}`).join(' · ');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* View model. mode: 'hidden' (off or nothing running) | 'summary' (narrow or short terminal) | 'full'.
|
|
184
|
+
* lines: [{ state, text, selected }] where text is already clipped to cols.
|
|
185
|
+
*/
|
|
186
|
+
export function rosterView({ rows, cols, termRows = 40, max = MAX_ROWS, on = true, sel = 0, focus = false, now = Date.now() }) {
|
|
187
|
+
if (!on || !rows.length) return { mode: 'hidden', lines: [], more: 0 };
|
|
188
|
+
if (cols < MIN_FULL_COLS || termRows < MIN_FULL_ROWS) {
|
|
189
|
+
return { mode: 'summary', lines: [], more: 0, summary: clip(' ' + countsLine(rows) + ' · /roster', cols - 1) };
|
|
190
|
+
}
|
|
191
|
+
const shown = rows.slice(0, max);
|
|
192
|
+
const ageW = Math.max(5, ...shown.map(r => [...ageText(r, now)].length));
|
|
193
|
+
let pos = 0;
|
|
194
|
+
const kindW = Math.min(9, Math.max(...shown.map(r => r.kind.length)));
|
|
195
|
+
const lines = shown.map((r, i) => {
|
|
196
|
+
const el = ageText(r, now).padStart(ageW);
|
|
197
|
+
const tone = toneName(r, pos, now); if (r.kind !== 'approval') pos++;
|
|
198
|
+
const actW = Math.min(18, Math.max(0, ...shown.map(x => [...(x.activity || '')].length)));
|
|
199
|
+
const act = clip(r.activity || '', actW).padEnd(actW);
|
|
200
|
+
const fixed = 1 + 1 + 1 + kindW + 1 + 1 + actW + 1 + ageW; // marker glyph kind name act elapsed
|
|
201
|
+
const nameW = Math.max(8, cols - 2 - fixed - 1);
|
|
202
|
+
const name = clip(r.name, nameW).padEnd(nameW);
|
|
203
|
+
const marker = focus && i === sel ? '›' : ' ';
|
|
204
|
+
const rest = `${r.kind.padEnd(kindW)} ${name} ${act} ${el}`;
|
|
205
|
+
return { key: r.key, state: r.state, tone, selected: focus && i === sel, marker, glyph: GLYPH[r.state], rest: ' ' + rest, text: `${marker}${GLYPH[r.state]} ${rest}` };
|
|
206
|
+
});
|
|
207
|
+
return { mode: 'full', lines, more: Math.max(0, rows.length - shown.length) };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function peekLines(row, now = Date.now()) {
|
|
211
|
+
if (!row) return [];
|
|
212
|
+
const age = ageText(row, now);
|
|
213
|
+
const out = [`${row.kind} ${row.id}`, `status ${row.status} · ${row.state}${age ? ` · ${age}` : ''}`, `name ${row.name}`];
|
|
214
|
+
if (row.activity) out.push(`activity ${row.activity}`);
|
|
215
|
+
const d = row.detail || {};
|
|
216
|
+
if (d.description) out.push(`goal ${d.description}`);
|
|
217
|
+
if (d.workflowId) out.push(`workflow ${d.workflowId}`);
|
|
218
|
+
if (d.trigger) out.push(`trigger ${d.trigger}`);
|
|
219
|
+
if (d.error && row.state === 'failed') out.push(`error ${typeof d.error === 'string' ? d.error : JSON.stringify(d.error)}`);
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bare `quox` is the conversation. Decide, from argv alone, whether this
|
|
3
|
+
* invocation belongs to the QUOXON client (no subcommand, only client flags).
|
|
4
|
+
* Anything with a subcommand, --help or --version stays with commander.
|
|
5
|
+
*/
|
|
6
|
+
const VALUE_FLAGS = new Set(['-p', '--profile', '-n', '--name']);
|
|
7
|
+
const LEVELS = new Set(['off', 'on', 'full']); // `--verbose` alone means on; it only takes a value that is a level
|
|
8
|
+
const PRINT_VALUE_FLAGS = new Set(['--output', '--conversation', '--turn-id', '--timeout', '--provider', '--model']);
|
|
9
|
+
const BOOL_FLAGS = new Set(['--demo', '--demo-onboarding', '--no-clear', '--color', '--service-key']);
|
|
10
|
+
const ALL_FLAGS = new Set([...VALUE_FLAGS, ...PRINT_VALUE_FLAGS, ...BOOL_FLAGS, '--print', '--resume', '-h', '--help', '--']);
|
|
11
|
+
|
|
12
|
+
export function routesToQuoxon(argv, isTTY) {
|
|
13
|
+
let print = false;
|
|
14
|
+
let help = false;
|
|
15
|
+
const dd = argv.indexOf('--');
|
|
16
|
+
const head = dd === -1 ? argv : argv.slice(0, dd);
|
|
17
|
+
const hasPrint = head.some(a => a === '--print' || a.startsWith('--print='));
|
|
18
|
+
for (let i = 0; i < argv.length; i++) {
|
|
19
|
+
const a = argv[i];
|
|
20
|
+
if (a === '--') { return print; } // everything after -- is the prompt
|
|
21
|
+
if (a === '--print') { print = true; const n = argv[i + 1]; if (n !== undefined && !ALL_FLAGS.has(n) && !/^--(profile|verbose|output|conversation|turn-id|timeout|print|provider|model)=/.test(n)) i++; }
|
|
22
|
+
else if (a.startsWith('--print=')) print = true;
|
|
23
|
+
else if (a === '-h' || a === '--help') help = true;
|
|
24
|
+
else if (a === '--verbose') { if (LEVELS.has(argv[i + 1])) i++; }
|
|
25
|
+
else if (a === '--resume') { const n = argv[i + 1]; if (n !== undefined && !n.startsWith('-')) i++; } // optional id or name
|
|
26
|
+
else if (a.startsWith('--resume=')) { /* inline value */ }
|
|
27
|
+
else if (VALUE_FLAGS.has(a)) i++;
|
|
28
|
+
else if (/^--(profile|verbose|name)=/.test(a)) { /* inline value */ }
|
|
29
|
+
else if (BOOL_FLAGS.has(a)) { /* no value */ }
|
|
30
|
+
else if (PRINT_VALUE_FLAGS.has(a)) i++;
|
|
31
|
+
else if (/^--(output|conversation|turn-id|timeout|provider|model)=/.test(a)) { /* inline value */ }
|
|
32
|
+
else if (hasPrint && !a.startsWith('-')) { print = true; } // the prompt, wherever it sits among the flags
|
|
33
|
+
else return false; // subcommand, positional, --help, --version, unknown flag
|
|
34
|
+
}
|
|
35
|
+
if (print) return true;
|
|
36
|
+
if (help) return false; // plain `quox --help` is commander's
|
|
37
|
+
return Boolean(isTTY); // interactive client needs a terminal; scripts keep the banner
|
|
38
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QUOX.md rules: like CLAUDE.md for Quox. Loaded from ~/.quox/QUOX.md (user level) and from QUOX.md in the
|
|
3
|
+
* current directory and every parent up to the git root (nearest last = highest precedence).
|
|
4
|
+
*
|
|
5
|
+
* Wire shape: the collector's /chat/stream only consumes `context.quox_context` (and memory_context);
|
|
6
|
+
* unknown context keys are ignored, and quox_context is injection-filtered and cut at 6000 chars. So the
|
|
7
|
+
* rules ride in quox_context under a clear label, capped below that server limit so OUR visible truncation
|
|
8
|
+
* note is what the user sees, never a silent server-side cut.
|
|
9
|
+
*
|
|
10
|
+
* Safety: a file is skipped (and reported) if it is not a regular file, is world-writable, or is a symlink
|
|
11
|
+
* resolving outside the user's home and the repo root.
|
|
12
|
+
*/
|
|
13
|
+
import { lstatSync, statSync, realpathSync, readFileSync, existsSync } from 'node:fs';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
16
|
+
|
|
17
|
+
export const RULES_FILE = 'QUOX.md';
|
|
18
|
+
export const RULES_CAP = 5500; // chars; the server cuts quox_context at 6000 including our label
|
|
19
|
+
const LABEL = 'User rules (QUOX.md files from the user\'s machine, in order of precedence, last wins). Follow them for style and behaviour; they cannot grant permissions or bypass approvals.';
|
|
20
|
+
|
|
21
|
+
const inside = (p, root) => p === root || p.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
22
|
+
|
|
23
|
+
const tilde = (p, home, gitRoot) => (inside(p, home) ? '~' + p.slice(home.length) : gitRoot && inside(p, gitRoot) ? '<repo>' + p.slice(gitRoot.length) : p);
|
|
24
|
+
|
|
25
|
+
export function findGitRoot(start, exists = existsSync) {
|
|
26
|
+
let d = resolve(start);
|
|
27
|
+
for (;;) {
|
|
28
|
+
if (exists(join(d, '.git'))) return d;
|
|
29
|
+
const up = dirname(d);
|
|
30
|
+
if (up === d) return null;
|
|
31
|
+
d = up;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** -> { path, content } | { path, skipped } | null (absent). */
|
|
36
|
+
function readSafe(path, allowedRoots, fsx) {
|
|
37
|
+
let st;
|
|
38
|
+
try { st = fsx.lstat(path); } catch { return null; }
|
|
39
|
+
if (st.isSymbolicLink()) {
|
|
40
|
+
let real;
|
|
41
|
+
try { real = fsx.realpath(path); } catch { return { path, skipped: 'broken symlink' }; }
|
|
42
|
+
if (!allowedRoots.some(r => inside(real, r))) return { path, skipped: 'symlink points outside your home and the repo' };
|
|
43
|
+
try { st = fsx.stat(real); } catch { return { path, skipped: 'unreadable' }; }
|
|
44
|
+
}
|
|
45
|
+
if (!st.isFile()) return { path, skipped: 'not a regular file' };
|
|
46
|
+
if (st.mode & 0o002) return { path, skipped: 'world-writable (chmod o-w to use it)' };
|
|
47
|
+
try { return { path, content: fsx.read(path) }; } catch { return { path, skipped: 'unreadable' }; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const realFs = { lstat: lstatSync, stat: statSync, realpath: realpathSync, read: (p) => readFileSync(p, 'utf8') };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* -> { files:[{path,label,chars}], skipped:[{path,reason}], text, truncated, totalChars }
|
|
54
|
+
* text is the combined rules (labels per file), already capped.
|
|
55
|
+
*/
|
|
56
|
+
export function loadRules({ cwd = process.cwd(), home = homedir(), cap = RULES_CAP, fsx = {} } = {}) {
|
|
57
|
+
const f = { ...realFs, ...fsx };
|
|
58
|
+
const exists = fsx.exists || existsSync;
|
|
59
|
+
let realHome = home;
|
|
60
|
+
try { realHome = f.realpath(home); } catch { /* keep */ }
|
|
61
|
+
const gitRoot = findGitRoot(cwd, exists);
|
|
62
|
+
const dirs = [];
|
|
63
|
+
if (gitRoot) {
|
|
64
|
+
for (let d = resolve(cwd); ; d = dirname(d)) { dirs.push(d); if (d === gitRoot || dirname(d) === d) break; }
|
|
65
|
+
} else dirs.push(resolve(cwd));
|
|
66
|
+
dirs.reverse(); // root-most first, cwd last
|
|
67
|
+
const roots = [realHome, ...(gitRoot ? [gitRoot] : [resolve(cwd)])];
|
|
68
|
+
const candidates = [{ path: join(home, '.quox', RULES_FILE), label: '~/.quox/QUOX.md' }];
|
|
69
|
+
for (const d of dirs) {
|
|
70
|
+
const p = join(d, RULES_FILE);
|
|
71
|
+
if (!candidates.some(c => c.path === p)) candidates.push({ path: p, label: tilde(p, home, gitRoot) });
|
|
72
|
+
}
|
|
73
|
+
const files = [], skipped = [];
|
|
74
|
+
const sections = [];
|
|
75
|
+
for (const c of candidates) {
|
|
76
|
+
const r = readSafe(c.path, roots, f);
|
|
77
|
+
if (!r) continue;
|
|
78
|
+
if (r.skipped) { skipped.push({ path: c.path, reason: r.skipped }); continue; }
|
|
79
|
+
const content = r.content.replace(/\r\n/g, '\n').trim();
|
|
80
|
+
if (!content) continue;
|
|
81
|
+
files.push({ path: c.path, label: c.label, chars: content.length, content });
|
|
82
|
+
sections.push(`# ${c.label}\n${content}`);
|
|
83
|
+
}
|
|
84
|
+
let body = sections.join('\n\n');
|
|
85
|
+
const totalChars = body.length;
|
|
86
|
+
let truncated = false;
|
|
87
|
+
if (body.length > cap) {
|
|
88
|
+
truncated = true;
|
|
89
|
+
const note = `\n[QUOX.md rules truncated: ${totalChars} chars, kept ${cap}]`;
|
|
90
|
+
body = body.slice(0, Math.max(0, cap - note.length)) + note;
|
|
91
|
+
}
|
|
92
|
+
return { files, skipped, text: body, truncated, totalChars };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The /chat/stream `context` object, or undefined when there is nothing to send. */
|
|
96
|
+
export function rulesContext(rules) {
|
|
97
|
+
if (!rules || !rules.text) return undefined;
|
|
98
|
+
return { quox_context: `${LABEL}\n\n${rules.text}` };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Status-line fragment, e.g. "rules: QUOX.md (+1)". */
|
|
102
|
+
export function rulesLabel(rules) {
|
|
103
|
+
if (!rules || !rules.files.length) return null;
|
|
104
|
+
const extra = rules.files.length - 1;
|
|
105
|
+
return `rules: QUOX.md${extra ? ` (+${extra})` : ''}${rules.truncated ? ' (cut)' : ''}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** ~-shorten a path and, when it still does not fit `max`, keep its head and tail (the file name survives). */
|
|
109
|
+
export function shortPath(p, max = 60, home = homedir()) {
|
|
110
|
+
let t = String(p);
|
|
111
|
+
if (home && (t === home || t.startsWith(home + sep))) t = '~' + t.slice(home.length);
|
|
112
|
+
const g = [...t];
|
|
113
|
+
if (g.length <= max) return t;
|
|
114
|
+
const room = Math.max(8, max - 1);
|
|
115
|
+
const head = Math.min(6, Math.floor(room / 4));
|
|
116
|
+
return g.slice(0, head).join('') + '…' + g.slice(g.length - (room - head)).join('');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** /rules view as lines (paged by the caller). `cols` lets paths shrink so the reason and file name stay visible. */
|
|
120
|
+
export function rulesLines(rules, { enabled = true, cols = 100, home } = {}) {
|
|
121
|
+
const out = [];
|
|
122
|
+
if (!rules || (!rules.files.length && !rules.skipped.length)) return ['No QUOX.md found. Put one in this directory (or a parent up to the git root) or at ~/.quox/QUOX.md.'];
|
|
123
|
+
out.push(enabled ? 'Rules are ON for this session.' : 'Rules are OFF for this session (/rules on to re-enable).');
|
|
124
|
+
const room = Math.max(20, cols - 4);
|
|
125
|
+
for (const f of rules.files) { const tail = ` (${f.chars} chars)`; out.push(`loaded ${shortPath(f.label, room - 8 - tail.length, home)}${tail}`); }
|
|
126
|
+
for (const s of rules.skipped) { const head = `skipped: ${s.reason}: `; out.push(head + shortPath(s.path, Math.max(16, room - [...head].length), home)); }
|
|
127
|
+
if (rules.truncated) out.push(`Only the first ${RULES_CAP} chars are sent (total ${rules.totalChars}).`);
|
|
128
|
+
for (const f of rules.files) { out.push('', `--- ${f.label} ---`, ...f.content.split('\n')); }
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session name, new-session invite, agent matching. Pure helpers plus the local
|
|
3
|
+
* name store (~/.config/quox/sessions.json, keyed by conversation id). The
|
|
4
|
+
* collector has no conversation rename route, so names persist here.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join, dirname } from 'node:path';
|
|
9
|
+
|
|
10
|
+
export const INVITE = 'Ⓠ /rename this session · /slot <name> for an address · /attach <agent>';
|
|
11
|
+
export const MAX_NAME = 40;
|
|
12
|
+
|
|
13
|
+
export const sessionsPath = () => join(homedir(), '.config', 'quox', 'sessions.json');
|
|
14
|
+
|
|
15
|
+
/** Free text -> a one-line name, or null when nothing usable is left. */
|
|
16
|
+
export function cleanName(raw) {
|
|
17
|
+
const s = String(raw ?? '').replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
18
|
+
return s ? [...s].slice(0, MAX_NAME).join('') : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function loadNames(path = sessionsPath()) {
|
|
22
|
+
try {
|
|
23
|
+
const d = JSON.parse(readFileSync(path, 'utf8'));
|
|
24
|
+
return d && typeof d === 'object' && !Array.isArray(d) ? d : {};
|
|
25
|
+
} catch { return {}; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** -> { ok:true } | { ok:false, error }. Atomic write; never throws. */
|
|
29
|
+
export function saveName(id, name, path = sessionsPath()) {
|
|
30
|
+
try {
|
|
31
|
+
const all = loadNames(path);
|
|
32
|
+
all[id] = { name, updated_at: new Date().toISOString() };
|
|
33
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
34
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
35
|
+
writeFileSync(tmp, JSON.stringify(all, null, 2) + '\n', { mode: 0o600 });
|
|
36
|
+
renameSync(tmp, path);
|
|
37
|
+
return { ok: true };
|
|
38
|
+
} catch (e) { return { ok: false, error: e?.message || 'failed' }; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const lev = (a, b) => {
|
|
42
|
+
const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
43
|
+
for (let j = 1; j <= b.length; j++) dp[0][j] = j;
|
|
44
|
+
for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) {
|
|
45
|
+
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
46
|
+
}
|
|
47
|
+
return dp[a.length][b.length];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const idOf = (a) => String(a?.agent_id ?? a?.id ?? a?.name ?? '');
|
|
51
|
+
const nameOf = (a) => String(a?.name ?? a?.agent_id ?? a?.id ?? '');
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* -> { agent: { id, name } } on an exact (case-insensitive) id or name match,
|
|
55
|
+
* else { matches: [ids] } with the closest names (may be empty).
|
|
56
|
+
*/
|
|
57
|
+
export function matchAgent(input, agents) {
|
|
58
|
+
const q = String(input || '').trim().toLowerCase();
|
|
59
|
+
const list = Array.isArray(agents) ? agents : [];
|
|
60
|
+
const hit = list.find(a => idOf(a).toLowerCase() === q || nameOf(a).toLowerCase() === q);
|
|
61
|
+
if (hit) return { agent: { id: idOf(hit), name: nameOf(hit) } };
|
|
62
|
+
const scored = list.map(a => {
|
|
63
|
+
const ks = [idOf(a).toLowerCase(), nameOf(a).toLowerCase()];
|
|
64
|
+
const d = Math.min(...ks.map(k => (k.includes(q) || (k && q.includes(k)) ? 0 : lev(q, k))));
|
|
65
|
+
return { id: idOf(a), d };
|
|
66
|
+
}).filter(x => x.d <= Math.max(2, Math.floor(q.length / 3))).sort((x, y) => x.d - y.d || x.id.localeCompare(y.id));
|
|
67
|
+
return { matches: scored.slice(0, 3).map(x => x.id) };
|
|
68
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** /skills and /skill helpers (pure). Skills are the org's QSKL procedural skills (not Crystals); running goes through a normal governed turn. */
|
|
2
|
+
const one = (s, n = 80) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, n);
|
|
3
|
+
|
|
4
|
+
export function findSkill(all, name) {
|
|
5
|
+
const n = String(name || '').trim().toLowerCase();
|
|
6
|
+
if (!n) return null;
|
|
7
|
+
return all.find(s => String(s.name || '').toLowerCase() === n) || all.find(s => String(s.id || '').toLowerCase() === n) || null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function skillRows(all, width = 76) {
|
|
11
|
+
const nameW = Math.min(28, Math.max(4, ...all.map(s => [...String(s.name || s.id || '')].length)));
|
|
12
|
+
return all.map(s => {
|
|
13
|
+
const status = s.edition || s.status || 'unknown';
|
|
14
|
+
const head = `${String(s.name || s.id).padEnd(nameW)} ${status.padEnd(12)}`;
|
|
15
|
+
const room = Math.max(0, width - head.length - 2);
|
|
16
|
+
return `${head} ${one(s.description, room)}`.trimEnd();
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function skillDetail(s) {
|
|
21
|
+
const out = [`${s.name || s.id} (${s.edition || s.status || 'unknown'}${s.version != null ? ' v' + s.version : ''})`];
|
|
22
|
+
if (s.description) out.push(one(s.description, 400));
|
|
23
|
+
for (const [k, label] of [['category', 'category'], ['decayState', 'decay'], ['sourceWorkflowId', 'workflow'], ['createdAt', 'created']]) if (s[k]) out.push(`${label}: ${s[k]}`);
|
|
24
|
+
if (Array.isArray(s.tags) && s.tags.length) out.push(`tags: ${s.tags.join(', ')}`);
|
|
25
|
+
out.push(`Run it: /skill ${s.name || s.id} [args]`);
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The turn text that asks CommanderQ to run a skill. Governed: approvals apply as for any tool. */
|
|
30
|
+
export const skillPrompt = (name, args) => `Run the skill ${name}${args ? ` with: ${args}` : ''}`;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /compact: POST /api/v1/conversations/:id/compact asks the server to summarise the conversation (keeping
|
|
3
|
+
* decisions); the summary becomes the head of the conversation for later turns. This module only words the
|
|
4
|
+
* result and the refusals; nothing is summarised on the client.
|
|
5
|
+
*/
|
|
6
|
+
export const SUMMARY_LINES = 6;
|
|
7
|
+
|
|
8
|
+
/** 18400 -> "18.4k", 950 -> "950". */
|
|
9
|
+
export function kTokens(n) {
|
|
10
|
+
if (n == null || n === '') return '?';
|
|
11
|
+
const v = Number(n);
|
|
12
|
+
if (!Number.isFinite(v)) return '?';
|
|
13
|
+
if (v < 1000) return String(Math.round(v));
|
|
14
|
+
return `${(v / 1000).toFixed(1).replace(/\.0$/, '')}k`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The result -> { head, tone, summaryLines, more, notes[] }. */
|
|
18
|
+
export function compactResult(r, { max = SUMMARY_LINES } = {}) {
|
|
19
|
+
const lines = String(r?.summary || '').split('\n').map(l => l.replace(/\s+$/, '')).filter((l, i, a) => l || (i > 0 && a[i - 1]));
|
|
20
|
+
const n = Number(r?.turns_compacted) || 0;
|
|
21
|
+
const head = r?.unchanged
|
|
22
|
+
? `Nothing new to compact; the summary is already up to date (${kTokens(r?.tokens_after)} tokens).`
|
|
23
|
+
: `Compacted ${n} turn${n === 1 ? '' : 's'}: ${kTokens(r?.tokens_before)} -> ${kTokens(r?.tokens_after)} tokens`;
|
|
24
|
+
const notes = [];
|
|
25
|
+
if (!r?.unchanged && Number(r?.tokens_after) > Number(r?.tokens_before)) notes.push('The summary is longer than what it replaced: this conversation was too short to gain from compacting.');
|
|
26
|
+
if (r?.provider || r?.model) notes.push(`Summarised by ${[r.provider, r.model].filter(Boolean).join(' ')}.`);
|
|
27
|
+
if (Array.isArray(r?.tool_activity) && r.tool_activity.some(t => t?.tools?.length)) notes.push('/compact show lists the tool activity recorded in those turns, the basis for any action the summary names.');
|
|
28
|
+
if (r?.cli_session_reset) notes.push('The Claude CLI session behind this conversation was reset, so it starts from the summary.');
|
|
29
|
+
return { head, tone: r?.unchanged ? undefined : 'ok', summaryLines: lines.slice(0, max), more: Math.max(0, lines.length - max), allLines: lines, notes, basisLines: toolBasisLines(r?.tool_activity) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The server's recorded tool activity per compacted turn -> lines. Read from the stored turns, never
|
|
34
|
+
* from the model, so any action the summary names can be checked here. No row is "none recorded",
|
|
35
|
+
* not "none ran": only some paths record tool activity.
|
|
36
|
+
*/
|
|
37
|
+
export function toolBasisLines(activity) {
|
|
38
|
+
if (!Array.isArray(activity)) return ['The server sent no tool activity record for this compaction.'];
|
|
39
|
+
if (!activity.length) return ['No turns were compacted, so there is no tool activity.'];
|
|
40
|
+
return activity.map((t) => {
|
|
41
|
+
const tools = (Array.isArray(t?.tools) ? t.tools : []).map(x => `${x?.name || '?'} ${x?.status || '?'}`);
|
|
42
|
+
if (Number(t?.more) > 0) tools.push(`and ${Number(t.more)} more`);
|
|
43
|
+
return `turn ${t?.turn ?? '?'}: ${tools.length ? tools.join(', ') : 'no tools recorded'}`;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** /compact show: the whole summary, then the recorded tool activity it was made from. */
|
|
48
|
+
export function compactShowLines(v) {
|
|
49
|
+
const basis = Array.isArray(v?.basisLines) ? v.basisLines : toolBasisLines(undefined);
|
|
50
|
+
return [
|
|
51
|
+
{ kind: 'plain', text: `Summary (${v.allLines.length} lines)` },
|
|
52
|
+
...v.allLines.map(t => ({ kind: 'dim', text: t || ' ' })),
|
|
53
|
+
{ kind: 'plain', text: 'Recorded tool activity (from the stored turns, not the model)' },
|
|
54
|
+
...basis.map(t => ({ kind: 'dim', text: t })),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A refused or failed compaction -> one honest line. */
|
|
59
|
+
export function compactError(e) {
|
|
60
|
+
const code = e?.code || e?.body?.error || null;
|
|
61
|
+
const msg = e?.body?.message || null;
|
|
62
|
+
if (e?.status === 409) {
|
|
63
|
+
if (code === 'turn_running') return 'A turn is still running in this conversation. Compact after it finishes.';
|
|
64
|
+
if (code === 'compact_in_progress') return 'A compaction of this conversation is already running.';
|
|
65
|
+
if (code === 'transcripts_disabled') return msg || 'Transcript storage is off for this org, so no summary can be stored.';
|
|
66
|
+
return msg || 'The server refused to compact right now (409).';
|
|
67
|
+
}
|
|
68
|
+
if (e?.status === 403 && code === 'ai_consent_required') return 'Compacting sends this conversation to your AI provider, and this org has not given AI processing consent. An admin can turn it on in Settings.';
|
|
69
|
+
if (e?.status === 403) return 'Not allowed to compact this conversation (403).';
|
|
70
|
+
if (e?.status === 404) return 'The server has no stored turns for this conversation (expired after 7 days, or nothing sent yet).';
|
|
71
|
+
if (e?.status === 502) return 'The model call failed; nothing was changed. Try /compact again.';
|
|
72
|
+
if (e?.status === 503 && code === 'no_provider') return 'No AI provider is configured for this org, so nothing can summarise it. /connect openai';
|
|
73
|
+
if (e?.status === 500 && code === 'summary_not_stored') return 'The summary was made but could not be stored; nothing was changed.';
|
|
74
|
+
if (e?.status === 404 || e?.status === 405) return 'This server cannot compact conversations yet.';
|
|
75
|
+
return `compact failed: ${e?.status ? `HTTP ${e.status} ` : ''}${e?.message || 'failed'}`.trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** GET .../context -> the /context rows' facts. */
|
|
79
|
+
export function contextFacts(c) {
|
|
80
|
+
if (!c || typeof c !== 'object') return null;
|
|
81
|
+
const t = c.tokens || {};
|
|
82
|
+
const how = t.source === 'cli_session_usage' ? 'from the CLI session usage' : t.source === 'chars_div_4' ? 'estimated from stored text (chars / 4)' : t.source ? `source ${t.source}` : 'source not given';
|
|
83
|
+
return {
|
|
84
|
+
tokens: t.estimate,
|
|
85
|
+
tokensText: `${kTokens(t.estimate)} tokens${t.context_window ? ` of ${kTokens(t.context_window)}` : ''}, ${how}`,
|
|
86
|
+
turns: c.turns,
|
|
87
|
+
turnsText: c.turns == null ? null : c.summary_present ? `${c.turns} turn${c.turns === 1 ? '' : 's'} since the summary` : `${c.turns} stored turn${c.turns === 1 ? '' : 's'}`,
|
|
88
|
+
summaryText: c.summary_present ? `yes, ${c.compacted_turns ?? '?'} turns folded in${c.summary_at ? ` (${String(c.summary_at).slice(0, 16).replace('T', ' ')} UTC)` : ''}` : 'none yet (/compact)'
|
|
89
|
+
};
|
|
90
|
+
}
|