quoxcli 0.4.0 → 0.5.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/CHANGELOG.md +61 -0
- package/README.md +2 -2
- package/bin/quox.js +19 -1
- package/bin/quoxon.js +6 -1
- package/lib/api/endpoints/admin.js +7 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +19 -0
- package/lib/api/endpoints/index.js +2 -0
- package/lib/api/endpoints/mon.js +122 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +13 -0
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agentAttach.js +15 -0
- package/lib/commands/approvals.js +193 -3
- package/lib/commands/ask.js +1 -1
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +15 -0
- package/lib/commands/claude.js +4 -1
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/evidence.js +2 -1
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +78 -5
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +30 -3
- package/lib/commands/lucid.js +1 -0
- package/lib/commands/mail.js +161 -20
- package/lib/commands/memory.js +68 -4
- package/lib/commands/memorySync.js +158 -0
- package/lib/commands/mon.js +55 -144
- package/lib/commands/notes.js +29 -7
- package/lib/commands/notesSeal.js +259 -0
- package/lib/commands/objective.js +5 -2
- package/lib/commands/offload.js +2 -3
- package/lib/commands/plan.js +17 -0
- package/lib/commands/provider.js +29 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/run.js +2 -1
- package/lib/commands/server.js +37 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/skills.js +4 -1
- package/lib/commands/slot.js +49 -9
- package/lib/commands/status.js +92 -44
- package/lib/commands/tool.js +30 -0
- package/lib/commands/vault.js +6 -3
- package/lib/commands/walkthrough.js +14 -2
- package/lib/commands/watch.js +11 -2
- package/lib/commands/wazuh.js +824 -0
- package/lib/commands/wazuhIntake.js +317 -0
- package/lib/commands/wazuhReport.js +192 -0
- package/lib/commands/workflow.js +47 -4
- package/lib/commands/x.js +50 -0
- package/lib/core/announce.js +86 -0
- package/lib/core/auth.js +20 -2
- package/lib/core/callMe.js +118 -0
- package/lib/core/client.js +27 -1
- package/lib/core/commandRegistrars.js +8 -0
- package/lib/core/config.js +28 -2
- package/lib/core/confirm.js +68 -7
- package/lib/core/crossOrgApprovals.js +69 -0
- package/lib/core/errors.js +52 -1
- package/lib/core/governedSession.js +135 -5
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/offload.js +37 -0
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +1 -1
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/select.js +2 -2
- package/lib/quoxon/EXTENSIONS.md +142 -19
- package/lib/quoxon/api.js +185 -34
- package/lib/quoxon/app.js +1032 -152
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +56 -2
- package/lib/quoxon/approvalSentence.js +8 -4
- package/lib/quoxon/approvalsView.js +69 -11
- package/lib/quoxon/autoOpen.js +94 -0
- package/lib/quoxon/backgroundWork.js +59 -0
- package/lib/quoxon/bang.js +1 -1
- package/lib/quoxon/capabilities.js +7 -2
- package/lib/quoxon/commandShort.js +7 -0
- package/lib/quoxon/commands.js +40 -10
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dnd.js +71 -0
- package/lib/quoxon/dragonCompanion.js +37 -7
- package/lib/quoxon/dragonMoments.js +78 -0
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +43 -7
- package/lib/quoxon/extensions.js +398 -38
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +125 -22
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/loopCard.js +5 -1
- package/lib/quoxon/loopCards.js +17 -8
- package/lib/quoxon/loopStream.js +20 -9
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +20 -5
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objects/away.js +43 -5
- package/lib/quoxon/onboarding/connectServer.js +162 -0
- package/lib/quoxon/onboarding/copy.js +6 -0
- package/lib/quoxon/onboarding/index.js +5 -43
- package/lib/quoxon/onboarding/probe.js +20 -1
- package/lib/quoxon/onboarding/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +22 -0
- package/lib/quoxon/print.js +2 -0
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/render.js +63 -14
- package/lib/quoxon/roster.js +10 -5
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +86 -0
- package/lib/quoxon/slash/agents.js +2 -1
- package/lib/quoxon/slash/argSources.js +9 -2
- package/lib/quoxon/slash/args.js +4 -2
- package/lib/quoxon/slash/broadcast.js +18 -5
- package/lib/quoxon/slash/broadcastRun.js +13 -2
- package/lib/quoxon/slash/cliLeaves.json +63 -1
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/palette.js +22 -1
- package/lib/quoxon/slash/paletteController.js +10 -4
- package/lib/quoxon/slash/restart.js +7 -4
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- package/lib/quoxon/slashSpinner.js +35 -0
- package/lib/quoxon/slotAttach.js +8 -0
- package/lib/quoxon/slotChat.js +17 -0
- package/lib/quoxon/slotFormat.js +1 -1
- package/lib/quoxon/slotOutbox.js +113 -0
- package/lib/quoxon/suggestCommand.js +1 -1
- package/lib/quoxon/term.js +25 -1
- package/lib/quoxon/transcript.js +2 -1
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +1 -1
- package/lib/quoxon/whatsnew.jsonl +36 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +55 -0
- package/package.json +3 -2
- package/vendor/dragonlab/SOURCE +3 -3
- package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
- package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
- package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
- package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
- package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
- package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
- package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
- package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
- package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
package/lib/quoxon/commands.js
CHANGED
|
@@ -60,13 +60,18 @@ const ARGS = {
|
|
|
60
60
|
slot: [{ after: [], src: 'slots', choices: [{ value: 'log', desc: 'recent messages in the held slot' }] }],
|
|
61
61
|
mode: [{ after: [], choices: [{ value: 'ask', desc: 'ask first (the default)' }, { value: 'plan', desc: 'plan only: investigates and proposes, changes nothing' }] }],
|
|
62
62
|
say: [{ after: [], src: 'slots' }],
|
|
63
|
-
broadcast: [{ after: [], choices: [{ value: '--slots', desc: 'only these slots, comma separated' }, { value: '--priority', desc: 'urgent shows red' }, { value: '--ack', desc: 'ask recipients to /ack' }] }],
|
|
63
|
+
broadcast: [{ after: [], choices: [{ value: '--slots', desc: 'only these slots, comma separated' }, { value: '--priority', desc: 'urgent shows red' }, { value: '--ack', desc: 'ask recipients to /ack' }, { value: '--active', desc: 'only sessions listening now; dormant are skipped' }, { value: '--dry-run', desc: 'with --active: list who would be reached, send nothing' }] }],
|
|
64
64
|
send: [{ after: [], src: 'slots' }, { after: ['*'], src: 'files', of: 'partial' }],
|
|
65
65
|
dragon: [
|
|
66
|
-
{ after: [], choices: ['explain', 'ask', 'sleep', 'fly', 'hero', 'fire', 'classic', { value: 'companion', desc: 'the dragon beside an attached Lucid, Hyperion, Seer, Sentinel, Drago or
|
|
66
|
+
{ after: [], choices: ['explain', 'ask', 'sleep', 'fly', 'hero', 'fire', 'classic', { value: 'companion', desc: 'the dragon beside an attached Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan or Quoxperticus' }] },
|
|
67
67
|
{ after: ['companion'], choices: [{ value: 'on', desc: 'on attach and key moments, then leaves' }, { value: 'greeting', desc: 'only on attach, then leaves' }, { value: 'off', desc: 'never on its own (/dragon shows it)' }, { value: 'status', desc: 'what is set and whether the art loads' }] }
|
|
68
68
|
],
|
|
69
|
-
|
|
69
|
+
ext: [
|
|
70
|
+
{ after: [], choices: [{ value: 'list', desc: 'each listed extension and where it was found' }, { value: 'on', desc: 'load one now and at every start' }, { value: 'off', desc: 'unload one' }, { value: 'tab', desc: 'focus an extension panel (off collapses)' }, { value: 'set', desc: 'change one of its settings' }, { value: 'settings', desc: 'show its settings' }] },
|
|
71
|
+
{ after: ['on'], src: 'ext.listed' }, { after: ['off'], src: 'ext.loaded' }, { after: ['tab'], src: 'ext.panels' },
|
|
72
|
+
{ after: ['set'], src: 'ext.loaded' }, { after: ['settings'], src: 'ext.loaded' }, { after: ['set', '*'], src: 'ext.keys', of: 1 }
|
|
73
|
+
],
|
|
74
|
+
inbox: [{ after: [], choices: [{ value: 'read', desc: 'read N | all: mark items read' }, { value: 'open', desc: 'open N: take an approval to /approvals' }, { value: 'latest', desc: 'the latest 10' }, { value: 'mute', desc: 'mute <from> [1h|8h|1d]: no arrival lines from that source (approvals never muted)' }, { value: 'unmute', desc: 'unmute <from>' }, { value: 'mutes', desc: 'muted sources and when they expire' }] }, { after: ['mute'], src: 'inbox.sources' }, { after: ['unmute'], src: 'inbox.mutes' }],
|
|
70
75
|
tips: [{ after: [], choices: topicList().map(t => ({ value: t.topic, desc: `${t.count} tip${t.count === 1 ? "" : "s"}` })) }],
|
|
71
76
|
whatsnew: [{ after: [], choices: [{ value: 'all', desc: 'every command and entry, regardless of seen' }, { value: 'on', desc: 'show the card at startup when something is new' }, { value: 'off', desc: 'stop the startup card' }] }],
|
|
72
77
|
'tip-settings': [{ after: [], choices: [
|
|
@@ -126,8 +131,10 @@ export const REGISTRY = [
|
|
|
126
131
|
{ id: 'paste', kind: 'key', group: 'Talk', usage: 'paste', desc: 'long pastes collapse to [Pasted text #N +L lines] and expand on send', status: 'active' },
|
|
127
132
|
{ id: 'ctrlc', kind: 'key', group: 'Talk', usage: 'Ctrl+C', desc: 'clear the draft; during a turn interrupt; twice when idle quits', status: 'active' },
|
|
128
133
|
// Approvals
|
|
129
|
-
{ id: 'approvals', kind: 'command', group: 'Approvals', usage: '/approvals [mine|agents|old|<agent>|N]', desc: 'the questions waiting for you, one at a time', server: true, cap: 'approvals', status: 'active'
|
|
130
|
-
|
|
134
|
+
{ id: 'approvals', kind: 'command', group: 'Approvals', usage: '/approvals [mine|agents|old|<agent>|N|auto on|off]', desc: 'the questions waiting for you, one at a time', server: true, cap: 'approvals', status: 'active',
|
|
135
|
+
detail: 'New approvals and questions open by themselves as the one-at-a-time card (after your draft, typing pause or turn; never mid-typing, never answered for you), including ones in your other orgs, which are answered there without switching. /approvals auto off keeps them as the ⏸ line; the setting is saved on this profile.' },
|
|
136
|
+
{ id: 'inbox', kind: 'command', new: true, group: 'Approvals', usage: '/inbox [N | read N|N-M|all | open N | mute <from> [1h|8h|1d] | unmute <from> | mutes]', desc: 'your Quox inbox: the latest 10 as a table, N shows one in full, read marks read, open takes an approval to /approvals; mute silences a source\'s arrival lines (default 8h, approvals never muted), repeats of the same title collapse to (×N)', needs: ['inboxItems'], cap: 'approvals', status: 'active', args: ARGS.inbox },
|
|
137
|
+
{ id: 'mail', kind: 'command', new: true, group: 'Approvals', usage: '/mail [N | focus|fyi|auto|screened]', desc: 'your triaged email (InboxQ), read only: the latest 10, N reads one with its triage reason', needs: ['mailMessages'], status: 'active' },
|
|
131
138
|
{ id: 'walkthrough', kind: 'command', new: true, group: 'Approvals', usage: '/walkthrough', desc: 'walk through what is waiting for you one question at a time: what it does, why, my advice, then your choice (same as /approvals walkthrough)', server: true, cap: 'approvals', status: 'active' },
|
|
132
139
|
{ id: 'yestoall', kind: 'command', new: true, group: 'Approvals', usage: '/yestoall', desc: 'approve everything waiting for you (same as /approve all; privileged ones still ask)', server: true, cap: 'approvals', status: 'active' },
|
|
133
140
|
{ id: 'approve', kind: 'command', group: 'Approvals', usage: '/approve N | 1 y 2 n | agent <name>', desc: 'approve item N from the list, or everything one agent asked', server: true, cap: 'approvals', status: 'active',
|
|
@@ -141,11 +148,15 @@ export const REGISTRY = [
|
|
|
141
148
|
detail: 'Like /approve N, but asks for your step-up PIN first when there is no live human-present window. The digits are never shown, saved, or sent anywhere except the PIN check.' },
|
|
142
149
|
{ id: 'override', kind: 'command', group: 'Approvals', usage: '/override <block id|last> <reason>', desc: 'override ONE blocked action (PIN, recorded and witnessed)', server: true, cap: 'approvals', status: 'active',
|
|
143
150
|
detail: 'When a policy block refuses an action (safety level, budget cap, AOCL or WARD deny), a person with a fresh PIN can override that one action. The reason, your name and the block are recorded and witnessed. Today only safety blocks (E-STOP, DEFCON RED dispatch refusals) are honoured by a gate; other block types are recorded but stay blocked, and the answer says so. "last" is the most recent block this window saw.' },
|
|
151
|
+
{ id: 'verify-email', kind: 'command', group: 'Approvals', usage: '/verify-email <email> --reason "<why>"', desc: 'mark a user email verified (instance admin; preview, then your PIN; audited)', server: true, cap: 'approvals', status: 'active',
|
|
152
|
+
detail: 'The TUI front of quox admin users verify-email: the same endpoint, and the server stays the only gate (instance admin, a human caller, a one-shot step-up token, a reason). --reason is required and is recorded in the audit log; --user-id <id> refuses unless the address belongs to that user. You see what will happen and answer y/N, then the private PIN card opens. It always asks for the PIN afresh: a live /authenticate window is not reused or spent. The PIN is never an argument, never shown, saved or logged.' },
|
|
144
153
|
// Skills and rules
|
|
145
154
|
{ id: 'skills', kind: 'command', group: 'Skills', usage: '/skills [name]', desc: 'list your org\'s governed skills, or show one as a card', needs: ['skills'], cap: 'skills', status: 'active',
|
|
146
155
|
detail: 'Lists your org skills (reusable procedures an agent follows; not Crystals, which are deterministic replays) with edition and one-line description. /skills <name> shows its card: purpose, version and review state, tests (results only when run-tests recorded them), the agents it is for, where it came from, recorded use, and how to run it.' },
|
|
147
156
|
{ id: 'skill', kind: 'command', group: 'Skills', usage: '/skill <name> [args]', desc: 'ask Quox to run a skill (governed: approvals still apply)', server: true, cap: 'skills', status: 'active',
|
|
148
157
|
detail: 'Sends "Run the skill <name> with: <args>" as a normal turn, so CommanderQ runs it through the usual governed path. Nothing is executed from here, and approval cards work as always.' },
|
|
158
|
+
{ id: 'callmeforapprovals', kind: 'command', new: true, group: 'Approvals', usage: '/callmeforapprovals [on|off|status]', desc: 'phone me when a high-risk approval sits unanswered: status, on or off (human org admin only)', needs: ['callMe'], status: 'active', aliases: ['callme'],
|
|
159
|
+
detail: 'The same call as quox approvals call-me. Alone it shows whether the phone-me switch is on, how long an approval waits before the call, quiet hours, the minimum risk tier, and whether the watcher on the server is actually alive (or says it cannot tell). on or off flips only the switch (add --after <minutes> or --quiet 22:00-08:00 to tune it); every other setting is kept. The call only reads you one approval and asks: approve needs a keypad PIN, nothing is ever approved for you. Org admins only; an agent login is refused.' },
|
|
149
160
|
{ id: 'plugins', kind: 'command', group: 'Skills', usage: '/plugins [name]', desc: 'plugins (extensions, add-ons) your org has installed: enabled state and version; /plugins <name> shows one', needs: ['pluginList'], status: 'active', aliases: ['plugin', 'extensions', 'extension', 'addons', 'apps'],
|
|
150
161
|
detail: 'Read-only. Lists the plugins installed for the active org (the same call as quox plugin list) with enabled or disabled and the version. /plugins <name> shows one plugin (as quox plugin get). Also answers to /extensions, /extension, /addons and /apps; "/extension list" works too.' },
|
|
151
162
|
{ id: 'todo', kind: 'command', group: 'Awareness', usage: '/todo <text>', desc: 'add an Action Tracker item; /todo move do2 done moves one; /todo alone lists', needs: ['todoAdd', 'todoList', 'todoMove'], status: 'active',
|
|
@@ -219,6 +230,10 @@ export const REGISTRY = [
|
|
|
219
230
|
detail: '/feedback <text> shows exactly what will be sent (type feedback, your text, and the quox CLI version; no name, email, org or token) to https://api.quox.ai/api/feedback, the Quox team rather than your own Quox server, and asks y/N. y sends it; anything else leaves it unsent. The Quox site takes 5 a hour per address. /gripe is different: it files a GitHub issue through your org.' },
|
|
220
231
|
{ id: 'gripe', kind: 'command', group: 'Conversation', usage: '/gripe <text>', desc: 'file a GitHub issue through the GRIPE agent; shows it first, /gripe! files at once', needs: ['gripe'], status: 'active', aliases: ['gripe!'],
|
|
221
232
|
detail: 'The same governed path as `quox gripe`: the GRIPE agent files the issue through your org\'s GitHub connector (default repo quoxai/quox). /gripe <text> shows what would be filed and asks; /gripe! (alone, within 2 minutes) files it, or /gripe! <text> files straight away. The issue link is shown when one comes back; with no link it says so and treats it as not filed.' },
|
|
233
|
+
{ id: 'gripes', kind: 'command', group: 'Conversation', usage: '/gripes', desc: 'gripes waiting to be filed and recently filed, with issue links', status: 'active',
|
|
234
|
+
detail: 'When Quox does not answer (timeout, network, server error) /gripe! saves the gripe on this machine, in ~/.quox/gripe-queue.json, and files it when Quox answers again: on start, then at most every 2 minutes, giving up after 24 hours with a visible line. Each gripe carries a key so a filing that actually succeeded before the timeout is never filed twice. /gripes lists what is waiting and what was filed, with the issue links.' },
|
|
235
|
+
{ id: 'outbox', kind: 'command', group: 'Conversation', usage: '/outbox [drop N|send]', desc: 'messages saved while QuoxCORE did not answer; sent when it does', needs: ['slotSay'], status: 'active',
|
|
236
|
+
detail: 'When a /say or @slot message cannot be sent because QuoxCORE is not answering (connection failed, refused, timeout, server error), it is kept on this machine in ~/.quox/slot-outbox.json (mode 0600, up to 50) and sent in order when QuoxCORE answers again, with a line per message. A refusal (permission, no such slot) is never queued: the error shows and your text is back in the prompt. /outbox lists them, /outbox drop N removes one, /outbox send retries now. A message whose window closed mid-send is marked as possibly delivered and is only retried by /outbox send, so nothing is sent twice by surprise.' },
|
|
222
237
|
{ id: 'sessions', kind: 'command', group: 'Conversation', usage: '/sessions [all|refs]', desc: 'everything live in your org by name and where it runs; refs adds ids, all adds every chat', needs: ['sessionsIndex'], status: 'active',
|
|
223
238
|
detail: 'Each row leads with the session name and where it runs (host, or "host unknown" when the server does not know), then how long it has been up and when it was last active. Sections: terminal sessions, slots, work (objectives, loops, workflows) and chats. /sessions refs adds ids and attach commands under each row; /sessions all includes every chat from the last 24 h. The same data as `quox sessions`. Names set with /rename are kept on this machine (~/.config/quox/sessions.json).' },
|
|
224
239
|
{ id: 'active', kind: 'command', new: true, group: 'Conversation', usage: '/active [sessions|slots|agents]', desc: 'who is doing what: one line per active worker, grouped working / waiting on you / idle (same as `quox active`)', needs: ['sessionsIndex'], status: 'active',
|
|
@@ -235,8 +250,8 @@ export const REGISTRY = [
|
|
|
235
250
|
detail: 'Subscribes this window to the slot\'s live stream; the last ~10 messages show first. Holding (/slot) stays separate and exclusive. Post with #<slot> <text> or /say; #<slot> alone makes it the default target (a bare # undoes it). /<slot> join, /<slot> leave and /join #<slot> work too. Your lines are signed with this session\'s name and person, never as a human profile.' },
|
|
236
251
|
{ id: 'leave', kind: 'command', group: 'Conversation', usage: '/leave <slot>', desc: 'leave a slot room you joined (does not release a hold)', needs: ['slotSay'], cap: 'slots', status: 'active', args: ARGS.say },
|
|
237
252
|
{ id: 'rooms', kind: 'command', new: true, group: 'Conversation', usage: '/rooms', desc: 'rooms (groups) this window has joined, with unread counts; also on the status line', needs: ['slotSay'], cap: 'slots', status: 'active', aliases: ['groups'] },
|
|
238
|
-
{ id: 'broadcast', kind: 'command', new: true, group: 'Conversation', usage: '/broadcast [--slots a,b] [--priority urgent] [--ack] <text>', desc: 'tell every held slot in this org at once, signed as you', needs: ['slotBroadcast'], cap: 'slots', status: 'active', aliases: ['roar'], args: ARGS.broadcast,
|
|
239
|
-
detail: 'One server call delivers to every slot held in this org (never another org), or only --slots a,b. --priority urgent marks it red for recipients. --ack asks them to reply /ack <id>; you then see "Broadcast delivery: delivered N / acknowledged M / awaiting K" update. You get a framed card: how many sessions have it (stored in the slot, listening sessions see it at once) and a separate chat-rooms count (sent / sending / failed). /broadcast status <id> redraws it. Typing it is your consent. Not yet: --at (schedule), --fleet, --org, --voice.' },
|
|
253
|
+
{ id: 'broadcast', kind: 'command', new: true, group: 'Conversation', usage: '/broadcast [--slots a,b] [--priority urgent] [--ack] [--active [--dry-run]] <text>', desc: 'tell every held slot in this org at once, signed as you', needs: ['slotBroadcast'], cap: 'slots', status: 'active', aliases: ['roar', 'broadcast-active'], args: ARGS.broadcast,
|
|
254
|
+
detail: 'One server call delivers to every slot held in this org (never another org), or only --slots a,b. --priority urgent marks it red for recipients. --ack asks them to reply /ack <id>; you then see "Broadcast delivery: delivered N / acknowledged M / awaiting K" update. You get a framed card: how many sessions have it (stored in the slot, listening sessions see it at once) and a separate chat-rooms count (sent / sending / failed). /broadcast status <id> redraws it. Typing it is your consent. --active (or /broadcast-active) reaches only sessions listening right now (a live slot wait); dormant slots are skipped and not woken, and the card says how many. --dry-run (with --active) lists who would be reached and who is skipped, and sends nothing. Not yet: --at (schedule), --fleet, --org, --voice.' },
|
|
240
255
|
{ id: 'page', kind: 'command', new: true, group: 'Conversation', usage: '/page <slot> <text>', desc: 'urgent message to one slot, acknowledgement requested', needs: ['slotBroadcast'], cap: 'slots', status: 'active', args: ARGS.say,
|
|
241
256
|
detail: 'Like /broadcast to a single slot with --priority urgent --ack: it shows red there, asks them to /ack, and you see when they have.' },
|
|
242
257
|
{ id: 'ack', kind: 'command', new: true, group: 'Conversation', usage: '/ack <broadcast id>', desc: 'acknowledge a broadcast, as the slot this window holds', needs: ['slotAck'], cap: 'slots', status: 'active',
|
|
@@ -323,6 +338,9 @@ export const REGISTRY = [
|
|
|
323
338
|
{ id: 'poke', kind: 'command', new: true, group: 'Conversation', usage: '/poke <slot or session> [text]', desc: 'wake a session: an urgent page asking it to acknowledge (also /wake, /nudge, /ping)', needs: ['slotBroadcast'], cap: 'slots', status: 'active', aliases: ['wake', 'nudge', 'ping', 'slap', 'awaken'] },
|
|
324
339
|
{ id: 'afk', kind: 'command', new: true, group: 'Context', usage: '/afk [note]', desc: 'tell sessions you are away (they stop waiting on approvals, which still queue); /back then shows everything since, grouped', status: 'active',
|
|
325
340
|
detail: 'Sets your presence on the server (per user and org): sessions saying into hitl, triage-q or this window\'s slot see "owner away since 15:54 UTC (note): approvals will queue"; `quox presence`, `quox slot list`, quox ask and agents see it too. Nothing is approved while you are away. /back or any typed message marks you present.' },
|
|
341
|
+
{ id: 'donotdisturb', kind: 'command', new: true, group: 'Context', usage: '/donotdisturb [on|off] [until HH:MM]', desc: 'sleep means sleep: collect arrivals quietly (no notices, cards, bells) until you ask for /morningreport; also /dnd', status: 'active', aliases: ['dnd'],
|
|
342
|
+
detail: 'On sets your presence to sleeping with the note "do not disturb" (sessions see it) and the window only counts what arrives: "🌙 do not disturb · N waiting · /morningreport". Bare /dnd toggles. /afk and /sleep imply it. Saying you are going to sleep (chat or /broadcast) offers it, y/N. A restarted window restores it from the server. /back or /dnd off ends it.' },
|
|
343
|
+
{ id: 'morningreport', kind: 'command', new: true, group: 'Context', usage: '/morningreport', desc: 'everything collected while do not disturb was on: incidents first, approvals, inbox (xN), slot messages, sessions that need you; then offers to turn it off', status: 'active' },
|
|
326
344
|
{ id: 'back', kind: 'command', new: true, group: 'Context', usage: '/back', desc: 'mark yourself present and show what happened since /afk (same as /away)', status: 'active' },
|
|
327
345
|
{ id: 'huh', kind: 'command', new: true, group: 'Conversation', usage: '/huh [question]', desc: 'explain the last output in plain words, using one turn on your model (same as /dragon explain)', status: 'active' },
|
|
328
346
|
{ id: 'sleep', kind: 'command', new: true, group: 'Conversation', usage: '/sleep', desc: 'tell other sessions you are away sleeping so they stop waiting on your approvals (they still queue), then the dragon curls up (same as /dragon sleep)', status: 'active' },
|
|
@@ -371,8 +389,8 @@ export const REGISTRY = [
|
|
|
371
389
|
'profile does the same for one --print run.' },
|
|
372
390
|
{ id: 'deck', ext: 'quoxdeck', kind: 'command', group: 'Context', usage: '/deck on|off|status', desc: 'QuoxDeck: an optional MIDI pad surface; pads open an approval or preselect an answer, you confirm here', status: 'active',
|
|
373
391
|
detail: 'Provided by the optional quoxdeck extension, not by core: npm i -g quoxdeck, then quox ext add quoxdeck (until then /deck says so). quoxon pushes approvals, health and loops to the deck at most once a second. A pad only opens an approval view or fills the composer with the answer; it never answers for you, and high risk still needs the step-up PIN. The setting is saved, so the deck comes back on at the next start.' },
|
|
374
|
-
{ id: 'ext', kind: 'command', group: 'Context', usage: '/ext list|on|off <name>', desc: 'optional extensions (npm packages) that add surfaces to this window', status: 'active',
|
|
375
|
-
detail: 'Extensions load only when installed (npm i -g <pkg>) and listed (quox ext add <pkg>). /ext list shows each one and where it was found, on|off switches one for the next start and now. An extension can open an approval or preselect an answer; it cannot answer for you. Contract: cli/lib/quoxon/EXTENSIONS.md.' },
|
|
392
|
+
{ id: 'ext', kind: 'command', group: 'Context', usage: '/ext list|on|off|tab|set|settings <name>', desc: 'optional extensions (npm packages) that add surfaces to this window', status: 'active', args: ARGS.ext,
|
|
393
|
+
detail: 'Extensions load only when installed (npm i -g <pkg>) and listed (quox ext add <pkg>). /ext list shows each one and where it was found, on|off switches one for the next start and now. An extension can open an approval or preselect an answer; it cannot answer for you. /ext tab <name> (or Ctrl+]) focuses an extension panel and Esc returns to the composer; /ext set <name> <key> <value> and /ext settings <name> change and show its settings. Contract: cli/lib/quoxon/EXTENSIONS.md.' },
|
|
376
394
|
{ id: 'row', kind: 'command', group: 'Context', usage: '/row [new|add|list|edit <id>|rm <id>|test <id>|reload|on|off]', desc: 'your own live lines above the status bar: stats from a URL, your Quox, a plugin or a command', status: 'active', args: ARGS.row,
|
|
377
395
|
detail:
|
|
378
396
|
'/row new walks you through one row with y/N cards and shows what it will say before saving; /row edit <id> changes one. ' +
|
|
@@ -405,6 +423,18 @@ for (const e of VENDOR_ENTRIES) {
|
|
|
405
423
|
// The palette row's one-line wording (commandShort.js); /help keeps the long desc.
|
|
406
424
|
for (const e of REGISTRY) if (e.kind === 'command' && !e.short && SHORT[e.id]) e.short = SHORT[e.id];
|
|
407
425
|
|
|
426
|
+
/** Commands of loaded extensions join the registry (help, palette, Tab) while loaded: [{ name, help, ext, subcommands }]. Static ids win. */
|
|
427
|
+
export function setExtEntries(cmds) {
|
|
428
|
+
for (let i = REGISTRY.length - 1; i >= 0; i--) if (REGISTRY[i].dyn) REGISTRY.splice(i, 1);
|
|
429
|
+
for (const c of cmds || []) {
|
|
430
|
+
const id = String(c.name || '').replace(/^\//, '');
|
|
431
|
+
if (!id || REGISTRY.some((e) => e.id === id)) continue;
|
|
432
|
+
const subs = c.subcommands || [];
|
|
433
|
+
REGISTRY.push({ id, dyn: true, ext: c.ext, kind: 'command', group: 'Context', usage: subs.length ? `/${id} ${subs.map((s) => s.name).join('|')}` : `/${id}`, desc: c.help || `from the ${c.ext} extension`, status: 'active',
|
|
434
|
+
...(subs.length ? { args: [{ after: [], choices: subs.map((s) => ({ value: s.name, desc: s.help || '' })) }] } : {}) });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
408
438
|
const norm = (s) => String(s || '').trim().toLowerCase().replace(/^\//, '');
|
|
409
439
|
|
|
410
440
|
/** Dev-only entries (/observe, /devnote) are invisible unless a dev observer is configured. */
|
|
@@ -500,7 +530,7 @@ export function suggestCommands(input, max = 3) {
|
|
|
500
530
|
if (!q) return [];
|
|
501
531
|
const names = [...new Set(commandNames())];
|
|
502
532
|
const lev = (a, b) => { const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]); for (let j = 1; j <= b.length; j++) dp[0][j] = j; for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) 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)); return dp[a.length][b.length]; };
|
|
503
|
-
return names.map(n => { const k = n.slice(1); const
|
|
533
|
+
return names.map(n => { const k = n.slice(1); const sep = k.length >= 2 && q.length > k.length && q.startsWith(k) && /[-_ :]/.test(q[k.length]); const rel = sep || k.startsWith(q) || (k.length >= 2 && q.includes(k) && q.length - k.length <= 2) || (q.length >= 2 && k.includes(q)); const head = q.length >= 3 && k.length > q.length ? lev(q, k.slice(0, q.length)) + 0.5 : Infinity; return { n, d: sep ? -1 : rel ? 0 : Math.min(lev(q, k), head), len: Math.abs(k.length - q.length) }; })
|
|
504
534
|
.filter(x => x.d <= Math.max(2, Math.floor(q.length / 3)))
|
|
505
535
|
.sort((a, b) => a.d - b.d || a.len - b.len || a.n.localeCompare(b.n)).slice(0, max).map(x => x.n);
|
|
506
536
|
}
|
package/lib/quoxon/connect.js
CHANGED
|
@@ -125,7 +125,7 @@ export const pickRow = (rows, type) => {
|
|
|
125
125
|
* `caps` is the server's capabilities answer, the source of truth for whether each provider is usable
|
|
126
126
|
* (providers.js, shared with /model and /doctor). Anything neither can answer is "? unknown".
|
|
127
127
|
*/
|
|
128
|
-
export function connectionLines(res, caps = null) {
|
|
128
|
+
export function connectionLines(res, caps = null, { includeOthers = true } = {}) {
|
|
129
129
|
const STATUS_PROVIDERS = [PROVIDERS.openai, PROVIDERS.anthropic, PROVIDERS.ollama, PROVIDERS.openrouter, PROVIDERS.huggingface, PROVIDERS.groq, PROVIDERS.together, PROVIDERS.lmstudio, PROVIDERS.vllm, PROVIDERS.custom];
|
|
130
130
|
const out = [];
|
|
131
131
|
const ok = res && res.ok && Array.isArray(res.rows);
|
|
@@ -136,7 +136,7 @@ export function connectionLines(res, caps = null) {
|
|
|
136
136
|
const st = providerState({ cap: p.id, vault: p.type }, caps, res);
|
|
137
137
|
const def = st.row;
|
|
138
138
|
if (st.state === 'unknown') out.push([`? ${p.name} unknown (vault not readable)`, 'warn']);
|
|
139
|
-
else if (st.state === 'connected' && def) out.push([`● ${p.name} connected · ${def.label || def.id} · ${testTag(def)}`, def.test_status === 'failed' ? 'warn' : 'ok']);
|
|
139
|
+
else if (st.state === 'connected' && def) out.push([`● ${p.name} connected · ${def.label || def.id} · ${testTag(def)}${def.test_status === 'failed' ? ` · re-test: quox provider test ${p.id}` : ''}`, def.test_status === 'failed' ? 'warn' : 'ok']);
|
|
140
140
|
else if (st.state === 'connected') out.push([`● ${p.name} ready on this server (an instance-wide key, not in your org vault)`, 'ok']);
|
|
141
141
|
else if (st.state === 'stored') out.push([`◐ ${p.name} stored but not the default · quox vault set-default ${st.row.id}`, 'warn']);
|
|
142
142
|
else if (st.state === 'unavailable') out.push([`○ ${p.name} not available here${st.reason ? `: ${st.reason}` : ''}`]);
|
|
@@ -155,7 +155,9 @@ export function connectionLines(res, caps = null) {
|
|
|
155
155
|
if (!cur || (r.is_default && !cur.is_default)) others.set(r.integration_type, r);
|
|
156
156
|
}
|
|
157
157
|
const list = [...others.values()];
|
|
158
|
-
if (
|
|
158
|
+
if (!includeOthers) {
|
|
159
|
+
if (list.length) out.push([` ${list.length} other integration${list.length === 1 ? '' : 's'} in your vault: quox provider list --all`]);
|
|
160
|
+
} else if (list.length) {
|
|
159
161
|
out.push(['Integrations in your vault:']);
|
|
160
162
|
for (const r of list.slice(0, 12)) out.push([`● ${r.integration_type} ${r.label || r.id} · ${testTag(r)}`, r.test_status === 'failed' ? 'warn' : undefined]);
|
|
161
163
|
if (list.length > 12) out.push([` +${list.length - 12} more · quox vault list`]);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LAZY PATH (QUOXON, 2026-10-29; plan first-run-onboarding-plan.md section 4): /deck on a machine that lacks the
|
|
3
|
+
* quoxdeck extension, or the ALSA headers its native midi dependency builds against, OFFERS one y/N instead of a raw
|
|
4
|
+
* build error. Pure planning plus an injected runner: nothing here touches the machine until the user says y, and
|
|
5
|
+
* sudo is the user's own (the exact command is shown first; no password is ever read, stored or passed).
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
export const DECK_PKG = 'quoxdeck';
|
|
10
|
+
export const ALSA_PKG = 'libasound2-dev';
|
|
11
|
+
const SYSTEM = { apt: { cmd: `sudo apt-get install -y ${ALSA_PKG}`, pkg: ALSA_PKG }, dnf: { cmd: 'sudo dnf install -y alsa-lib-devel', pkg: 'alsa-lib-devel' } };
|
|
12
|
+
const NPM_CMD = `npm i -g ${DECK_PKG}`;
|
|
13
|
+
const ADD_CMD = `quox ext add ${DECK_PKG}`;
|
|
14
|
+
|
|
15
|
+
const quick = (file, args) => { try { return spawnSync(file, args, { stdio: 'ignore', timeout: 3000 }); } catch { return { status: 1 }; } };
|
|
16
|
+
/** Cheap ALSA header check: pkg-config, else the header path. */
|
|
17
|
+
export async function defaultHasAlsa() {
|
|
18
|
+
if (quick('pkg-config', ['--exists', 'alsa']).status === 0) return true;
|
|
19
|
+
return ['/usr/include/alsa/asoundlib.h'].some((p) => quick('test', ['-f', p]).status === 0);
|
|
20
|
+
}
|
|
21
|
+
/** apt or dnf only; anything else is null (we never guess further). */
|
|
22
|
+
export async function defaultPackageManager() {
|
|
23
|
+
for (const pm of ['apt-get', 'dnf']) if (quick('sh', ['-c', `command -v ${pm}`]).status === 0) return pm === 'dnf' ? 'dnf' : 'apt';
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* -> null (nothing to offer) | { text } (no automatic path: say it, show manual commands)
|
|
29
|
+
* | { question, steps:[{label, cmd, interactive?}], manual:[cmd] }
|
|
30
|
+
*/
|
|
31
|
+
export async function deckOffer({ platform = process.platform, isInstalled, hasAlsa = defaultHasAlsa, packageManager = defaultPackageManager } = {}) {
|
|
32
|
+
const installed = Boolean(await isInstalled());
|
|
33
|
+
const alsaMissing = platform === 'linux' && !(await hasAlsa());
|
|
34
|
+
if (installed && !alsaMissing) return null;
|
|
35
|
+
const pm = alsaMissing ? await packageManager() : null;
|
|
36
|
+
if (alsaMissing && !pm) {
|
|
37
|
+
return { steps: null, text: `quoxdeck needs the ALSA development headers (${ALSA_PKG} on Debian, alsa-lib-devel on Fedora). This machine has neither apt or dnf, so install them with your own package manager, then run: ${NPM_CMD} && ${ADD_CMD}` };
|
|
38
|
+
}
|
|
39
|
+
const steps = [];
|
|
40
|
+
if (alsaMissing) steps.push({ label: `system package ${SYSTEM[pm].pkg}`, cmd: SYSTEM[pm].cmd, interactive: true });
|
|
41
|
+
steps.push({ label: installed ? 'rebuild the quoxdeck extension' : 'install the quoxdeck extension', cmd: NPM_CMD });
|
|
42
|
+
steps.push({ label: 'list it in quoxon', cmd: ADD_CMD });
|
|
43
|
+
const need = [...(alsaMissing ? [`system package ${SYSTEM[pm].pkg}`] : []), 'the quoxdeck extension'];
|
|
44
|
+
return { steps, manual: steps.map((s) => s.cmd), question: `quoxdeck needs: ${need.join(', then ')}. Install now?` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const declineText = (o) => `Nothing to turn off. When you want it: ${o.manual.join(' && ')}`;
|
|
48
|
+
|
|
49
|
+
/** Runs the steps in order via the injected run(step); stops at the first failure; re-runs /deck only on full success. */
|
|
50
|
+
export async function runDeckOffer(o, { run, say = () => {}, rerun = async () => {} }) {
|
|
51
|
+
for (const s of o.steps) {
|
|
52
|
+
say(`Running: ${s.cmd}`);
|
|
53
|
+
let r; try { r = await run(s); } catch (e) { r = { code: 1, error: e?.message }; }
|
|
54
|
+
if (r?.code !== 0) { say(`Stopped at: ${s.cmd} (${r?.error || `exit ${r?.code ?? 'unknown'}`}). Nothing after it ran. To finish by hand: ${o.manual.slice(o.steps.indexOf(s)).join(' && ')}`, 'warn'); return { ok: false, failed: s.cmd }; }
|
|
55
|
+
}
|
|
56
|
+
await rerun();
|
|
57
|
+
return { ok: true };
|
|
58
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// A DESTRUCTIVE approval (owner 2026-10-27 16:15 UTC, after two delete_workflow cards were approved by one 'y' each)
|
|
2
|
+
// needs a stronger confirm: the human types the object's name. One place decides what is destructive.
|
|
3
|
+
import { describeInboxItem } from './events.js';
|
|
4
|
+
|
|
5
|
+
/** Tools named delete_*, remove_*, purge_*, destroy_*, drop_* or wipe_* (the prefix only; "undelete_x" is not one). */
|
|
6
|
+
export const DESTRUCTIVE_TOOL = /^(delete|remove|purge|destroy|drop|wipe)_/i;
|
|
7
|
+
export const isDestructiveTool = (tool) => DESTRUCTIVE_TOOL.test(String(tool ?? '').trim());
|
|
8
|
+
|
|
9
|
+
const flagged = (o) => Boolean(o && (o.destructive === true || o.irreversible === true));
|
|
10
|
+
|
|
11
|
+
/** True when the server marks the item destructive or irreversible, else when its tool name follows the rule. */
|
|
12
|
+
export function isDestructive(it) {
|
|
13
|
+
if (!it) return false;
|
|
14
|
+
const p = it.payload || {}; const c = p.approval_context || {};
|
|
15
|
+
if (flagged(it) || flagged(p) || flagged(c) || flagged(c.method) || flagged(p.what)) return true;
|
|
16
|
+
return isDestructiveTool(describeInboxItem(it).tool);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : typeof v === 'number' ? String(v) : null);
|
|
20
|
+
const argsOf = (it) => {
|
|
21
|
+
const p = it?.payload || {}; const c = p.approval_context || {}; const m = c.method || {};
|
|
22
|
+
const a = m.args ?? m.input ?? c.args ?? c.input ?? p.args ?? p.input ?? p.action_params ?? null;
|
|
23
|
+
if (a && typeof a === 'object' && !Array.isArray(a)) return a;
|
|
24
|
+
if (typeof a === 'string') { try { const j = JSON.parse(a); if (j && typeof j === 'object' && !Array.isArray(j)) return j; } catch { /* not JSON */ } }
|
|
25
|
+
return {};
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** What gets deleted: { label, name }. The human name when the input has one (name, workflow_name, host_name...), else the id from the input, else the short item id. name is what the human must type. */
|
|
29
|
+
export function destructiveObject(it) {
|
|
30
|
+
const a = argsOf(it);
|
|
31
|
+
const nameKey = Object.keys(a).find(k => /(^|_)name$/i.test(k) && str(a[k]));
|
|
32
|
+
const idKey = Object.keys(a).find(k => /(^|_)id$/i.test(k) && str(a[k]));
|
|
33
|
+
const v = (nameKey && str(a[nameKey])) || (idKey && str(a[idKey])) || String(it?.id ?? 'item').slice(0, 8);
|
|
34
|
+
return { label: v, name: v };
|
|
35
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DO-NOT-DISTURB (owner 2026-10-28: "sleep means sleep"). Pure helpers for /donotdisturb (/dnd) and /morningreport.
|
|
3
|
+
* While DND is on the window collects instead of announcing; the app holds the collected arrivals and these functions
|
|
4
|
+
* turn them into the morning digest. No I/O here.
|
|
5
|
+
*/
|
|
6
|
+
export const DND_NOTE = 'do not disturb';
|
|
7
|
+
export const DND_USAGE = 'Usage: /donotdisturb [on|off] [until HH:MM] (alias /dnd; bare turns it on)';
|
|
8
|
+
|
|
9
|
+
/** `/dnd [on|off] [until HH:MM]` -> { ok, op: 'on'|'off'|'toggle', until: {h,m}|null } | { ok:false, error }. */
|
|
10
|
+
export function parseDndArgs(arg) {
|
|
11
|
+
const a = String(arg || '').trim().toLowerCase();
|
|
12
|
+
if (!a) return { ok: true, op: 'toggle', until: null };
|
|
13
|
+
const m = /^(?:(on|off)\s*)?(?:until\s+(\d{1,2}):(\d{2}))?$/.exec(a);
|
|
14
|
+
if (!m || (!m[1] && m[2] === undefined)) return { ok: false, error: DND_USAGE };
|
|
15
|
+
let until = null;
|
|
16
|
+
if (m[2] !== undefined) {
|
|
17
|
+
const h = Number(m[2]), mi = Number(m[3]);
|
|
18
|
+
if (h > 23 || mi > 59) return { ok: false, error: DND_USAGE };
|
|
19
|
+
if (m[1] === 'off') return { ok: false, error: DND_USAGE };
|
|
20
|
+
until = { h, m: mi };
|
|
21
|
+
}
|
|
22
|
+
return { ok: true, op: m[1] || 'on', until };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The next local HH:MM after `now` as epoch ms. */
|
|
26
|
+
export function untilEpoch({ h, m }, now = Date.now()) {
|
|
27
|
+
const d = new Date(now); d.setHours(h, m, 0, 0);
|
|
28
|
+
if (d.getTime() <= now) d.setDate(d.getDate() + 1);
|
|
29
|
+
return d.getTime();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Text that says the owner is heading off: going to sleep / off to bed / signing off for the night / night all. */
|
|
33
|
+
const SLEEP_RE = /\b(?:i['’]?m|i am|im|we['’]?re|owner(?: is)?|adam(?: is)?)?\s*(?:going to (?:sleep|bed)|off to (?:bed|sleep)|heading to (?:bed|sleep)|going offline|signing off(?: for (?:the )?(?:night|today|now))?|logging off for the night|turning in(?: for the night)?)\b|\bnight[,!. ]+(?:all|everyone|team|folks)\b|\bgood ?night (?:all|everyone|team|folks)\b/i;
|
|
34
|
+
export const sleepIntent = (text) => SLEEP_RE.test(String(text || ''));
|
|
35
|
+
|
|
36
|
+
/** `/afk <text>` whose text says sleep/bed/night: the owner means /sleep. */
|
|
37
|
+
export const afkMeansSleep = (text) => /\b(?:sleep|sleeping|asleep|bed|bedtime|night|goodnight)\b/i.test(String(text || '')) || /\bgood night\b/i.test(String(text || ''));
|
|
38
|
+
|
|
39
|
+
export const newCollected = () => ({ inbox: [], slots: [], since: Date.now() });
|
|
40
|
+
|
|
41
|
+
const titleOf = (it) => String(it.title || it.kind || 'untitled');
|
|
42
|
+
const INCIDENT_RE = /incident|outage|critical|sev[ -]?[01]|down\b|breach|alert|failed/i;
|
|
43
|
+
export const isIncident = (it) => INCIDENT_RE.test(`${it.kind || ''} ${it.severity || ''} ${it.priority || ''} ${titleOf(it)}`);
|
|
44
|
+
|
|
45
|
+
/** Collapse by title with a count: [{ title, n }] in first-seen order. */
|
|
46
|
+
export function collapseByTitle(items) {
|
|
47
|
+
const m = new Map();
|
|
48
|
+
for (const it of items) { const k = titleOf(it); m.set(k, (m.get(k) || 0) + 1); }
|
|
49
|
+
return [...m].map(([title, n]) => ({ title, n }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** How many things wait for the owner: approvals + collected inbox arrivals + collected slot messages. */
|
|
53
|
+
export const waitingCount = (c, approvals = 0) => approvals + (c?.inbox?.length || 0) + (c?.slots?.length || 0);
|
|
54
|
+
|
|
55
|
+
/** The digest as [text, tone] lines. Incidents first, then approvals, inbox (xN), slot messages, sessions that need you. */
|
|
56
|
+
export function morningLines(c, { approvals = 0, sessions = [] } = {}) {
|
|
57
|
+
const lines = [];
|
|
58
|
+
const inc = c.inbox.filter(isIncident), rest = c.inbox.filter((i) => !isIncident(i));
|
|
59
|
+
const group = (head, items, tone) => { if (items.length) { lines.push([head, tone]); for (const g of collapseByTitle(items)) lines.push([` ${g.title}${g.n > 1 ? ` (×${g.n})` : ''}`, 'plain']); } };
|
|
60
|
+
group(`Incidents (${inc.length})`, inc, 'warn');
|
|
61
|
+
if (approvals) lines.push([`Approvals waiting (${approvals}): /approvals`, 'warn']);
|
|
62
|
+
group(`Inbox (${rest.length})`, rest, 'plain');
|
|
63
|
+
if (c.slots.length) {
|
|
64
|
+
lines.push([`Slot messages (${c.slots.length})`, 'plain']);
|
|
65
|
+
for (const s of c.slots.slice(0, 10)) lines.push([` ${s.from}: ${String(s.text).replace(/\s+/g, ' ').slice(0, 90)}`, 'plain']);
|
|
66
|
+
if (c.slots.length > 10) lines.push([` ... ${c.slots.length - 10} more`, 'dim']);
|
|
67
|
+
}
|
|
68
|
+
if (sessions.length) { lines.push([`Sessions that need you (${sessions.length})`, 'warn']); for (const s of sessions.slice(0, 8)) lines.push([` ${s}`, 'plain']); }
|
|
69
|
+
if (!lines.length) lines.push(['Nothing collected while you were away.', 'ok']);
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Dragon companion for Quoxon (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher). The controller owns attach state and feeds REAL stream
|
|
2
|
+
* Dragon companion for Quoxon (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus). The controller owns attach state and feeds REAL stream
|
|
3
3
|
* events to dragonlab's pure view (integration/companions-v1/view.mjs: the same reducer and renderer the CLI
|
|
4
4
|
* uses). No art, state logic or timers are duplicated here; this file only decides, translates and lays out.
|
|
5
5
|
* Decisions: docs/dragons/COMPANION-UX.md. The reducer never overwrites pending approvals or failures, but the host only SHOWS the dragon during a dwell (attach, key moment, invoke).
|
|
@@ -13,7 +13,7 @@ import { useFrame } from './motion/frame.js';
|
|
|
13
13
|
import { loadCompanionView } from '../dragon/companionView.js';
|
|
14
14
|
import { ART_MIN_COLS as SLOT_MIN_COLS, ART_MIN_ROWS as SLOT_MIN_ROWS } from '../dragon/frames.js';
|
|
15
15
|
import { companionPlan, motionPolicy, normaliseMode, COMPANION_MODES, VENDORED_DRAGONLAB, dragonForAgent, companionMode } from '../dragon/select.js';
|
|
16
|
-
import { createMomentEngine, createRosterWatch, LONG_TASK_MS, SUMMON_COOLDOWN_MS } from './dragonMoments.js';
|
|
16
|
+
import { createMomentEngine, createRosterWatch, createSenderWatch, LONG_TASK_MS, SUMMON_COOLDOWN_MS } from './dragonMoments.js';
|
|
17
17
|
import { moodForEvent } from './motion/level.js';
|
|
18
18
|
|
|
19
19
|
const h = React.createElement;
|
|
@@ -50,8 +50,8 @@ export function toCompanionEvent(action) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
export function createCompanionController({ settings = () => ({}), env = process.env, isTTY = true, exists = existsSync, now = Date.now, importer = null, makeView = loadCompanionView, jobs = null, typing = () => false } = {}) {
|
|
53
|
-
let noticed = false, showUntil = 0, shownAt = 0, unpainted = false, waitingFrame = false, trigger = 'attach', forced = false, timer = null, job = null;
|
|
54
|
-
let summoned = null, summonCaption = null; const lastSummon = new Map(); const rosterWatch = createRosterWatch(); // a domain moment's dragon (not the attached one) and its truthful caption
|
|
53
|
+
let noticed = false, narrowNoticed = false, showUntil = 0, shownAt = 0, unpainted = false, waitingFrame = false, trigger = 'attach', forced = false, timer = null, job = null;
|
|
54
|
+
let summoned = null, summonCaption = null; const lastSummon = new Map(); const rosterWatch = createRosterWatch(); const senderWatch = createSenderWatch({ now }); // a domain moment's dragon (not the attached one) and its truthful caption
|
|
55
55
|
const moments = createMomentEngine({ now });
|
|
56
56
|
let agent = null, plan = { enabled: false, why: 'no agent attached' }, view = null, state = null, token = 0, greet = true;
|
|
57
57
|
const listeners = new Set();
|
|
@@ -141,15 +141,35 @@ export function createCompanionController({ settings = () => ({}), env = process
|
|
|
141
141
|
* screen, and not the same dragon again inside SUMMON_COOLDOWN_MS. Works with nothing attached. The caption is the moment's own
|
|
142
142
|
* truthful line. -> true when it showed.
|
|
143
143
|
*/
|
|
144
|
-
|
|
144
|
+
canSummon(dragon) {
|
|
145
145
|
if (companionMode(settings(), env) !== 'on' || typing() || live()) return false;
|
|
146
146
|
const last = lastSummon.get(dragon);
|
|
147
|
-
|
|
147
|
+
return !(last != null && now() - last < SUMMON_COOLDOWN_MS);
|
|
148
|
+
},
|
|
149
|
+
async summon(dragon, caption) {
|
|
150
|
+
if (!this.canSummon(dragon)) return false;
|
|
148
151
|
lastSummon.set(dragon, now());
|
|
149
152
|
if (agent && dragonForAgent(agent) === dragon && view && plan.persist) { summonCaption = caption; appear('summon'); return true; }
|
|
150
153
|
await load({ show: true, as: dragon, caption, persist: true, why: 'summon' });
|
|
151
154
|
return Boolean(view);
|
|
152
155
|
},
|
|
156
|
+
/**
|
|
157
|
+
* A slot message landed in the owner's stream: a fresh line from a session whose agent has a dragon summons that dragon for one
|
|
158
|
+
* dwell (dragonMoments.js slotMessageMoment; deduped, per-sender cooldown). `opts` = { broadcast, run }. -> true when it showed.
|
|
159
|
+
*/
|
|
160
|
+
async slotMessage(m, opts = {}) {
|
|
161
|
+
const mo = senderWatch.observe(m, opts);
|
|
162
|
+
if (!mo || !this.canSummon(mo.dragon)) return false;
|
|
163
|
+
senderWatch.shown(mo.sender);
|
|
164
|
+
return this.summon(mo.dragon, mo.caption);
|
|
165
|
+
},
|
|
166
|
+
/** Another session's approval card newly waiting (state prev -> next), asked by a dragon agent: that dragon appears. */
|
|
167
|
+
async waitingCards(prev, next) {
|
|
168
|
+
const mo = senderWatch.card(prev, next);
|
|
169
|
+
if (!mo || !this.canSummon(mo.dragon)) return false;
|
|
170
|
+
senderWatch.shown(mo.sender);
|
|
171
|
+
return this.summon(mo.dragon, mo.caption);
|
|
172
|
+
},
|
|
153
173
|
/** Roster rows (every refresh) -> the first objective/loop that just finished or failed summons its dragon. */
|
|
154
174
|
watchRoster(rows) { const m = rosterWatch.observe(rows)[0]; return m ? this.summon(m.dragon, m.caption) : Promise.resolve(false); },
|
|
155
175
|
/** True while the dragon is on screen and moving: the host asks the shared clock for frames only then. */
|
|
@@ -182,6 +202,16 @@ export function createCompanionController({ settings = () => ({}), env = process
|
|
|
182
202
|
return `Dragon companion is ${plan.mode} but its art could not be loaded, so no dragon will show (${plan.why}). /dragon companion status`;
|
|
183
203
|
},
|
|
184
204
|
mode: () => plan.mode,
|
|
205
|
+
/**
|
|
206
|
+
* Once per session: a dragon is attached and would show, but this window is too small for the art, so all that appears is a
|
|
207
|
+
* one-line label. Say why in plain words rather than hiding it silently. Otherwise null.
|
|
208
|
+
*/
|
|
209
|
+
narrowNotice({ cols, rows }) {
|
|
210
|
+
if (narrowNoticed || !agent || !dragonForAgent(agent) || !plan.enabled || !view) return null;
|
|
211
|
+
if (cols >= ART_MIN_COLS && rows >= ART_MIN_ROWS) return null;
|
|
212
|
+
narrowNoticed = true;
|
|
213
|
+
return `The dragon needs ${ART_MIN_COLS} columns and ${ART_MIN_ROWS} rows to be drawn; this window is ${cols} by ${rows}, so you only get its caption line. Widen the window to see it.`;
|
|
214
|
+
},
|
|
185
215
|
/** -> null | { kind:'art', frame, caption, name } | { kind:'label', text } */
|
|
186
216
|
snapshot(t, { cols, rows }) {
|
|
187
217
|
const out = this.snapshotNow(t, { cols, rows });
|
|
@@ -219,7 +249,7 @@ export function companionCommand(arg, { settings = {}, plan = null, artProblem =
|
|
|
219
249
|
const mode = cur || plan?.mode || 'on';
|
|
220
250
|
const now = plan?.enabled ? `showing ${plan.id}` : `not showing (${plan?.why || 'no agent attached'})`;
|
|
221
251
|
if (artProblem && mode !== 'off') return { text: `Dragon companion: ${mode}, but the art can't be loaded so no dragon will show (${artProblem}). /dragon companion on|greeting|off.`, tone: 'warn' };
|
|
222
|
-
return { text: `Dragon companion: ${mode}, ${now}. /dragon companion on|greeting|off. Only Lucid, Hyperion, Seer, Sentinel, Drago and
|
|
252
|
+
return { text: `Dragon companion: ${mode}, ${now}. /dragon companion on|greeting|off. Only Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan and Quoxperticus have dragons.`, invoke: true };
|
|
223
253
|
}
|
|
224
254
|
const m = normaliseMode(w);
|
|
225
255
|
if (!m) return { text: `Usage: /dragon companion ${COMPANION_MODES.join('|')}|status`, tone: 'warn' };
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* dragon shows itself. The controller (dragonCompanion.js) runs the engine on every stream fact and owns the routing: while a
|
|
6
6
|
* companion owns the moments the Q dragon stays quiet, so a moment is never two dragons.
|
|
7
7
|
*/
|
|
8
|
+
import { dragonForAgent } from '../dragon/select.js';
|
|
9
|
+
|
|
8
10
|
export const MOMENTS = Object.freeze({
|
|
9
11
|
approval: Object.freeze({ mood: 'waiting', companion: true }),
|
|
10
12
|
failure: Object.freeze({ mood: 'failure', companion: true }),
|
|
@@ -80,3 +82,79 @@ export function createRosterWatch() {
|
|
|
80
82
|
},
|
|
81
83
|
};
|
|
82
84
|
}
|
|
85
|
+
|
|
86
|
+
// ---- sender moments: a named session or agent speaks to the owner, so its own dragon appears (companion mode `on` only) ---------
|
|
87
|
+
/** The same sender cannot call its dragon again for this long once it has shown (a chatty session is one dragon, not a parade). */
|
|
88
|
+
export const SENDER_COOLDOWN_MS = 5 * 60 * 1000;
|
|
89
|
+
/** Only a message this recent is a moment: a backlog replayed on (re)connect is history, not news. */
|
|
90
|
+
export const SLOT_FRESH_MS = 2 * 60 * 1000;
|
|
91
|
+
|
|
92
|
+
const head = (s) => String(s).split(/[\s\-_.:/]+/)[0];
|
|
93
|
+
/** One name -> a dragon id: the whole name, its first word, or that word without trailing digits (quoxperticus-126). */
|
|
94
|
+
function dragonForName(name) {
|
|
95
|
+
const s = String(name ?? '').trim();
|
|
96
|
+
if (!s) return null;
|
|
97
|
+
return dragonForAgent(s) || dragonForAgent(head(s)) || dragonForAgent(head(s).replace(/\d+$/, ''));
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* A slot message sender -> its agent's dragon, or null. `from` is "slot (display)" or a bare name. The speaker (the display name
|
|
101
|
+
* in brackets) wins, then the bare name, then the slot it holds. Only approved dragons (select.js AGENT_DRAGON) can match.
|
|
102
|
+
*/
|
|
103
|
+
export function dragonForSender(m) {
|
|
104
|
+
if (!m) return null;
|
|
105
|
+
const from = String(m.from ?? '').trim();
|
|
106
|
+
const inner = (from.match(/\(([^)]*)\)\s*$/) || [])[1];
|
|
107
|
+
const bare = from.replace(/\s*\(.*$/, '');
|
|
108
|
+
for (const n of [inner, bare, m.from_slot]) { const d = dragonForName(n); if (d) return d; }
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const senderKey = (m) => String(m?.from_slot || m?.from || '').replace(/\s*\(.*$/, '').trim().toLowerCase();
|
|
112
|
+
|
|
113
|
+
/** A fresh slot message from a dragon session -> { dragon, sender, caption } | null. The caption says who and what kind, never the content. */
|
|
114
|
+
export function slotMessageMoment(m, { now = Date.now(), broadcast = false, run = false } = {}) {
|
|
115
|
+
const dragon = dragonForSender(m);
|
|
116
|
+
if (!dragon) return null;
|
|
117
|
+
const t = Date.parse(m.created_at ?? m.createdAt ?? '');
|
|
118
|
+
if (!Number.isFinite(t) || now - t > SLOT_FRESH_MS) return null;
|
|
119
|
+
const who = String(m.from).trim();
|
|
120
|
+
const caption = run ? `${who} asks you to run a command.` : broadcast ? `Broadcast from ${who}.` : `Message from ${who}.`;
|
|
121
|
+
return { dragon, sender: senderKey(m), caption };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Another session's approval card newly waiting, asked by an agent with a dragon -> { dragon, sender, key, caption } | null. */
|
|
125
|
+
export function waitingCardMoment(prev, next) {
|
|
126
|
+
const before = new Set((prev?.waiting || []).map(c => c.inboxItemId));
|
|
127
|
+
for (const c of next?.waiting || []) {
|
|
128
|
+
if (!c || before.has(c.inboxItemId)) continue;
|
|
129
|
+
const who = c.origin || c.askedBy;
|
|
130
|
+
const dragon = dragonForName(who) || dragonForName(c.askedBy);
|
|
131
|
+
if (!dragon) continue;
|
|
132
|
+
return { dragon, sender: String(who).toLowerCase(), key: c.inboxItemId, caption: `${who} wants your approval${c.tool ? ` (${c.tool})` : ''}.` };
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Dedupe + per-sender cooldown for sender moments. observe() remembers the message id at once (a re-delivery is never a second
|
|
139
|
+
* moment); shown() starts the sender's cooldown, so a moment refused because the owner was typing does not silence that sender.
|
|
140
|
+
*/
|
|
141
|
+
export function createSenderWatch({ now = Date.now, cooldownMs = SENDER_COOLDOWN_MS } = {}) {
|
|
142
|
+
const seen = new Set(), last = new Map();
|
|
143
|
+
const remember = (k) => { seen.add(k); if (seen.size > 500) seen.delete(seen.values().next().value); };
|
|
144
|
+
const cool = (sender) => { const t = last.get(sender); return t != null && now() - t < cooldownMs; };
|
|
145
|
+
return {
|
|
146
|
+
observe(m, opts = {}) {
|
|
147
|
+
const id = m?.event_id || m?.id || (m?.created_at ? `${m.created_at}|${m.seq ?? ''}|${m.from ?? ''}` : null);
|
|
148
|
+
if (id != null) { if (seen.has(String(id))) return null; remember(String(id)); }
|
|
149
|
+
const mo = slotMessageMoment(m, { ...opts, now: now() });
|
|
150
|
+
return mo && !cool(mo.sender) ? mo : null;
|
|
151
|
+
},
|
|
152
|
+
card(prev, next) {
|
|
153
|
+
const mo = waitingCardMoment(prev, next);
|
|
154
|
+
if (!mo || seen.has(`card:${mo.key}`)) return null;
|
|
155
|
+
remember(`card:${mo.key}`);
|
|
156
|
+
return cool(mo.sender) ? null : mo;
|
|
157
|
+
},
|
|
158
|
+
shown(sender) { last.set(sender, now()); },
|
|
159
|
+
};
|
|
160
|
+
}
|
package/lib/quoxon/echo.js
CHANGED
|
@@ -10,6 +10,7 @@ export const ECHO_CAP = 2000;
|
|
|
10
10
|
export function echoLine(text, { cols = 100 } = {}) {
|
|
11
11
|
let t = String(text ?? '').trim();
|
|
12
12
|
if (PIN_CMD.test(t)) t = t.split(/\s+/)[0]; // a stray argument may be a PIN: never shown
|
|
13
|
+
if (/^\/verify-email(\s|$)/i.test(t) && /(^|\D)\d{6}(\D|$)/.test(t)) t = '/verify-email'; // a 6-digit run may be a PIN typed by mistake: never shown
|
|
13
14
|
const full = redactSecrets(t).text;
|
|
14
15
|
return { shown: clip(full.replace(/\s*\n\s*/g, ' '), ECHO_CAP), full };
|
|
15
16
|
}
|