quoxcli 0.4.0 → 0.6.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.
Files changed (199) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +2 -2
  3. package/bin/quox.js +19 -1
  4. package/bin/quoxon.js +6 -1
  5. package/lib/api/endpoints/admin.js +7 -0
  6. package/lib/api/endpoints/agent-attach.js +7 -0
  7. package/lib/api/endpoints/compliance.js +25 -0
  8. package/lib/api/endpoints/governance.js +7 -0
  9. package/lib/api/endpoints/inbox.js +6 -0
  10. package/lib/api/endpoints/inboxq.js +58 -0
  11. package/lib/api/endpoints/index.js +4 -0
  12. package/lib/api/endpoints/lucid.js +17 -0
  13. package/lib/api/endpoints/mcp.js +35 -0
  14. package/lib/api/endpoints/mon.js +122 -0
  15. package/lib/api/endpoints/notesq.js +2 -0
  16. package/lib/api/endpoints/quoxplan.js +51 -0
  17. package/lib/api/endpoints/tools.js +13 -0
  18. package/lib/api/endpoints/v1.js +13 -0
  19. package/lib/commands/actions.js +51 -13
  20. package/lib/commands/admin.js +35 -0
  21. package/lib/commands/agentAttach.js +15 -0
  22. package/lib/commands/approvals.js +193 -3
  23. package/lib/commands/ask.js +1 -1
  24. package/lib/commands/auth.js +64 -11
  25. package/lib/commands/backup.js +11 -4
  26. package/lib/commands/capabilities.js +3 -2
  27. package/lib/commands/chat.js +15 -0
  28. package/lib/commands/claude.js +4 -1
  29. package/lib/commands/compliance.js +109 -0
  30. package/lib/commands/connect.js +69 -0
  31. package/lib/commands/contacts.js +2 -0
  32. package/lib/commands/dev.js +6 -8
  33. package/lib/commands/doctor.js +22 -3
  34. package/lib/commands/evidence.js +2 -1
  35. package/lib/commands/gripe.js +24 -13
  36. package/lib/commands/inbox.js +37 -3
  37. package/lib/commands/inboxq.js +147 -0
  38. package/lib/commands/kuma.js +78 -5
  39. package/lib/commands/licenceLinkView.js +311 -0
  40. package/lib/commands/linkedin.js +19 -0
  41. package/lib/commands/loop.js +30 -3
  42. package/lib/commands/lucid.js +7 -0
  43. package/lib/commands/lucidGoals.js +389 -0
  44. package/lib/commands/mail.js +427 -25
  45. package/lib/commands/mcp.js +190 -0
  46. package/lib/commands/memory.js +68 -4
  47. package/lib/commands/memorySync.js +158 -0
  48. package/lib/commands/mon.js +69 -144
  49. package/lib/commands/monitoring.js +68 -0
  50. package/lib/commands/notes.js +29 -7
  51. package/lib/commands/notesSeal.js +617 -0
  52. package/lib/commands/objective.js +5 -2
  53. package/lib/commands/offload.js +52 -12
  54. package/lib/commands/plan.js +487 -52
  55. package/lib/commands/provider.js +29 -8
  56. package/lib/commands/qlarity.js +32 -1
  57. package/lib/commands/run.js +2 -1
  58. package/lib/commands/server.js +37 -0
  59. package/lib/commands/setup.js +127 -75
  60. package/lib/commands/skills.js +4 -1
  61. package/lib/commands/slot.js +49 -9
  62. package/lib/commands/status.js +92 -44
  63. package/lib/commands/tool.js +30 -0
  64. package/lib/commands/vault.js +6 -3
  65. package/lib/commands/volt.js +6 -1
  66. package/lib/commands/walkthrough.js +14 -2
  67. package/lib/commands/watch.js +11 -2
  68. package/lib/commands/wazuh.js +880 -0
  69. package/lib/commands/wazuhIntake.js +358 -0
  70. package/lib/commands/wazuhReport.js +192 -0
  71. package/lib/commands/workflow.js +47 -4
  72. package/lib/commands/x.js +50 -0
  73. package/lib/core/announce.js +86 -0
  74. package/lib/core/auth.js +20 -2
  75. package/lib/core/callMe.js +118 -0
  76. package/lib/core/client.js +27 -1
  77. package/lib/core/commandRegistrars.js +9 -0
  78. package/lib/core/config.js +42 -3
  79. package/lib/core/confirm.js +68 -7
  80. package/lib/core/crossOrgApprovals.js +69 -0
  81. package/lib/core/errors.js +52 -1
  82. package/lib/core/governedSession.js +135 -5
  83. package/lib/core/inboxResolution.js +3 -2
  84. package/lib/core/loginGuard.js +73 -0
  85. package/lib/core/offload.js +120 -0
  86. package/lib/core/org-override.js +5 -1
  87. package/lib/core/output.js +30 -5
  88. package/lib/core/profileTrust.js +182 -0
  89. package/lib/core/quoxglyph.js +22 -1
  90. package/lib/core/router.js +14 -0
  91. package/lib/core/selfUpdate.js +2 -0
  92. package/lib/core/stepUpPrompt.js +99 -0
  93. package/lib/core/version.js +1 -1
  94. package/lib/core/walkthrough.js +9 -5
  95. package/lib/dragon/select.js +2 -2
  96. package/lib/quoxon/EXTENSIONS.md +142 -19
  97. package/lib/quoxon/api.js +189 -34
  98. package/lib/quoxon/app.js +1277 -188
  99. package/lib/quoxon/approvalBatch.js +117 -0
  100. package/lib/quoxon/approvalFacts.js +56 -2
  101. package/lib/quoxon/approvalSentence.js +8 -4
  102. package/lib/quoxon/approvalsView.js +72 -12
  103. package/lib/quoxon/ask.js +4 -2
  104. package/lib/quoxon/autoOpen.js +94 -0
  105. package/lib/quoxon/backgroundWork.js +59 -0
  106. package/lib/quoxon/bang.js +8 -2
  107. package/lib/quoxon/capabilities.js +7 -2
  108. package/lib/quoxon/commandShort.js +8 -1
  109. package/lib/quoxon/commands.js +42 -12
  110. package/lib/quoxon/connect.js +5 -3
  111. package/lib/quoxon/deckLazy.js +58 -0
  112. package/lib/quoxon/delegationDragon.js +49 -0
  113. package/lib/quoxon/destructive.js +35 -0
  114. package/lib/quoxon/dnd.js +118 -0
  115. package/lib/quoxon/dragonCompanion.js +37 -7
  116. package/lib/quoxon/dragonMoments.js +78 -0
  117. package/lib/quoxon/echo.js +1 -0
  118. package/lib/quoxon/events.js +55 -8
  119. package/lib/quoxon/extensions.js +400 -38
  120. package/lib/quoxon/foreignText.js +49 -0
  121. package/lib/quoxon/gripeQueue.js +135 -0
  122. package/lib/quoxon/inboxView.js +125 -22
  123. package/lib/quoxon/keys.js +2 -2
  124. package/lib/quoxon/launcher.js +2 -1
  125. package/lib/quoxon/loopCard.js +7 -2
  126. package/lib/quoxon/loopCards.js +17 -8
  127. package/lib/quoxon/loopStream.js +20 -9
  128. package/lib/quoxon/mailView.js +71 -0
  129. package/lib/quoxon/main.js +27 -6
  130. package/lib/quoxon/markdown.js +1 -1
  131. package/lib/quoxon/model.js +15 -0
  132. package/lib/quoxon/neverAnswered.js +20 -0
  133. package/lib/quoxon/objects/away.js +43 -5
  134. package/lib/quoxon/onboarding/connectServer.js +162 -0
  135. package/lib/quoxon/onboarding/copy.js +6 -0
  136. package/lib/quoxon/onboarding/dockerOffer.js +3 -1
  137. package/lib/quoxon/onboarding/index.js +5 -43
  138. package/lib/quoxon/onboarding/probe.js +20 -1
  139. package/lib/quoxon/onboarding/view.js +16 -3
  140. package/lib/quoxon/orgSwitch.js +22 -0
  141. package/lib/quoxon/print.js +2 -0
  142. package/lib/quoxon/profileSwitch.js +7 -0
  143. package/lib/quoxon/promptHistory.js +3 -1
  144. package/lib/quoxon/render.js +68 -16
  145. package/lib/quoxon/resend.js +14 -0
  146. package/lib/quoxon/roster.js +10 -5
  147. package/lib/quoxon/rosterSessions.js +44 -0
  148. package/lib/quoxon/rows.js +3 -1
  149. package/lib/quoxon/screenContext.js +1 -1
  150. package/lib/quoxon/secretShape.js +88 -0
  151. package/lib/quoxon/slash/agents.js +2 -1
  152. package/lib/quoxon/slash/argSources.js +9 -2
  153. package/lib/quoxon/slash/args.js +4 -2
  154. package/lib/quoxon/slash/broadcast.js +48 -11
  155. package/lib/quoxon/slash/broadcastRun.js +24 -7
  156. package/lib/quoxon/slash/cliLeaves.json +98 -1
  157. package/lib/quoxon/slash/dashboard.js +2 -0
  158. package/lib/quoxon/slash/hatWalk.js +1 -1
  159. package/lib/quoxon/slash/listView.js +2 -1
  160. package/lib/quoxon/slash/palette.js +63 -1
  161. package/lib/quoxon/slash/paletteController.js +15 -5
  162. package/lib/quoxon/slash/restart.js +9 -4
  163. package/lib/quoxon/slash/stepUp.js +5 -5
  164. package/lib/quoxon/slash/stepUpCmds.js +74 -1
  165. package/lib/quoxon/slashSpinner.js +35 -0
  166. package/lib/quoxon/slotAttach.js +8 -0
  167. package/lib/quoxon/slotChat.js +24 -3
  168. package/lib/quoxon/slotFormat.js +1 -1
  169. package/lib/quoxon/slotOutbox.js +113 -0
  170. package/lib/quoxon/slotRun.js +2 -0
  171. package/lib/quoxon/suggestCommand.js +1 -1
  172. package/lib/quoxon/term.js +25 -1
  173. package/lib/quoxon/transcript.js +2 -1
  174. package/lib/quoxon/webPty.js +15 -0
  175. package/lib/quoxon/welcome.js +1 -1
  176. package/lib/quoxon/whatsnew.js +1 -1
  177. package/lib/quoxon/whatsnew.jsonl +42 -0
  178. package/lib/render/planBrief.js +614 -0
  179. package/lib/server/installFlow.js +168 -0
  180. package/lib/server/status.js +15 -1
  181. package/lib/toolOutputShape.js +61 -3
  182. package/package.json +3 -2
  183. package/vendor/dragonlab/SOURCE +3 -3
  184. package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
  185. package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
  186. package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
  187. package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
  188. package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
  189. package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
  190. package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
  191. package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
  192. package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
  193. package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
  194. package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
  195. package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
  196. package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
  197. package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
  198. package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
  199. package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
@@ -0,0 +1,168 @@
1
+ /**
2
+ * `quox server install`: the existing guided installer behind a CLI verb (ONBOARD-P1).
3
+ *
4
+ * Not a second installer. Preflight (onboarding/preflight.js), the real runner (installRunner.js: get.quox.ai ->
5
+ * setup.sh) and the finisher (finish.js: owner account, sign-in, one-time link) are the same modules the Getting
6
+ * started screen uses; this file only sequences them and turns their events into stage events:
7
+ *
8
+ * preflight docker download compose-up health owner vault link
9
+ *
10
+ * The runner's own stages fold in as: fetch + configure + build -> download, start -> compose-up, health -> health.
11
+ * `vault` is skipped here (no AI key is taken on argv); the next step is printed.
12
+ * Nothing is claimed ok that did not answer: health is the runner's own 2xx probe of the auth service.
13
+ */
14
+ import { resolve } from 'node:path';
15
+ import { QuoxUserError } from '../core/errors.js';
16
+ import { confirmInteractive } from '../core/confirm.js';
17
+ import { runPreflight, preflightPassed, defaultDeps as preflightDeps } from '../quoxon/onboarding/preflight.js';
18
+ import { defaultInstallDir } from '../quoxon/onboarding/installRunner.js';
19
+ import { passwordProblem, emailOk } from '../quoxon/onboarding/finish.js';
20
+
21
+ export const INSTALL_STAGES = ['preflight', 'docker', 'download', 'compose-up', 'health', 'owner', 'vault', 'link'];
22
+ const FOLD = { fetch: 'download', configure: 'download', build: 'download', start: 'compose-up', health: 'health' };
23
+ export const PLAN_STEPS = [
24
+ 'check Docker, Compose, git, openssl, disk, RAM and ports (read-only)',
25
+ 'download the installer from get.quox.ai and show its SHA256',
26
+ 'clone the Quox source into the install directory',
27
+ 'write secrets (.env) and a self-signed certificate',
28
+ 'download the Quox images',
29
+ 'start the services with docker compose up -d',
30
+ 'wait until the auth service answers /health with a 2xx',
31
+ 'create your owner account and sign in',
32
+ 'print a one-time dashboard sign-in link (10 minutes)'
33
+ ];
34
+ const AGAIN = 'quox server install';
35
+ const LINK_HOW = 'quox -p <profile> dashboard (mints a fresh one-time sign-in link), or re-run with --print-login-link';
36
+
37
+ /** The preflight copy is shared with the TUI ("press r"); the CLI/JSON path speaks CLI. */
38
+ const cliWords = (t) => String(t == null ? t : t).replace(/,? then press r to check again\.?/gi, ` Then run: ${AGAIN}`).replace(/,? then press r( to try again)?\.?/gi, '.').replace(/\s+\./g, '.').replace(/\.\s*\./g, '.');
39
+ /** This verb never installs Docker: the honest offer is a manual one with the doc link. */
40
+ const cliRow = (r) => ({
41
+ ...r,
42
+ ...(r.fix ? { fix: cliWords(r.fix) } : {}),
43
+ ...(r.offer === 'install' ? { offer: 'manual' } : {})
44
+ });
45
+
46
+ /** First line of stdin, no trailing newline. */
47
+ export async function readPasswordStdin(readStdin) {
48
+ const first = String(await readStdin()).split(/\r?\n/)[0];
49
+ if (!first) throw new QuoxUserError('--password-stdin got an empty first line. Pipe the password in, for example: printf %s "$PW" | quox server install --password-stdin', { exitCode: 2, code: 'empty_password' });
50
+ return first;
51
+ }
52
+
53
+ export async function defaultReadStdin() {
54
+ const chunks = [];
55
+ for await (const c of process.stdin) chunks.push(c);
56
+ return Buffer.concat(chunks).toString('utf8');
57
+ }
58
+
59
+ async function defaultPrompt(message, type) {
60
+ const { default: inquirer } = await import('inquirer');
61
+ const a = await inquirer.prompt([{ type, name: 'v', message, ...(type === 'password' ? { mask: '*' } : {}) }]);
62
+ return a.v;
63
+ }
64
+
65
+ export async function defaultMakeInstall({ dir, env = process.env } = {}) {
66
+ const { realInstall } = await import('../quoxon/onboarding/index.js');
67
+ return realInstall({ env, installDir: dir });
68
+ }
69
+
70
+ /**
71
+ * -> { ok, ... }. Throws QuoxUserError (exit 2) for refusals made before anything changes.
72
+ * opts: { dir, dryRun, yes, email, passwordStdin }; deps: see defaults; emit(event).
73
+ */
74
+ export async function runInstallFlow(opts = {}, deps = {}, emit = () => {}) {
75
+ const d = {
76
+ runChecks: runPreflight, makeInstall: defaultMakeInstall, confirm: confirmInteractive,
77
+ isTTY: () => Boolean(process.stdin.isTTY), readStdin: defaultReadStdin,
78
+ promptEmail: () => defaultPrompt('Owner email', 'input'), promptPassword: () => defaultPrompt('Owner password (hidden)', 'password'),
79
+ ...deps
80
+ };
81
+ const { dryRun = false, yes = false, passwordStdin = false } = opts;
82
+ const dir = resolve(opts.dir || defaultInstallDir());
83
+ const printLink = Boolean(opts.printLink);
84
+ const stage = (s, status, extra = {}) => emit({ type: 'stage', stage: s, status, ...extra });
85
+ const fail = (failedStage, error, next, extra = {}) => {
86
+ stage(failedStage, 'fail', { error, next });
87
+ const res = { ok: false, failedStage, error, next, ...extra };
88
+ emit({ type: 'result', ...res });
89
+ return res;
90
+ };
91
+
92
+ // ---- refusals first: nothing has been touched --------------------------------------------------------------
93
+ if (!dryRun) {
94
+ if (!yes && !d.isTTY()) throw new QuoxUserError('No terminal to answer y/N here. Read the plan with --dry-run, then re-run with --yes to install.', { exitCode: 2, code: 'no_terminal' });
95
+ if (!d.isTTY() && !passwordStdin) throw new QuoxUserError('No terminal for the owner password prompt. Pipe it in with --password-stdin (the password is never taken as an argument).', { exitCode: 2, code: 'no_terminal' });
96
+ }
97
+
98
+ // ---- preflight (read-only, also in a dry run) --------------------------------------------------------------
99
+ stage('preflight', 'running');
100
+ const rows = (await d.runChecks(preflightDeps(), () => {})).map(cliRow);
101
+ const dockerRows = rows.filter(r => r.id === 'docker' || r.id === 'compose');
102
+ const otherRows = rows.filter(r => r.id !== 'docker' && r.id !== 'compose');
103
+ const nextFor = (rs) => rs.filter(r => r.status === 'fail').map(r => `${r.fix || r.body}`).join(' ');
104
+ const bad = rows.filter(r => r.status === 'fail');
105
+ const otherBad = bad.filter(r => r.id !== 'docker' && r.id !== 'compose');
106
+ if (otherBad.length) return fail('preflight', bad.map(r => r.body).join('; '), `${nextFor(bad)} Then run: ${AGAIN}`, { checks: rows });
107
+ stage('preflight', 'ok', { detail: otherRows.map(r => r.body).join('; ') });
108
+ if (bad.length) return fail('docker', bad.map(r => r.body).join('; '), `${nextFor(bad)} Then run: ${AGAIN}`, { checks: rows });
109
+ stage('docker', 'ok', { detail: dockerRows.map(r => r.body).join('; ') });
110
+
111
+ if (dryRun) {
112
+ emit({ type: 'plan', dir, installerUrl: 'https://get.quox.ai', checks: rows.map(r => ({ id: r.id, status: r.status, body: r.body })), steps: PLAN_STEPS, changed: 'nothing' });
113
+ const res = { ok: true, dryRun: true };
114
+ emit({ type: 'result', ...res });
115
+ return res;
116
+ }
117
+
118
+ // ---- confirm, then collect the owner credentials BEFORE the long install -----------------------------------
119
+ if (!yes && !(await d.confirm(`Install Quox into ${dir} now?`, { flag: '--yes', purpose: 'install' }))) {
120
+ const res = { ok: false, declined: true, error: 'not confirmed', next: `Nothing was changed. Run ${AGAIN} --dry-run to see the plan.` };
121
+ emit({ type: 'result', ...res });
122
+ return res;
123
+ }
124
+ let email = opts.email ? String(opts.email).trim() : null;
125
+ if (!email && d.isTTY()) email = String(await d.promptEmail()).trim();
126
+ if (!emailOk(email)) throw new QuoxUserError('A valid owner email is needed: pass --email <address>.', { exitCode: 2, code: 'bad_email' });
127
+ const password = passwordStdin ? await readPasswordStdin(d.readStdin) : await d.promptPassword();
128
+ const weak = passwordProblem(password);
129
+ if (weak) throw new QuoxUserError(`The owner password is not accepted: ${weak}`, { exitCode: 2, code: 'weak_password' });
130
+
131
+ // ---- the install itself (the same runner Getting started uses) ----------------------------------------------
132
+ const { runner, finisher } = await d.makeInstall({ dir });
133
+ const open = {};
134
+ const res = await runner.run({
135
+ onEvent: (e) => {
136
+ if (e.type === 'log') { emit({ type: 'log', line: e.line }); return; }
137
+ if (e.type !== 'stage') return;
138
+ const s = FOLD[e.id]; if (!s) return;
139
+ if (e.status === 'running') { if (!open[s]) { open[s] = true; stage(s, 'running'); } return; }
140
+ if (e.status === 'ok' && (e.id === 'build' || e.id === 'start' || e.id === 'health')) { stage(s, 'ok', e.ms != null ? { ms: e.ms } : {}); open[s] = false; }
141
+ }
142
+ });
143
+ if (!res.ok) {
144
+ const failed = FOLD[res.failedStage] || res.failedStage || 'download';
145
+ if (res.interrupted) return fail(failed, 'interrupted', `Run ${AGAIN} again; finished steps are kept.`);
146
+ const next = [res.cause?.next || res.cause?.fix, res.hint || 'Finished steps are kept.', `Then run: ${AGAIN}`].filter(Boolean).join(' ');
147
+ return fail(failed, res.error || 'the installer failed', next, { logPath: res.logPath, ...(res.dashboardUrl ? { dashboardUrl: res.dashboardUrl } : {}) });
148
+ }
149
+
150
+ // ---- owner account, sign-in, vault, link ---------------------------------------------------------------------
151
+ stage('owner', 'running');
152
+ const made = await finisher.createOwner({ email, password });
153
+ if (!made.ok) {
154
+ return fail('owner', made.error || 'the owner account was not created',
155
+ made.existed ? 'An owner already exists on this server. Sign in with: quox login' : `The server is running. Create the owner with: quox setup`, { dashboardUrl: res.dashboardUrl, logPath: res.logPath });
156
+ }
157
+ const signed = await finisher.signIn({ email, password });
158
+ if (!signed.ok) return fail('owner', `owner created, but sign-in failed: ${signed.error}`, 'Sign in with: quox login', { dashboardUrl: res.dashboardUrl, logPath: res.logPath });
159
+ stage('owner', 'ok', { detail: email });
160
+ stage('vault', 'skip', { detail: 'no AI key was given here', next: 'Add one with: quox provider connect' });
161
+ const link = await finisher.loginLink(res.dashboardUrl);
162
+ if (!link.ok) return fail('link', link.error || 'could not create the sign-in link', `Open ${res.dashboardUrl || 'https://localhost:3000'} and sign in with ${email}.`, { dashboardUrl: res.dashboardUrl, logPath: res.logPath });
163
+ stage('link', 'ok', { ttlSeconds: link.ttlSeconds, linkIssued: true, ...(printLink ? {} : { linkHow: LINK_HOW }) });
164
+ const done = { ok: true, dashboardUrl: res.dashboardUrl, link: link.url, installDir: runner.installDir, logPath: res.logPath };
165
+ const { link: liveLink, ...safe } = done;
166
+ emit({ type: 'result', ...(printLink ? done : { ...safe, linkIssued: true, linkHow: LINK_HOW }) });
167
+ return done;
168
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { readFileSync, writeFileSync, mkdirSync, appendFileSync, renameSync } from 'node:fs';
6
6
  import { join, dirname } from 'node:path';
7
+ import { X509Certificate } from 'node:crypto';
7
8
  import { readInstalled } from './install.js';
8
9
 
9
10
  const STATE_FILE = 'lifecycle-state.json';
@@ -67,6 +68,18 @@ async function getJson(url, fetchImpl, timeout = 3000) {
67
68
  } catch (e) { return { ok: false, error: e?.cause?.code || e?.message || 'no answer' }; }
68
69
  }
69
70
 
71
+ /**
72
+ * H3: the SHA256 fingerprint of the certificate the edge serves (ssl/quox.crt in the checkout), in the same format
73
+ * as the `quox connect` card, so the two can be compared by eye or by script. Never throws.
74
+ */
75
+ export function certFingerprint(repoDir, { read = readFileSync } = {}) {
76
+ const file = join(repoDir || '', 'ssl', 'quox.crt');
77
+ try {
78
+ const c = new X509Certificate(read(file));
79
+ return { ok: true, file, fingerprint256: c.fingerprint256, validTo: c.validTo };
80
+ } catch (e) { return { ok: false, file, error: e?.code === 'ENOENT' ? 'no certificate file' : 'could not read the certificate' }; }
81
+ }
82
+
70
83
  /** The running build, from the collector's public /version. */
71
84
  export async function runningVersion(collectorUrl, fetchImpl = globalThis.fetch) {
72
85
  const r = await getJson(`${collectorUrl.replace(/\/+$/, '')}/version`, fetchImpl);
@@ -99,7 +112,7 @@ export async function gatherStatus({ install, sys, fetchImpl = globalThis.fetch,
99
112
  const hist = backups ? readHistory(backups) : [];
100
113
  const lastUpdate = [...hist].reverse().find((e) => e.op === 'update' || e.op === 'rollback') || null;
101
114
  const state = backups ? readState(backups) : null;
102
- return { install, installed, running, services: svc, disk, http, lastUpdate, inFlight: state && state.status === 'running' ? state : null };
115
+ return { install, installed, running, services: svc, disk, http, cert: certFingerprint(install.repoDir), lastUpdate, inFlight: state && state.status === 'running' ? state : null };
103
116
  }
104
117
 
105
118
  const svcIcon = (s) => (s.state === 'running' ? (s.health && s.health !== 'healthy' ? '⚠' : '✔') : '✖');
@@ -113,6 +126,7 @@ export function statusRows(st) {
113
126
  out.push(row('Version', `${i.release || '? no VERSION file'}${i.tag ? ` (${i.tag})` : ''} · checkout ${i.commit || '?'}${i.dirty ? ' · local changes' : ''}`));
114
127
  const rv = st.running;
115
128
  out.push(row('Running', rv.ok ? `${rv.commit || '?'}${rv.stamped === false ? ' (unstamped build)' : ''}${rv.commit && i.commit && !String(rv.commit).startsWith(i.commit) && !String(i.commit).startsWith(rv.commit) ? ' · differs from the checkout' : ''}` : `? ${rv.error}`));
129
+ if (st.cert) out.push(row('Certificate', st.cert.ok ? `SHA256 ${st.cert.fingerprint256} · valid to ${st.cert.validTo} (compare with the card \`quox connect\` shows)` : `? ${st.cert.error} (${st.cert.file})`));
116
130
  for (const h of st.http) out.push(row(`${h.ok ? '✔' : '✖'} ${h.name}`, `${h.url} · ${h.detail} · ${h.ms}ms`));
117
131
  if (!st.services.ok) out.push(row('✖ Services', `? ${st.services.error}`));
118
132
  else {
@@ -16,7 +16,7 @@
16
16
  import { parsePath } from './quoxon/rows.js';
17
17
 
18
18
  const BLOCKED = new Set(['__proto__', 'constructor', 'prototype']);
19
- const GRAMMAR = 'use a.b, a[0], list[field=value] (exact), list[field~text] (contains, any case), list[*].field (that field of every item), list[*].{a,b} (several fields of every item, one call), list[field=value].name, count(list), or several paths at once: total,count(list)';
19
+ const GRAMMAR = 'use . (the whole result), a.b, a[0], list[field=value] (exact), list[field~text] (contains, any case), list[*].field (that field of every item), list[*].{a,b} (several fields of every item, one call), list[field=value].name, count(list), or several paths at once: total,count(list)';
20
20
  export const DEFAULT_SAMPLE = 10;
21
21
  const COUNT_KEY_RE = /^(status|state|type|kind|level|severity|health|healthy|enabled|active|category|source|platform|os|role|priority|result|outcome|connected|online)$/i;
22
22
 
@@ -36,6 +36,9 @@ function splitTopLevel(e) {
36
36
  return parts;
37
37
  }
38
38
 
39
+ // "." is the whole result, as in jq (SEER 2026-11-07: a model wrote --select . and was refused).
40
+ const selectSteps = (x) => (x.trim() === '.' ? [] : parsePath(x));
41
+
39
42
  function parseSelect(expr) {
40
43
  const e = String(expr ?? '').trim();
41
44
  // GUIDES-FIX: several paths in one call ("total,active_total"): each must be a valid path on its own.
@@ -53,10 +56,10 @@ function parseSelect(expr) {
53
56
  const fn = /^([A-Za-z_]+)\s*\(([\s\S]*)\)$/.exec(e);
54
57
  if (fn) {
55
58
  if (fn[1] !== 'count') return { error: `--select: unknown function ${fn[1]}() (only count); ${GRAMMAR}` };
56
- const steps = parsePath(fn[2]);
59
+ const steps = selectSteps(fn[2]);
57
60
  return steps ? { fn: 'count', steps } : { error: `--select: count() needs a path; ${GRAMMAR}` };
58
61
  }
59
- const steps = parsePath(e);
62
+ const steps = selectSteps(e);
60
63
  return steps ? { steps } : { error: `--select: bad path ${JSON.stringify(e)}; ${GRAMMAR}` };
61
64
  }
62
65
 
@@ -270,6 +273,59 @@ function smallTopFields(body, found) {
270
273
  return Object.keys(out).length ? out : null;
271
274
  }
272
275
 
276
+
277
+ /**
278
+ * OVERSIZED-IDS: the oversized summary used to carry only a sample of whole rows, which a big row (logs, blobs)
279
+ * pushes out entirely, so the model lost every id/name and answered from another tool (keeper_backup_list,
280
+ * bob_log_tail). id_index keeps the first ID_INDEX_ROWS rows of every list (top level, or under a top key) reduced
281
+ * to identifier/status fields, inside ID_INDEX_BUDGET chars in total, and says how many rows were left out.
282
+ */
283
+ export const ID_INDEX_ROWS = 20;
284
+ export const ID_INDEX_BUDGET = 6000;
285
+ const ID_FIELD_RE = /^(id|name|title|status|state|created_at|started_at|key|host|host_id|hostname|slug|label|kind|type)$|_id$/i;
286
+ const ID_VALUE_MAX = 80;
287
+
288
+ function idRow(row) {
289
+ if (!isObj(row)) return (row === null || typeof row === 'object') ? null : (typeof row === 'string' ? row.slice(0, ID_VALUE_MAX) : row);
290
+ const r = {};
291
+ for (const [k, v] of Object.entries(row)) {
292
+ if (BLOCKED.has(k) || !ID_FIELD_RE.test(k)) continue;
293
+ if (v === null || typeof v === 'object') continue;
294
+ r[k] = typeof v === 'string' && v.length > ID_VALUE_MAX ? `${v.slice(0, ID_VALUE_MAX)}...` : v;
295
+ }
296
+ return r;
297
+ }
298
+
299
+ function idIndex(body, found) {
300
+ const lists = [];
301
+ if (Array.isArray(body)) lists.push(['(root)', body]);
302
+ else if (isObj(body)) {
303
+ for (const [k, v] of Object.entries(body)) if (!BLOCKED.has(k) && Array.isArray(v) && v.length) lists.push([k, v]);
304
+ if (!lists.length && found && found.path) lists.push([found.path, found.list]);
305
+ }
306
+ if (!lists.length) return null;
307
+ // the main list first, then the rest by size
308
+ const mainPath = found && found.path;
309
+ lists.sort((a, b) => ((b[0] === mainPath) - (a[0] === mainPath)) || b[1].length - a[1].length);
310
+ const out = {};
311
+ let used = 0;
312
+ for (const [path, list] of lists.slice(0, 8)) {
313
+ const rows = [];
314
+ for (const it of list.slice(0, ID_INDEX_ROWS)) {
315
+ const r = idRow(it);
316
+ if (r === null || (isObj(r) && !Object.keys(r).length)) continue;
317
+ const n = jsonLen(r) + 2;
318
+ if (used + n + 120 > ID_INDEX_BUDGET) break;
319
+ rows.push(r);
320
+ used += n;
321
+ }
322
+ if (!rows.length) continue;
323
+ used += 120;
324
+ out[path] = { total: list.length, shown: rows.length, omitted: list.length - rows.length, rows };
325
+ }
326
+ return Object.keys(out).length ? out : null;
327
+ }
328
+
273
329
  export function capInline(value, { maxChars = INLINE_MAX_CHARS } = {}) {
274
330
  const size = jsonLen(value);
275
331
  if (size <= maxChars) return { value, capped: false };
@@ -299,6 +355,8 @@ export function capInline(value, { maxChars = INLINE_MAX_CHARS } = {}) {
299
355
  : `--select '${lp ? `${lp}[field=value].name` : '[0]'}', --select 'count(${lp || ''})' or --select '${lp ? `${lp}[*].field` : '[*].field'}'`;
300
356
  const next = `Result is ${fullKb} KB, too large to show whole, so this is the --summary view. For the exact part you need, run the same quox tool exec again with ${example} (or --summary --limit N for a bigger sample)${via ? `; the list is inside ${via}` : ''}. Never read or post-process a saved output with a shell.`;
301
357
  const head = { oversized: true, full_size_kb: fullKb, next, top_keys: isObj(body) ? Object.keys(body).slice(0, 40) : undefined };
358
+ const idx = idIndex(body, found);
359
+ if (idx) head.id_index = idx;
302
360
  const extra = smallTopFields(body, found);
303
361
  for (const limit of [DEFAULT_SAMPLE, 5, 3, 1, 0]) {
304
362
  const s = summarize(body, { limit });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quoxcli",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Quox: the conversational front door to your self-hosted AI agent control plane",
5
5
  "type": "module",
6
6
  "bin": {
@@ -61,6 +61,7 @@
61
61
  "!lib/core/websiteSession.js",
62
62
  "!lib/quoxon/whatsnew.md",
63
63
  "README.md",
64
+ "CHANGELOG.md",
64
65
  "LICENSE"
65
66
  ],
66
67
  "repository": {
@@ -68,7 +69,7 @@
68
69
  "url": "git+https://github.com/quoxai/quoxcore.git",
69
70
  "directory": "cli"
70
71
  },
71
- "homepage": "https://cli.quox.ai",
72
+ "homepage": "https://quox.ai/cli",
72
73
  "bugs": {
73
74
  "url": "https://github.com/quoxai/quoxcore/issues"
74
75
  },
@@ -1,4 +1,4 @@
1
1
  repo: quoxai/dragonlab
2
- commit: 5b478a813554eb80793b848d14da1468ea3bdcef
3
- branch: HEAD
4
- files: 52
2
+ commit: a4dda5ef12c417689768664022b8de1245794c56
3
+ branch: integration/companions-vulcan-quoxperticus
4
+ files: 66
@@ -0,0 +1,59 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 480">
2
+ <title>Quoxperticus — the white, crimson and obsidian skill keeper</title>
3
+ <g id="wings">
4
+ <path id="left-wing" fill="#a92339" d="M 322 298 Q 252 207 137 92 L 161 209 L 114 182 L 148 279 L 112 291 L 243 337 Z"/>
5
+ <path id="right-wing" fill="#c12b42" d="M 444 296 Q 510 133 621 78 L 608 192 L 666 170 L 634 269 L 677 277 L 533 338 Z"/>
6
+ <path id="wing-ribs" fill="none" stroke="#666777" stroke-width="12" d="M 322 298 L 137 92 L 161 209 L 148 279 M 322 298 L 161 209 M 444 296 L 621 78 L 608 192 L 634 269 M 444 296 L 608 192"/>
7
+ <path id="wing-tips" fill="#f6f0f2" d="M 126 105 L 137 76 L 165 103 L 145 98 Z M 600 91 L 629 66 L 642 99 L 622 86 Z"/>
8
+ </g>
9
+ <g id="tail">
10
+ <path id="tail-coil" fill="#555763" d="M 447 353 C 521 330 611 352 631 391 C 655 437 576 454 521 432 C 578 438 610 413 593 395 Q 566 368 461 387 Z"/>
11
+ <path id="tail-edge" fill="none" stroke="#eeecef" stroke-width="10" d="M 468 367 C 537 346 611 376 619 404 Q 617 428 563 433"/>
12
+ <path id="tail-blade" fill="#e8374d" d="M 523 431 L 494 406 L 504 443 L 543 447 Z"/>
13
+ </g>
14
+ <g id="body">
15
+ <path id="obsidian-body" fill="#55545f" d="M 315 237 Q 393 210 448 257 L 479 347 Q 475 403 431 418 L 311 413 Q 272 378 292 312 Z"/>
16
+ <path id="chest-armour" fill="#e9e4ea" d="M 340 231 L 407 237 L 436 304 L 415 369 L 367 395 L 324 350 L 319 287 Z"/>
17
+ <path id="chest-joints" fill="none" stroke="#8d8696" stroke-width="7" d="M 325 275 L 417 280 M 324 305 L 430 312 M 336 339 L 419 344 M 351 367 L 399 374"/>
18
+ <path id="legs" fill="#666370" d="M 301 341 L 340 367 L 325 407 L 350 435 L 271 435 L 261 414 Z M 425 360 L 465 345 L 483 410 L 500 434 L 420 434 L 405 413 Z"/>
19
+ <path id="feet-armour" fill="#f1edf1" d="M 273 409 L 319 407 L 342 429 L 271 429 Z M 424 408 L 475 408 L 493 428 L 420 429 Z"/>
20
+ <path id="talons" fill="#161923" d="M 282 416 L 278 432 L 290 427 L 300 415 L 300 432 L 312 428 L 318 417 L 325 433 L 337 428 M 433 416 L 430 432 L 442 428 L 451 415 L 453 432 L 465 428 L 472 417 L 480 432 L 490 428"/>
21
+ </g>
22
+ <g id="neck">
23
+ <path id="neck-black" fill="#53515d" d="M 334 166 L 400 160 Q 437 211 433 267 L 389 292 L 335 261 L 348 214 Z"/>
24
+ <path id="neck-white" fill="#f5eef2" d="M 344 186 L 371 187 Q 384 227 409 261 L 386 281 L 353 252 L 360 216 Z"/>
25
+ <path id="neck-spines" fill="#ede6ef" d="M 399 178 L 442 177 L 423 196 L 450 211 L 428 221 L 457 241 L 432 247 L 449 268 L 416 265 Z"/>
26
+ </g>
27
+ <g id="head">
28
+ <path id="horn-far" fill="#faf4f4" d="M 324 117 L 297 76 L 300 26 L 315 65 L 346 105 Z"/>
29
+ <path id="horn-near" fill="#ffffff" d="M 380 108 L 401 59 L 446 22 L 425 71 L 411 124 Z"/>
30
+ <path id="horn-bands" fill="none" stroke="#292b35" stroke-width="9" d="M 300 65 L 321 69 M 312 91 L 334 89 M 408 66 L 430 61 M 397 90 L 417 91"/>
31
+ <path id="face-armour" fill="#e9e5ed" d="M 304 128 L 339 99 L 384 100 L 419 127 L 409 173 L 381 201 L 326 193 L 293 165 Z"/>
32
+ <path id="crown" fill="#e8334e" d="M 337 113 L 341 73 L 358 96 L 376 60 L 379 101 L 397 85 L 392 125 L 365 116 Z"/>
33
+ <path id="cheek-fins" fill="#faf5f7" d="M 393 143 L 455 114 L 427 151 L 457 148 L 426 174 L 438 186 L 399 190 Z"/>
34
+ <path id="muzzle" fill="#ffffff" d="M 313 163 Q 286 153 271 170 L 280 192 L 323 204 L 358 188 L 352 174 Z"/>
35
+ <path id="nostril" fill="#20212b" d="M 282 172 L 293 168 L 290 180 Z"/>
36
+ <path id="mouth" fill="none" stroke="#6a5265" stroke-width="5" d="M 281 187 Q 314 204 347 184"/>
37
+ </g>
38
+ <g id="panel">
39
+ <path id="panel-shell" fill="#6d626f" d="M 186 309 L 416 309 L 442 367 L 211 367 Z"/>
40
+ <path id="panel-screen" fill="#242333" d="M 199 317 L 406 317 L 425 354 L 218 354 Z"/>
41
+ <path id="panel-rim" fill="none" stroke="#ff6475" stroke-width="7" d="M 186 309 L 416 309 L 442 367 L 211 367 Z"/>
42
+ <path id="skill-cards" fill="#f0e9ee" d="M 224 326 L 258 326 L 264 344 L 230 344 Z M 281 326 L 315 326 L 321 344 L 287 344 Z"/>
43
+ <path id="selected-skill" fill="#ff344f" d="M 341 326 L 379 326 L 385 344 L 347 344 Z"/>
44
+ </g>
45
+ <g id="arms">
46
+ <path id="far-arm" fill="#e5dfe8" d="M 319 248 Q 288 235 267 263 L 251 290 L 228 294 L 223 312 L 272 315 L 287 284 L 325 277 Z"/>
47
+ <path id="near-arm" fill="#f7eff2" d="M 412 250 Q 448 247 451 276 L 417 304 L 382 311 L 369 299 L 389 282 L 411 277 Z"/>
48
+ <path id="finger-claws" fill="#20212b" d="M 232 297 L 225 316 L 240 308 L 249 298 L 245 317 L 260 309 M 377 296 L 367 313 L 385 308 L 393 295 L 390 312 L 407 303"/>
49
+ <path id="arm-scales" fill="none" stroke="#a699ac" stroke-width="6" d="M 281 259 L 298 275 M 421 261 L 438 277"/>
50
+ </g>
51
+ <g id="eyes">
52
+ <path id="far-eye-socket" fill="#080d19" d="M 304 147 C 304 137.0588745 312.0588745 129 322 129 C 331.9411255 129 340 137.0588745 340 147 C 340 156.9411255 331.9411255 165 322 165 C 312.0588745 165 304 156.9411255 304 147 Z"/>
53
+ <path id="far-eye-white" fill="#ffffff" d="M 310 147 C 310 140.372583 315.372583 135 322 135 C 328.627417 135 334 140.372583 334 147 C 334 153.627417 328.627417 159 322 159 C 315.372583 159 310 153.627417 310 147 Z"/>
54
+ <path id="far-pupil" fill="#080d19" d="M 318 145 C 318 138 326 138 326 145 C 326 153 318 153 318 145 Z"/>
55
+ <path id="near-eye-socket" fill="#080d19" d="M 349 147 C 349 134.8497355 358.8497355 125 371 125 C 383.1502645 125 393 134.8497355 393 147 C 393 159.1502645 383.1502645 169 371 169 C 358.8497355 169 349 159.1502645 349 147 Z"/>
56
+ <path id="near-eye-white" fill="#ffffff" d="M 356 147 C 356 138.71572875 362.71572875 132 371 132 C 379.28427125 132 386 138.71572875 386 147 C 386 155.28427125 379.28427125 162 371 162 C 362.71572875 162 356 155.28427125 356 147 Z"/>
57
+ <path id="near-pupil" fill="#080d19" d="M 364 145 C 364 137 373 137 373 145 C 373 154 364 154 364 145 Z"/>
58
+ </g>
59
+ </svg>
@@ -0,0 +1,43 @@
1
+ import {renderSvgRig} from '../../../factory/svg-rig-contour.mjs';
2
+ export const QUOXPERTICUS_CLIPS=Object.freeze([{id:'idle',title:'Quiet Expertise',duration:12000},{id:'attach',title:'At Your Command',duration:3000},{id:'skill',title:'Select a Skill',duration:6000}]);
3
+ export const STORY_DURATION=12000;
4
+ const pulse=(t,a,b)=>t<=a||t>=b?0:Math.sin(Math.PI*(t-a)/(b-a))**2;
5
+ const ease=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x);};
6
+ export function poseRig(rig,scene,ms){
7
+ const t=scene==='attach'?ms:ms%(scene==='skill'?6000:12000);
8
+ const look=scene==='skill'?pulse(t,0,3000):scene==='attach'?pulse(t,200,1900):.25*pulse(t,2000,5500);
9
+ const tap=scene==='skill'?(t<1800?ease((t-800)/1000):1-ease((t-2200)/1000)):0;
10
+ const follow=scene==='attach'?pulse(t,700,2800):scene==='idle'?.4*pulse(t,3000,8500):.25*pulse(t,3000,5800);
11
+ const paths=rig.paths.map(p=>({...p,polygons:p.polygons.map(poly=>poly.map(([x,y])=>{
12
+ if(p.group==='head'||p.group==='eyes')return [x-2*look,y+7*look];
13
+ if(p.group==='neck'){const w=Math.max(0,Math.min(1,(285-y)/75));return [x-2*look*w,y+7*look*w];}
14
+ if(p.id==='near-arm'||p.id==='finger-claws'&&x>350||p.id==='arm-scales'&&x>350){const w=Math.max(0,Math.min(1,(y-250)/45));return [x-12*tap*w,y+20*tap*w];}
15
+ if(p.group==='wings'){const w=Math.max(0,Math.min(1,(330-y)/240));return [x+(x<380?4:-4)*follow*w,y+5*follow*w];}
16
+ if(p.group==='tail'){const w=Math.max(0,Math.min(1,(x-460)/150));return [x,y-6*follow*w];}
17
+ return [x,y];
18
+ }))}));
19
+ const active=scene==='skill'&&t>=1800&&t<5000;
20
+ for(const p of paths)if(p.id==='selected-skill')p.rgb=active?[255,Math.round(70+100*pulse(t,1800,5000)),105]:[145,35,55];
21
+ if(active){
22
+ // Trace along the panel's own lower edge, clear of face and claws.
23
+ const u=Math.min(1,(t-1800)/1800),x=222+204*u,y=359;
24
+ paths.push({id:'skill-pulse',group:'effect',rgb:[255,230,240],stroke:0,erase:false,polygons:[[[x-4,y],[x,y-4],[x+4,y],[x,y+4]]]});
25
+ }
26
+ const map=new Map(paths.map(p=>[p.id,p]));
27
+ return {...rig,paths,eyes:rig.eyes.map(e=>({...e,socket:map.get(e.socket.id),white:map.get(e.white.id),pupil:map.get(e.pupil.id)}))};
28
+ }
29
+ export function renderQuoxperticus(rig,scene='idle',ms=0,options={}){
30
+ if(!QUOXPERTICUS_CLIPS.some(c=>c.id===scene))return {ok:false,code:'UNKNOWN_CLIP'};
31
+ if(!Number.isFinite(ms)||ms<0)return {ok:false,code:'INVALID_TIME'};
32
+ if(scene==='attach'&&ms>=3000&&!options.still)return renderQuoxperticus(rig,'idle',ms-3000,options);
33
+ const t=options.still?({idle:4000,attach:1200,skill:2200}[scene]):ms;
34
+ const caption=scene==='attach'?'Skills at your command.':scene==='skill'?'Select. Illuminate. Connect.':'Ready when you are.';
35
+ return {...renderSvgRig(poseRig(rig,scene,t),options),clip:scene,scene,caption};
36
+ }
37
+ export function renderStory(rig,ms,options={}){
38
+ if(!Number.isFinite(ms)||ms<0)return {ok:false,code:'INVALID_TIME'};
39
+ if(options.still)return renderQuoxperticus(rig,'skill',2200,options);
40
+ if(ms<3000)return renderQuoxperticus(rig,'attach',ms,options);
41
+ if(ms<9000)return renderQuoxperticus(rig,'skill',ms-3000,options);
42
+ return renderQuoxperticus(rig,'idle',ms-9000,options);
43
+ }
@@ -0,0 +1,57 @@
1
+ import {renderSvgRig} from '../../../factory/svg-rig-contour.mjs';
2
+ import {poseRig as previousPose,renderQuoxperticus as previous,QUOXPERTICUS_CLIPS as clips} from '../v3/character.mjs';
3
+ export const QUOXPERTICUS_CLIPS=Object.freeze([...clips,{id:'orchestrate',title:'Fleet Conductor',duration:10000}]);
4
+ export const STORY_DURATION=13000;
5
+ const clamp=x=>Math.max(0,Math.min(1,x));
6
+ const ease=x=>{x=clamp(x);return x*x*(3-2*x);};
7
+ const fit=rig=>({...rig,bounds:{left:12,top:0,right:790,bottom:460}});
8
+ export function poseRig(rig,ms,{deck=false}={}){
9
+ const t=ms%10000,energy=ease(t/1400)*(1-ease((t-7800)/2200));
10
+ const base=previousPose(fit(rig),'idle',t);
11
+ const beat=energy*(.75+.25*Math.sin(t/380));
12
+ const paths=base.paths.map(p=>({...p,polygons:p.polygons.map(poly=>poly.map(([x,y])=>{
13
+ if(p.id==='near-arm'||p.id==='finger-claws'&&x>350||p.id==='arm-scales'&&x>350){const w=clamp((450-x)/70);return [x+14*energy*w,y-65*beat*w];}
14
+ return [x,y];
15
+ }))}));
16
+ const back=[],front=[];
17
+ const shape=(list,id,rgb,points,stroke=0)=>list.push({id,group:'orchestration',rgb,polygons:[points],stroke,erase:false});
18
+ const light=(v)=>[Math.round(160+95*v),Math.round(36+170*v),Math.round(65+170*v)];
19
+ const anchor=[390,288-50*beat];
20
+ if(energy>.01){
21
+ const count=deck?6:3;
22
+ for(let i=0;i<count;i++){
23
+ const left=i<3,x=left?55:742,y=110+(i%3)*100;
24
+ const phase=((t-1400-i*230)/2200%1+1)%1;
25
+ const bright=energy*(phase>.78?1:.3);
26
+ shape(back,'rack-'+i,light(bright),[[x-28,y-20],[x+28,y-20],[x+28,y+20],[x-28,y+20],[x-28,y-20]],4);
27
+ shape(back,'rack-divider-'+i,light(bright),[[x-26,y],[x+26,y]],3);
28
+ for(let j=0;j<2;j++)shape(back,'rack-led-'+i+'-'+j,[255,Math.round(130*energy),Math.round(150*energy)],[[x-17,y-12+j*20],[x-11,y-12+j*20],[x-11,y-7+j*20],[x-17,y-7+j*20]]);
29
+ // Curved command threads arch through the space above the wings.
30
+ const control=[left?170:600,25+i%3*22];
31
+ const point=u=>[(1-u)**2*anchor[0]+2*(1-u)*u*control[0]+u*u*x,(1-u)**2*anchor[1]+2*(1-u)*u*control[1]+u*u*y];
32
+ const thread=Array.from({length:31},(_,j)=>point(j/30));
33
+ shape(back,'thread-'+i,[Math.round(180*energy),Math.round(38*energy),Math.round(65*energy)],thread,3);
34
+ if(t>1400+i*230&&t<8500){
35
+ const [wx,wy]=point(phase),r=deck?6:7;
36
+ shape(front,'worker-'+i,light(energy),[[wx-r,wy],[wx,wy-r],[wx+r,wy],[wx,wy+r]]);
37
+ // Short comet tail makes outbound travel readable.
38
+ shape(front,'worker-tail-'+i,[240,65,105],[point(Math.max(0,phase-.055)),[wx,wy]],3);
39
+ }
40
+ }
41
+ // A ring at the conducting claw keeps the origin visible.
42
+ shape(front,'command-halo',light(energy),Array.from({length:33},(_,i)=>[anchor[0]+17*Math.cos(i*Math.PI/16),anchor[1]+9*Math.sin(i*Math.PI/16)]),3);
43
+ }
44
+ return {...base,paths:[...back,...paths,...front]};
45
+ }
46
+ export function renderQuoxperticus(rig,scene='idle',ms=0,options={}){
47
+ if(scene!=='orchestrate')return previous(fit(rig),scene,ms,options);
48
+ if(!Number.isFinite(ms)||ms<0)return {ok:false,code:'INVALID_TIME'};
49
+ const t=options.still?4600:ms%10000;
50
+ return {...renderSvgRig(poseRig(rig,t,options),options),clip:scene,scene,caption:t<1400?'Raise the command.':t<7800?'Dispatch workers. Conduct the fleet.':'Workers settle. The mission continues.',visualOnly:true,demo:true};
51
+ }
52
+ export function renderStory(rig,ms,options={}){
53
+ if(!Number.isFinite(ms)||ms<0)return {ok:false,code:'INVALID_TIME'};
54
+ if(options.still)return renderQuoxperticus(rig,'orchestrate',4600,options);
55
+ if(ms<3000)return renderQuoxperticus(rig,'attach',ms,options);
56
+ return renderQuoxperticus(rig,'orchestrate',ms-3000,options);
57
+ }
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs/promises';
3
+ import {parseSvgRig} from '../../../factory/svg-rig.mjs';
4
+ import {renderQuoxperticus,QUOXPERTICUS_CLIPS} from './character.mjs';
5
+ import {artSlot,composeMessage} from '../../lucid/recovery/layout.mjs';
6
+ import {cellsToAnsi} from '../../../vendor/quox-dragon/qart.js';
7
+ const args=process.argv.slice(2),values=new Set(['--cols','--rows','--terminal-cols','--terminal-rows','--message','--scene','--at']);
8
+ for(let i=0;i<args.length;i++){
9
+ if(values.has(args[i])){if(!args[i+1]||args[i+1].startsWith('--'))throw Error('Missing option value');i++;}
10
+ else if(!['--large','--mono','--help','--watch','--attach','--intro','--still','--list'].includes(args[i]))throw Error('Unknown option: '+args[i]);
11
+ }
12
+ const value=(name,fallback)=>args.includes(name)?args[args.indexOf(name)+1]:fallback;
13
+ if(args.includes('--help')){console.log('Quoxperticus V4 forge studio. --watch --attach --intro --still --scene ID --at MS --list --large --mono --message TEXT --cols N --rows N --terminal-cols N --terminal-rows N');process.exit(0);}
14
+ if(args.includes('--list')){console.log(QUOXPERTICUS_CLIPS.map(c=>c.id+' · '+c.title).join('\n'));process.exit(0);}
15
+ const w=Number(value('--terminal-cols',process.stdout.columns||80)),h=Number(value('--terminal-rows',process.stdout.rows||24));
16
+ if(!Number.isSafeInteger(w)||!Number.isSafeInteger(h)||w<1||h<1)throw Error('Invalid terminal size');
17
+ const slot=artSlot(w,h),large=args.includes('--large')||args.includes('--intro'),explicit=large||args.includes('--cols')||args.includes('--rows');
18
+ const cols=Number(value('--cols',large?Math.min(120,w-4):Math.min(39,slot.cols))),rows=Number(value('--rows',large?Math.min(36,h-4):Math.min(11,slot.rows)));
19
+ const message=value('--message','QUOXPERTICUS\nSkills at your command.');
20
+ if(!slot.showArt&&!explicit){console.log(composeMessage(null,message,{terminalCols:w}).join('\n'));}
21
+ else{
22
+ if(cols>w-4||rows>h-2)throw Error('Requested art exceeds terminal margins');
23
+ const rig=parseSvgRig(await fs.readFile(new URL('../v2/quoxperticus-master.svg',import.meta.url),'utf8'));
24
+ const opts={cols,rows,mono:args.includes('--mono'),still:args.includes('--still'),deck:large};
25
+ const scene=value('--scene',args.includes('--attach')?'attach':'idle');
26
+ const r=renderQuoxperticus(rig,scene,Number(value('--at',0)),opts);
27
+ if(!r.ok)throw Error(r.code);
28
+ if(args.includes('--watch')||args.includes('--attach')||args.includes('--intro')){
29
+ const {watch}=await import('./watch.mjs');
30
+ await watch(rig,opts,{terminalCols:w,terminalRows:h,large,scene:args.includes('--scene')||args.includes('--attach')?scene:null,arrival:args.includes('--attach'),message:args.includes('--message')?message:null});
31
+ }else{
32
+ const shownMessage=args.includes('--message')?message:'QUOXPERTICUS\n'+r.caption;
33
+ console.log((large?[...cellsToAnsi(r.frame),...composeMessage(null,shownMessage,{terminalCols:w})]:composeMessage(r.frame,shownMessage,{terminalCols:w})).join('\n'));
34
+ }
35
+ }
@@ -0,0 +1,56 @@
1
+ import {performance} from 'node:perf_hooks';
2
+ import {QUOXPERTICUS_CLIPS,renderQuoxperticus,renderStory,STORY_DURATION} from './character.mjs';
3
+ import {composeMessage} from '../../lucid/recovery/layout.mjs';
4
+ import {cellsToAnsi} from '../../../vendor/quox-dragon/qart.js';
5
+
6
+ /** One bounded performance. No alternate screen or full-screen clearing. */
7
+ export async function watch(paths,options,config){
8
+ if(!process.stdout.isTTY||!process.stdin.isTTY)throw new Error('--watch/--intro needs an interactive terminal. Use --scene and --at for snapshots.');
9
+ const {terminalCols,terminalRows,scene,large,message}=config;
10
+ const clip=scene?QUOXPERTICUS_CLIPS.find(c=>c.id===scene):null;
11
+ const duration=(clip?.duration??STORY_DURATION)+(config.arrival?6000:0);
12
+ const realCols=process.stdout.columns,realRows=process.stdout.rows;
13
+ const start=performance.now();
14
+ const render=t=>{
15
+ const r=scene?renderQuoxperticus(paths,scene,t,options):renderStory(paths,t,options);
16
+ if(!r?.ok)throw new Error(r?.code??'Invalid story frame');
17
+ const caption=message??'QUOXPERTICUS · '+r.clip+'\n'+r.caption;
18
+ return large?[...cellsToAnsi(r.frame),...caption.split('\n')]:composeMessage(r.frame,caption,{terminalCols});
19
+ };
20
+ // Reserve enough lines for captions too; keep within the real terminal.
21
+ const height=options.rows+3;
22
+ if(height>=terminalRows)throw new Error('Animation needs room for its caption; reduce --rows.');
23
+ let ended=false,drawn=false,timer,resolveDone,rejectDone;
24
+ const wasRaw=Boolean(process.stdin.isRaw);
25
+ const done=new Promise((resolve,reject)=>{resolveDone=resolve;rejectDone=reject;});
26
+ const cleanup=(error)=>{
27
+ if(ended)return;ended=true;clearInterval(timer);
28
+ process.stdin.off('data',key);process.stdout.off('resize',resized);
29
+ process.off('SIGINT',sigint);process.off('SIGTERM',sigterm);
30
+ process.stdin.setRawMode(wasRaw);process.stdin.pause();
31
+ if(drawn)process.stdout.write(`\x1b[${height}B\r`);
32
+ process.stdout.write('\x1b[0m\x1b[?25h');
33
+ error?rejectDone(error):resolveDone();
34
+ };
35
+ const key=data=>{if(data.includes(3)||data.toString().includes('q'))cleanup();};
36
+ const sigint=()=>cleanup(),sigterm=()=>cleanup();
37
+ const resized=()=>cleanup(new Error('Terminal resized; rerun for the new art slot.'));
38
+ try{
39
+ process.stdin.setRawMode(true);process.stdin.resume();process.stdin.on('data',key);
40
+ process.stdout.on('resize',resized);process.on('SIGINT',sigint);process.on('SIGTERM',sigterm);
41
+ process.stdout.write('\x1b[?25l');
42
+ // Allocate the block once; each frame replaces only these lines.
43
+ process.stdout.write('\n'.repeat(height)+`\x1b[${height}A`);drawn=true;
44
+ const tick=()=>{
45
+ try{
46
+ if(process.stdout.columns!==realCols||process.stdout.rows!==realRows)return resized();
47
+ const t=Math.min(performance.now()-start,duration-1),lines=render(t);
48
+ if(lines.length>height)throw new Error('Message exceeds the animation block; use a shorter message or a static preview.');
49
+ process.stdout.write(Array.from({length:height},(_,i)=>'\r\x1b[2K'+(lines[i]??'')+'\n').join('')+`\x1b[${height}A`);
50
+ if(performance.now()-start>=duration)cleanup();
51
+ }catch(e){cleanup(e);}
52
+ };
53
+ tick();if(!ended)timer=setInterval(tick,1000/6);
54
+ await done;
55
+ }catch(e){cleanup(e);await done;}
56
+ }